diff --git a/.changeset/on-demand-context-consolidation.md b/.changeset/on-demand-context-consolidation.md new file mode 100644 index 00000000..4ba2e358 --- /dev/null +++ b/.changeset/on-demand-context-consolidation.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": minor +--- + +Automatically consolidate identical skill installations when moving on demand and show harness-specific discovery context estimates, with unavailable measurements clearly labeled. diff --git a/.changeset/on-demand-skill-journey.md b/.changeset/on-demand-skill-journey.md new file mode 100644 index 00000000..8945af15 --- /dev/null +++ b/.changeset/on-demand-skill-journey.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": minor +--- + +Teach task-scoped skill activation during Desktop onboarding and surface an on-demand workflow in the local Library. The Library now measures local `SKILL.md` sizes and shows an explicitly labeled estimate of full skill text kept outside active projects. diff --git a/.changeset/select-on-demand-skills.md b/.changeset/select-on-demand-skills.md new file mode 100644 index 00000000..6d326b98 --- /dev/null +++ b/.changeset/select-on-demand-skills.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": minor +--- + +Review usage-based suggestions or choose skills to keep in the searchable Library for on-demand use. Verify exact revisions before removing active installations, show instruction-size estimates, and restore original installations with Undo. diff --git a/.changeset/validated-local-boundaries.md b/.changeset/validated-local-boundaries.md new file mode 100644 index 00000000..b70c1990 --- /dev/null +++ b/.changeset/validated-local-boundaries.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Validate local history and contribution payloads at their boundaries, preserve usable telemetry when optional metadata is malformed, and reject damaged offline workspace policy caches before installation. Improve Desktop diagnostic redaction and strengthen release/bootstrap regression coverage. diff --git a/.github/release-notes.json b/.github/release-notes.json index f0bcbab2..6c3bedaf 100644 --- a/.github/release-notes.json +++ b/.github/release-notes.json @@ -3,6 +3,952 @@ "generatedFrom": "sites/docs/changelog.mdx", "docsUrl": "https://docs.selftune.dev/changelog", "entries": [ + { + "label": "2026-09-07", + "description": "Validate redacted contributions and preserve diagnostic errors", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Preserve history when optional telemetry metadata is malformed", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Validate saved workspace policies before installation", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Keep dashboard updates resilient to malformed events", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Validate quarantine receipts before restoring skills", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Validate Desktop startup markers and API errors", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Validate stored package-search provenance", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Validate hook inputs before recording sessions", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Confine managed agent cleanup to agent filenames", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Validate evidence behind lifecycle suggestions", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Preserve configuration when changing analytics consent", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-07", + "description": "Keep malformed grading files out of result lists", + "tags": ["Desktop", "CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate remote sharing and workspace responses", + "tags": ["Desktop", "Self-hosted"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Preserve live command output behavior", + "tags": ["Desktop", "CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate runtime status before displaying updates", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate correction reviews before recording", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate trace candidate requests before replay", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Keep self-host validation errors free of request contents", + "tags": ["Self-hosted"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Preserve mixed user hook groups during uninstall", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate saved team contribution queues", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Reject empty evolution strategy flags", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Check host plugin state before installation", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Reject malformed scheduling flags", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate evaluation files and model results", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate imported task metadata", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate local dashboard action requests", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Check installed Codex hook commands", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate explicit description-evolution eval files", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate body-quality response fields", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate saved synthesis release authority", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate device-link grants and approvals", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Use one draft manifest reader for readiness and fingerprints", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate explicit body evolution inputs before proposals", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Keep malformed search evidence out of mutation inputs", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Preserve OpenCode config on invalid installer input", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate staged contributor signals before upload", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate historical regression evidence before replay", + "tags": ["Desktop", "CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Preserve Claude settings during packaged hook installation", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate Team CLI upload receipts", + "tags": ["CLI", "Teams"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Reject invalid explicit baseline eval sets", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate dashboard reports and saved caches", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Keep Windows installation I/O failures diagnosable", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate local JSONL recovery records", + "tags": ["CLI", "Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate saved scheduling preferences", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Drain agent subprocess output while it runs", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Distinguish malformed plugin inventories from empty ones", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Preserve contribution choices while validating saved config", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate team assignment recovery journals", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate saved package-candidate evaluations", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate saved skill-edit capture state", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate the skill revision selected for sharing", + "tags": ["Desktop"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate local MCP skill requests", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate correction evidence without changing its meaning", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Validate the local OTLP codec boundary", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-06", + "description": "Preserve valid Codex replay output through malformed events", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate CLI update-check records", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Keep malformed saved measurements out of skill reports", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Preserve valid skill-use records during local recovery", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate saved evaluation evidence before using it", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate tool-hook inputs and preserve skill-use evidence", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Keep Codex ingestion running past malformed evidence", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate installation recovery records", + "tags": ["Sharing"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Preserve Claude settings during hook setup", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Remove hosted telemetry preparation from sync", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Preserve valid compatibility queue prefixes", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate use-once workspace ownership", + "tags": ["Sharing"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate use-once authority contracts", + "tags": ["Sharing"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate skill usage repair evidence", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Bound Pi session tree ingestion", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate Codex rollout evidence", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate Windows lock evidence", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate OpenCode session inputs", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate stored report inputs", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Preserve valid transcript evidence", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate skill unit tests before execution", + "tags": ["CLI"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate blog data before rendering", + "tags": ["Community"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate public validator responses", + "tags": ["Community"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Read evaluation evidence through a shared contract", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Read trust summaries from stored JSON safely", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Reject invalid Library filter values", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Validate saved server profiles before use", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Context savings distinguish system and project scopes", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Review on-demand skills at Library scale", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "On-demand setup stays out of your Library workspace", + "tags": ["Dashboard"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-05", + "description": "Cloud stays usable after sign-in on every plan", + "tags": ["Cloud"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-09-04", + "description": "Use specialist skills only for the task that needs them", + "tags": ["CLI", "Dashboard", "OSS"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, { "label": "2026-09-03", "description": "Skill Set license draft recovery", diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 75e50dae..0e0893d9 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,6 +6,7 @@ "endOfLine": "lf", "insertFinalNewline": true, "ignorePatterns": [ + "tools/oxlint/anti-slop/**", "**/.agent/skills", "**/.claude/skills", "**/.claude/worktrees", diff --git a/.oxlintrc.json b/.oxlintrc.json index 9efb854e..e5d28365 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,7 +5,27 @@ "suspicious": "warn" }, "plugins": ["typescript", "unicorn", "oxc", "import"], + "jsPlugins": [ + { "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }, + { "name": "anti-slop-effect", "specifier": "./tools/oxlint/anti-slop/effect/index.ts" } + ], "rules": { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + "anti-slop-effect/no-service-constructor-imports": "error", "max-lines": [ "error", { @@ -24,6 +44,19 @@ "import/no-unassigned-import": "off" }, "ignorePatterns": [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".repos/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**", ".agent/skills", ".claude/skills", ".claude/worktrees", diff --git a/README.md b/README.md index a5e0b7d4..6fe14483 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,6 @@ Your agent runs these — you just say what you want ("improve my skills", "show | | `selftune recover` | Recover SQLite from legacy/exported JSONL during migration or disaster recovery | | | `selftune badge --skill ` | Generate a health badge for your skill's README | | | `selftune telemetry` | Manage anonymous usage analytics (status, enable, disable) | -| | `selftune alpha upload` | Run a manual SQLite-backed alpha upload cycle and emit a JSON send summary | Full command reference: `selftune --help` diff --git a/apps/cli/package.json b/apps/cli/package.json index 735d4d08..ce1cefd1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -16,6 +16,7 @@ "dependencies": { "@effect/platform-bun": "4.0.0-beta.66", "@effect/platform-node-shared": "4.0.0-beta.66", + "@selftune/control-plane": "workspace:*", "@selftune/harness-claude-code": "workspace:*", "@selftune/harness-cline": "workspace:*", "@selftune/harness-codex": "workspace:*", diff --git a/apps/cli/src/commands/ingest.ts b/apps/cli/src/commands/ingest.ts index 83a4907b..50d1cdb5 100644 --- a/apps/cli/src/commands/ingest.ts +++ b/apps/cli/src/commands/ingest.ts @@ -1,6 +1,8 @@ import { parseArgs } from "node:util"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { ingestSingleSourceLive, @@ -12,19 +14,19 @@ import { CLIError } from "@selftune/runtime/utils/cli-error"; const sourceNames = ["claude", "codex", "opencode", "pi"] as const; type IngestCommandSource = (typeof sourceNames)[number]; -const sourceForCommand: Record = { +const sourceForCommand = { claude: "claude_code", codex: "codex", opencode: "opencode", pi: "pi", -}; +} satisfies Record; -const rootFlagForSource: Record = { +const rootFlagForSource = { claude: "projects-dir", codex: "codex-home", opencode: "data-dir", pi: "sessions-dir", -}; +} satisfies Record; export interface IngestActionDependencies { readonly run: ( @@ -83,16 +85,18 @@ function parseSince(value: string | undefined): Date | undefined { return since; } +const decodeStringFlag = Schema.decodeUnknownOption(Schema.String); + function optionalString(value: string | boolean | undefined): string | undefined { - return typeof value === "string" ? value : undefined; + return Option.getOrUndefined(decodeStringFlag(value)); } -export function runSingleSourceIngestCommand( - source: IngestCommandSource, - args: ReadonlyArray, - dependencies: IngestActionDependencies = liveDependencies, -) { - return Effect.fn("selftune.cli.ingest.singleSource")(function* () { +export const runSingleSourceIngestCommand = Effect.fn("selftune.cli.ingest.singleSource")( + function* ( + source: IngestCommandSource, + args: ReadonlyArray, + dependencies: IngestActionDependencies = liveDependencies, + ) { if (args.includes("--help") || args.includes("-h")) { yield* Effect.sync(() => dependencies.writeStdout(help(source))); return; @@ -145,8 +149,8 @@ export function runSingleSourceIngestCommand( `${source}: ${result.available ? `scanned ${result.scanned}, synced ${result.synced}, skipped ${result.skipped}` : "not available"}`, ), ); - })(); -} + }, +); export function isSingleSourceIngestCommand(source: string): source is IngestCommandSource { return sourceNames.some((candidate) => candidate === source); diff --git a/apps/cli/src/commands/team.ts b/apps/cli/src/commands/team.ts index 7a9ca203..4fdfd8a0 100644 --- a/apps/cli/src/commands/team.ts +++ b/apps/cli/src/commands/team.ts @@ -1,4 +1,46 @@ import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { + HostedSkillSetPublishIntentReceipt, + HostedSkillSetPublishUploadReceipt, + HostedSkillSetContributionUploadIntentReceipt, + HostedSkillSetContributionUploadReceipt, +} from "@selftune/control-plane"; +import { Option, Schema } from "effect"; + +const ApiPayload = Schema.Record(Schema.String, Schema.Json); +const ApiError = Schema.Struct({ error: Schema.optionalKey(Schema.String) }); + +function receipt(schema: Schema.Codec, payload: typeof Schema.Json.Type): A { + try { + return Schema.decodeUnknownSync(schema)(payload); + } catch { + throw new CLIError("Team API returned an invalid receipt.", "OPERATION_FAILED"); + } +} + +async function readPayload(response: Response) { + const result = Schema.decodeUnknownOption(Schema.fromJsonString(ApiPayload))( + await response.text(), + ); + if (!response.ok) { + const detail = Option.isSome(result) + ? Schema.decodeUnknownOption(ApiError)(result.value) + : Option.none(); + const message = Option.isSome(result) + ? Option.isSome(detail) + ? (detail.value.error ?? "unknown") + : "unknown" + : "invalid_response"; + throw new CLIError( + `Team API failed (${response.status}): ${message}`, + response.status === 401 || response.status === 403 ? "AUTH_MISSING" : "OPERATION_FAILED", + ); + } + if (Option.isNone(result)) { + throw new CLIError("Team API returned an invalid response.", "OPERATION_FAILED"); + } + return result.value; +} type Flags = Readonly>; export type TeamCommandDependencies = { @@ -18,7 +60,7 @@ const live: TeamCommandDependencies = { stderr: (text) => process.stderr.write(`${text}\n`), }; -function parse(args: readonly string[]): Flags { +function parse(args: readonly string[]) { const flags: Record = {}; for (let index = 0; index < args.length; index += 1) { const value = args[index]!; @@ -39,7 +81,7 @@ function parse(args: readonly string[]): Flags { } function required(flags: Flags, name: string): string { const value = flags[name]; - if (typeof value !== "string" || value.length === 0) + if (!Schema.is(Schema.String)(value) || value.length === 0) throw new CLIError(`Missing --${name}.`, "MISSING_FLAG"); return value; } @@ -67,21 +109,14 @@ async function request( dependencies: TeamCommandDependencies, path: string, bearer: string, - body: unknown, - method = "POST", + body: typeof Schema.Json.Type, ) { const response = await dependencies.fetch(endpoint(dependencies, path), { - method, + method: "POST", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, body: JSON.stringify(body), }); - const result = await response.json().catch(() => ({ error: "invalid_response" })); - if (!response.ok) - throw new CLIError( - `Team API failed (${response.status}): ${(result as { error?: string }).error ?? "unknown"}`, - response.status === 401 || response.status === 403 ? "AUTH_MISSING" : "OPERATION_FAILED", - ); - return result as Record; + return readPayload(response); } function mutationConfirmed(flags: Flags): void { if (flags.yes !== true) @@ -90,7 +125,11 @@ function mutationConfirmed(flags: Flags): void { "GUARD_BLOCKED", ); } -function output(dependencies: TeamCommandDependencies, flags: Flags, result: unknown) { +function output( + dependencies: TeamCommandDependencies, + flags: Flags, + result: typeof Schema.Json.Type, +) { dependencies.stdout(flags.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); } @@ -128,24 +167,22 @@ export async function runTeamCommand( mutationConfirmed(flags); if (operation === "publish") { const bytes = await dependencies.readFile(required(flags, "envelope")); - const intent = await request( - dependencies, - "/api/v1/service/skill-sets/publish-intent", - bearer, - { + const intent = receipt( + HostedSkillSetPublishIntentReceipt, + await request(dependencies, "/api/v1/service/skill-sets/publish-intent", bearer, { skill_set_id: required(flags, "skill-set-id"), skill_set_revision_sha256: required(flags, "revision-sha256"), envelope_sha256: required(flags, "envelope-sha256"), byte_length: bytes.byteLength, - }, + }), ); - const upload = await dependencies.fetch(String(intent.upload_url), { + const upload = await dependencies.fetch(intent.upload_url, { method: "POST", headers: { "content-type": "application/octet-stream" }, body: uploadBody(bytes), }); if (!upload.ok) throw new CLIError(`Upload failed (${upload.status}).`, "OPERATION_FAILED"); - const stored = (await upload.json()) as { storageId: string }; + const stored = receipt(HostedSkillSetPublishUploadReceipt, await readPayload(upload)); output( dependencies, flags, @@ -157,17 +194,19 @@ export async function runTeamCommand( return 0; } if (operation === "assign" || operation === "rollback") { - const body = { + const assignment = { request_id: required(flags, "request-id"), release_id: required(flags, "release-id"), target_member_id: required(flags, "member-id"), target_device_id: required(flags, "device-id"), - update_policy: - typeof flags["update-policy"] === "string" - ? flags["update-policy"] - : "ask_before_updating", - ...(operation === "rollback" ? { reason: required(flags, "reason") } : {}), + update_policy: Schema.is(Schema.String)(flags["update-policy"]) + ? flags["update-policy"] + : "ask_before_updating", }; + const body = + operation === "rollback" + ? { ...assignment, reason: required(flags, "reason") } + : assignment; output( dependencies, flags, @@ -185,21 +224,24 @@ export async function runTeamCommand( proposed_envelope_sha256: required(flags, "envelope-sha256"), proposed_byte_length: bytes.byteLength, title: required(flags, "title"), - message: typeof flags.message === "string" ? flags.message : "", + message: Schema.is(Schema.String)(flags.message) ? flags.message : "", }; - const intent = await request( - dependencies, - "/api/v1/desktop/contributions/upload-intent", - bearer, - declaration, + const intent = receipt( + HostedSkillSetContributionUploadIntentReceipt, + await request( + dependencies, + "/api/v1/desktop/contributions/upload-intent", + bearer, + declaration, + ), ); - const upload = await dependencies.fetch(String(intent.upload_url), { + const upload = await dependencies.fetch(intent.upload_url, { method: "POST", headers: { "content-type": "application/octet-stream" }, body: uploadBody(bytes), }); if (!upload.ok) throw new CLIError(`Upload failed (${upload.status}).`, "OPERATION_FAILED"); - const stored = (await upload.json()) as { storageId: string }; + const stored = receipt(HostedSkillSetContributionUploadReceipt, await readPayload(upload)); output( dependencies, flags, diff --git a/apps/cli/src/commands/uninstall.ts b/apps/cli/src/commands/uninstall.ts index e475df79..a8b982aa 100644 --- a/apps/cli/src/commands/uninstall.ts +++ b/apps/cli/src/commands/uninstall.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { CredentialStoreLive } from "@selftune/runtime/credential-store"; @@ -17,8 +18,7 @@ export type { RuntimeServiceRemovalDependencies, UninstallOptions }; export function uninstall(options: UninstallOptions): Promise { return Effect.runPromise( runUninstallProgram(options).pipe( - Effect.provide(UninstallDependenciesLive), - Effect.provide(CredentialStoreLive), + Effect.provide(UninstallDependenciesLive.pipe(Layer.provide(CredentialStoreLive))), ), ); } diff --git a/apps/cli/src/commands/uninstall/live-dependencies.ts b/apps/cli/src/commands/uninstall/live-dependencies.ts index a5feca98..fe08e233 100644 --- a/apps/cli/src/commands/uninstall/live-dependencies.ts +++ b/apps/cli/src/commands/uninstall/live-dependencies.ts @@ -38,9 +38,14 @@ import { type ServiceFailure, ServiceManagerLive, } from "@selftune/local/service"; -import { isSelftuneCommand } from "@selftune/runtime/utils/hooks"; +import { + ClaudeCodeSettings, + type ClaudeCodeHookEntry, + isSelftuneCommand, +} from "@selftune/runtime/utils/hooks"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { UninstallDependencies } from "./dependencies.js"; import { uninstallCleanupFailure } from "./errors.js"; @@ -179,44 +184,53 @@ async function removeScheduling(dryRun: boolean): Promise return { removed: false, details: "No scheduling artifacts found" }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isSelftuneHookEntry(entry: unknown): boolean { - if (!isRecord(entry)) return false; - if (typeof entry.command === "string") return isSelftuneCommand(entry.command); - if (!Array.isArray(entry.hooks)) return false; - return entry.hooks.some( - (hook) => isRecord(hook) && typeof hook.command === "string" && isSelftuneCommand(hook.command), +function withoutSelftuneHooks(entry: ClaudeCodeHookEntry): ClaudeCodeHookEntry | null { + const ownCommand = entry.command !== undefined && isSelftuneCommand(entry.command); + const hooks = entry.hooks?.filter( + (hook) => hook.command === undefined || !isSelftuneCommand(hook.command), ); + const nestedChanged = hooks !== undefined && hooks.length !== entry.hooks?.length; + if (!ownCommand && !nestedChanged) return entry; + const { command, ...withoutCommand } = entry; + const retained = ownCommand ? withoutCommand : entry; + if (nestedChanged && hooks) { + if (hooks.length === 0 && (command === undefined || ownCommand)) return null; + return { ...retained, hooks }; + } + return ownCommand && !hooks?.length ? null : retained; } export function removeHooksFromSettings(dryRun: boolean, settingsPath?: string): HookRemovalResult { const path = settingsPath ?? CLAUDE_SETTINGS_PATH; if (!existsSync(path)) return { removed: 0, details: "No settings.json found" }; - let settings: Record; + let settings: ClaudeCodeSettings; try { - const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); - if (!isRecord(parsed)) return { removed: 0, details: "Failed to parse settings.json" }; - settings = parsed; + settings = Schema.decodeUnknownSync(Schema.fromJsonString(ClaudeCodeSettings))( + readFileSync(path, "utf-8"), + ); } catch { return { removed: 0, details: "Failed to parse settings.json" }; } const hooks = settings.hooks; - if (!isRecord(hooks)) { + if (!hooks) { return { removed: 0, details: "No hooks section in settings.json" }; } let totalRemoved = 0; for (const key of Object.keys(hooks)) { const entries = hooks[key]; - if (!Array.isArray(entries)) continue; - const filtered = entries.filter((entry) => !isSelftuneHookEntry(entry)); + const filtered: ClaudeCodeHookEntry[] = []; + let changed = 0; + for (const entry of entries) { + const retained = withoutSelftuneHooks(entry); + if (retained !== entry) changed += 1; + if (retained) filtered.push(retained); + } + if (changed === 0) continue; hooks[key] = filtered; - totalRemoved += entries.length - filtered.length; + totalRemoved += changed; if (filtered.length === 0) delete hooks[key]; } diff --git a/apps/cli/src/effect-cli/argument-compatibility.ts b/apps/cli/src/effect-cli/argument-compatibility.ts index cc5e8c3e..76de1711 100644 --- a/apps/cli/src/effect-cli/argument-compatibility.ts +++ b/apps/cli/src/effect-cli/argument-compatibility.ts @@ -200,20 +200,10 @@ function invalidAlphaArguments(message: string, suggestion: string): never { throw new CLIError(`Invalid arguments: ${message}`, "INVALID_FLAG", suggestion); } -function validateAlphaLeafArguments( - subcommand: "upload" | "relink", - args: ReadonlyArray, -): void { - const allowed = - subcommand === "upload" ? new Set(["--dry-run", "--help", "-h"]) : new Set(["--help", "-h"]); +function validateAlphaLeafArguments(subcommand: "relink", args: ReadonlyArray): void { + const allowed = new Set(["--help", "-h"]); for (const argument of args) { if (allowed.has(argument)) continue; - if (subcommand === "upload" && argument.startsWith("--dry-run=")) { - invalidAlphaArguments( - "Option '--dry-run' does not take an argument", - "selftune alpha upload --help", - ); - } const message = argument.startsWith("-") ? `Unknown option '${argument}'` : `Unexpected argument '${argument}'. This command does not take positional arguments`; @@ -224,7 +214,7 @@ function validateAlphaLeafArguments( function validateAlphaArguments(args: ReadonlyArray): void { const [subcommand, ...subcommandArgs] = args; if (!subcommand || subcommand === "--help" || subcommand === "-h") return; - if (subcommand === "upload" || subcommand === "relink") { + if (subcommand === "relink") { validateAlphaLeafArguments(subcommand, subcommandArgs); return; } diff --git a/apps/cli/src/effect-cli/commands/alpha.ts b/apps/cli/src/effect-cli/commands/alpha.ts index 3b87f055..568a8361 100644 --- a/apps/cli/src/effect-cli/commands/alpha.ts +++ b/apps/cli/src/effect-cli/commands/alpha.ts @@ -1,17 +1,13 @@ import * as Effect from "effect/Effect"; import * as Command from "effect/unstable/cli/Command"; -import * as Flag from "effect/unstable/cli/Flag"; -import type { AlphaUploadInput } from "@selftune/runtime/alpha-program"; import { CLIError } from "@selftune/runtime/utils/cli-error"; export interface AlphaCommandActions { - readonly upload: (input: AlphaUploadInput) => Effect.Effect; readonly relink: () => Effect.Effect; } interface AlphaProgramModule { - readonly runAlphaUploadProgram: (input: AlphaUploadInput) => Effect.Effect; readonly runAlphaRelinkProgram: () => Effect.Effect; } @@ -27,7 +23,7 @@ function failureMessage(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } -function alphaImportFailure(action: "upload" | "relink", cause: unknown): CLIError { +function alphaImportFailure(action: "relink", cause: unknown): CLIError { return new CLIError( `Unable to load alpha ${action} support: ${failureMessage(cause)}`, "INTERNAL_ERROR", @@ -35,16 +31,6 @@ function alphaImportFailure(action: "upload" | "relink", cause: unknown): CLIErr ); } -export const runAlphaUploadActionWithDependencies = Effect.fn("selftune.cli.alpha.upload")( - function* (input: AlphaUploadInput, dependencies: AlphaActionDependencies) { - const alpha = yield* Effect.tryPromise({ - try: dependencies.loadModule, - catch: (cause) => alphaImportFailure("upload", cause), - }); - yield* alpha.runAlphaUploadProgram(input); - }, -); - export const runAlphaRelinkActionWithDependencies = Effect.fn("selftune.cli.alpha.relink")( function* (dependencies: AlphaActionDependencies) { const alpha = yield* Effect.tryPromise({ @@ -56,41 +42,22 @@ export const runAlphaRelinkActionWithDependencies = Effect.fn("selftune.cli.alph ); export const liveAlphaCommandActions: AlphaCommandActions = { - upload: (input) => runAlphaUploadActionWithDependencies(input, LIVE_ALPHA_DEPENDENCIES), relink: () => runAlphaRelinkActionWithDependencies(LIVE_ALPHA_DEPENDENCIES), }; export function makeAlphaCommand(actions: AlphaCommandActions = liveAlphaCommandActions) { - const upload = Command.make( - "upload", - { - dryRun: Flag.boolean("dry-run").pipe( - Flag.withDescription("Stage and summarize records without sending them"), - ), - }, - (input) => actions.upload({ dryRun: input.dryRun }), - ).pipe( - Command.withDescription("Run a manual alpha data upload cycle"), - Command.withExamples([ - { - command: "selftune alpha upload --dry-run", - description: "Preview the records staged for upload", - }, - ]), - ); - const relink = Command.make("relink", {}, actions.relink).pipe( Command.withDescription("Re-authenticate with the cloud using device-code approval"), Command.withExamples([ { command: "selftune alpha relink", - description: "Replace the local upload key after browser approval", + description: "Replace the local cloud credential after browser approval", }, ]), ); return Command.make("alpha").pipe( - Command.withSubcommands([upload, relink]), - Command.withDescription("Manage alpha uploads and cloud authentication"), + Command.withSubcommands([relink]), + Command.withDescription("Manage cloud authentication"), ); } diff --git a/apps/cli/src/effect-cli/commands/create/actions.ts b/apps/cli/src/effect-cli/commands/create/actions.ts index 8a7410f8..2fc0ddd2 100644 --- a/apps/cli/src/effect-cli/commands/create/actions.ts +++ b/apps/cli/src/effect-cli/commands/create/actions.ts @@ -8,6 +8,7 @@ import type { } from "@selftune/runtime/create/publish"; import type { CreateReplayResult, RunCreateReplayOptions } from "@selftune/runtime/create/replay"; import type { + createScaffoldJsonResult, CreateScaffoldResult, RunCreateScaffoldOptions, } from "@selftune/runtime/create/scaffold"; @@ -35,7 +36,7 @@ interface StatusModule { interface ScaffoldModule { readonly runCreateScaffold: (options: RunCreateScaffoldOptions) => CreateScaffoldResult; readonly formatCreateScaffoldResult: (result: CreateScaffoldResult) => string; - readonly createScaffoldJsonResult: (result: CreateScaffoldResult) => unknown; + readonly createScaffoldJsonResult: typeof createScaffoldJsonResult; } interface CheckModule { @@ -141,13 +142,13 @@ export function writeCreateActionResult( action: CreateAction, output: CreateActionOutput, json: boolean, - result: unknown, + formatJson: () => string, format: () => string, exitCode: number, ): Effect.Effect { return Effect.try({ try: () => { - output.print(json || !output.isStdoutTTY() ? JSON.stringify(result, null, 2) : format()); + output.print(json || !output.isStdoutTTY() ? formatJson() : format()); output.setExitCode(exitCode); }, catch: (cause) => toCreateCliError(action, cause), @@ -182,7 +183,7 @@ export const makeLiveCreateCommandActions = ( "init", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatInitResult(result), createExitCode.init(), ); @@ -200,7 +201,7 @@ export const makeLiveCreateCommandActions = ( "status", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatCreateCheckResult(result), createExitCode.status(), ); @@ -218,7 +219,7 @@ export const makeLiveCreateCommandActions = ( "scaffold", dependencies.output, input.json, - runtimeModule.createScaffoldJsonResult(result), + () => JSON.stringify(runtimeModule.createScaffoldJsonResult(result), null, 2), () => runtimeModule.formatCreateScaffoldResult(result), createExitCode.scaffold(), ); @@ -236,7 +237,7 @@ export const makeLiveCreateCommandActions = ( "check", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatCreateCheckResult(result), createExitCode.check(result.ok), ); @@ -261,7 +262,7 @@ export const makeLiveCreateCommandActions = ( "replay", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatReplayResult(result), createExitCode.replay(result.failed), ); @@ -286,7 +287,7 @@ export const makeLiveCreateCommandActions = ( "baseline", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatBaselineResult(result), createExitCode.baseline(result.adds_value), ); @@ -309,7 +310,7 @@ export const makeLiveCreateCommandActions = ( "report", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatCreatePackageBenchmarkReport(result), createExitCode.report(result.summary.evaluation_passed), ); @@ -332,7 +333,7 @@ export const makeLiveCreateCommandActions = ( "publish", dependencies.output, input.json, - result, + () => JSON.stringify(result, null, 2), () => runtimeModule.formatCreatePublishResult(result), createExitCode.publish(result.published), ); diff --git a/apps/cli/src/effect-cli/commands/creator-contributions.ts b/apps/cli/src/effect-cli/commands/creator-contributions.ts index f21f2edf..a2792849 100644 --- a/apps/cli/src/effect-cli/commands/creator-contributions.ts +++ b/apps/cli/src/effect-cli/commands/creator-contributions.ts @@ -89,7 +89,7 @@ function execute( run: (runtime: CreatorContributionsModule) => Result, format: (runtime: CreatorContributionsModule, result: Result) => string, ) { - return Effect.fn(`selftune.cli.creatorContributions.${operation}`)(function* () { + return Effect.gen(function* () { const runtime = yield* Effect.tryPromise({ try: dependencies.loadModule, catch: importFailure, @@ -109,7 +109,7 @@ function execute( }, catch: (cause) => toCliError(operation, cause), }); - })(); + }).pipe(Effect.withSpan(`selftune.cli.creatorContributions.${operation}`)); } export function makeLiveCreatorContributionsCommandActions( diff --git a/apps/cli/src/effect-cli/commands/daemon.ts b/apps/cli/src/effect-cli/commands/daemon.ts index 1aa0172e..190a408b 100644 --- a/apps/cli/src/effect-cli/commands/daemon.ts +++ b/apps/cli/src/effect-cli/commands/daemon.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Argument from "effect/unstable/cli/Argument"; import * as Command from "effect/unstable/cli/Command"; import * as Flag from "effect/unstable/cli/Flag"; @@ -15,6 +16,7 @@ import type { DaemonStopInput, } from "@selftune/local/daemon-cli-contract"; import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { OperationFailure } from "../operation-failure.js"; export interface DaemonCommandActions { readonly run: (input: DaemonRunInput) => Effect.Effect; @@ -40,22 +42,6 @@ const LIVE_DAEMON_DEPENDENCIES: DaemonActionDependencies = { loadModule: () => import("@selftune/local/daemon"), }; -interface DaemonFailureShape { - readonly operation: string; - readonly message: string; -} - -function isDaemonFailure(cause: unknown): cause is DaemonFailureShape { - return ( - typeof cause === "object" && - cause !== null && - "operation" in cause && - typeof cause.operation === "string" && - "message" in cause && - typeof cause.message === "string" - ); -} - function failureMessage(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } @@ -73,8 +59,9 @@ function daemonImportFailure( function toDaemonCliError(cause: unknown): CLIError { if (cause instanceof CLIError) return cause; - const operation = isDaemonFailure(cause) ? cause.operation : undefined; - const message = isDaemonFailure(cause) ? cause.message : failureMessage(cause); + const failure = Option.getOrNull(Schema.decodeUnknownOption(OperationFailure)(cause)); + const operation = failure?.operation; + const message = failure?.message ?? failureMessage(cause); return new CLIError( message, operation === "parse" ? "INVALID_FLAG" : "OPERATION_FAILED", diff --git a/apps/cli/src/effect-cli/commands/library.ts b/apps/cli/src/effect-cli/commands/library.ts index d2f24969..95c4e899 100644 --- a/apps/cli/src/effect-cli/commands/library.ts +++ b/apps/cli/src/effect-cli/commands/library.ts @@ -18,8 +18,8 @@ export type LibraryAction = (input: LibraryProgramInput) => Effect.Effect Effect.Effect; - readonly formatLibraryResult: (result: LibraryProgramResult) => string; + ) => Effect.Effect, CLIError>; + readonly formatLibraryResult: (result: Pick) => string; } export interface LibraryActionDependencies { @@ -58,11 +58,8 @@ function toCliError(operation: string, cause: unknown): CLIError { ); } -export function runLibraryActionWithDependencies( - input: LibraryProgramInput, - dependencies: LibraryActionDependencies, -) { - return Effect.fn(`selftune.cli.library.${input.operation}`)(function* () { +export const runLibraryActionWithDependencies = Effect.fn( + function* (input: LibraryProgramInput, dependencies: LibraryActionDependencies) { const runtime = yield* Effect.tryPromise({ try: dependencies.loadModule, catch: importFailure, @@ -83,8 +80,9 @@ export function runLibraryActionWithDependencies( }, catch: (cause) => toCliError(input.operation, cause), }); - })(); -} + }, + (effect, input) => effect.pipe(Effect.withSpan(`selftune.cli.library.${input.operation}`)), +); export function makeLiveLibraryAction( dependencies: LibraryActionDependencies = LIVE_DEPENDENCIES, diff --git a/apps/cli/src/effect-cli/commands/project.ts b/apps/cli/src/effect-cli/commands/project.ts index 4ab8e1ce..9d8dfd4c 100644 --- a/apps/cli/src/effect-cli/commands/project.ts +++ b/apps/cli/src/effect-cli/commands/project.ts @@ -5,11 +5,13 @@ import * as Command from "effect/unstable/cli/Command"; import * as Flag from "effect/unstable/cli/Flag"; import { CLIError } from "@selftune/runtime/utils/cli-error"; +import type { + ProjectConfigurationInput, + ProjectConfigurationPlan, + applyProjectConfiguration, +} from "@selftune/runtime/project-provisioning"; -export interface ProjectConfigurationInput { - readonly projectRoot: string; - readonly skillSetIds: ReadonlyArray; -} +export type { ProjectConfigurationInput } from "@selftune/runtime/project-provisioning"; export type ProjectAction = ( operation: "plan" | "configure" | "init", @@ -17,12 +19,6 @@ export type ProjectAction = ( jsonRequested: boolean, ) => Effect.Effect; -interface ProjectModule { - readonly planProjectConfiguration: (input: ProjectConfigurationInput) => unknown; - readonly applyProjectConfiguration: (input: ProjectConfigurationInput) => Promise; - readonly initializeReactProject: (input: ProjectConfigurationInput) => Promise; -} - const PROJECT_HELP = `selftune project — Set up project Skill Sets Usage: @@ -42,28 +38,24 @@ function toCliError(operation: string, cause: unknown): CLIError { ); } -function format(value: unknown, json: boolean): string { +export function formatProjectResult( + value: ProjectConfigurationPlan | Awaited>, + json: boolean, +): string { if (json) return JSON.stringify(value, null, 2); - if (typeof value === "object" && value !== null && "plan" in value) { - const result = value as { plan: { creates: number; unchanged: number; conflicts: number } }; - return `Configured project: ${result.plan.creates} create, ${result.plan.unchanged} unchanged, ${result.plan.conflicts} conflicts.`; + if ("plan" in value) { + return `Configured project: ${value.plan.creates} create, ${value.plan.unchanged} unchanged, ${value.plan.conflicts} conflicts.`; } - const plan = value as { - creates: number; - unchanged: number; - conflicts: number; - missingDependencies: number; - }; - return `${plan.creates} create, ${plan.unchanged} unchanged, ${plan.conflicts} conflicts, ${plan.missingDependencies} download${plan.missingDependencies === 1 ? "" : "s"}.`; + return `${value.creates} create, ${value.unchanged} unchanged, ${value.conflicts} conflicts, ${value.missingDependencies} download${value.missingDependencies === 1 ? "" : "s"}.`; } export function makeLiveProjectAction(): ProjectAction { return (operation, input, jsonRequested) => Effect.gen(function* () { - const runtime = (yield* Effect.tryPromise({ + const runtime = yield* Effect.tryPromise({ try: () => import("@selftune/runtime/project-provisioning"), catch: (cause) => toCliError(operation, cause), - })) as ProjectModule; + }); const value = yield* operation === "plan" ? Effect.try({ try: () => runtime.planProjectConfiguration(input), @@ -76,7 +68,9 @@ export function makeLiveProjectAction(): ProjectAction { : runtime.applyProjectConfiguration(input), catch: (cause) => toCliError(operation, cause), }); - yield* Console.log(format(value, jsonRequested || process.stdout.isTTY !== true)); + yield* Console.log( + formatProjectResult(value, jsonRequested || process.stdout.isTTY !== true), + ); }); } diff --git a/apps/cli/src/effect-cli/commands/recover.ts b/apps/cli/src/effect-cli/commands/recover.ts index da32ada4..0d6b4455 100644 --- a/apps/cli/src/effect-cli/commands/recover.ts +++ b/apps/cli/src/effect-cli/commands/recover.ts @@ -25,7 +25,7 @@ Usage: Use this only for legacy backfill or explicit export-based recovery. Normal operation should use \`selftune sync\`, which replays native source data into -SQLite and preserves alpha-upload compatibility. +SQLite while retaining local history. Options: --full Rebuild SQLite tables from scratch diff --git a/apps/cli/src/effect-cli/commands/registry.ts b/apps/cli/src/effect-cli/commands/registry.ts index 704837bd..a6f2322e 100644 --- a/apps/cli/src/effect-cli/commands/registry.ts +++ b/apps/cli/src/effect-cli/commands/registry.ts @@ -85,11 +85,8 @@ function toCliError( ); } -export function runRegistryActionWithDependencies( - input: RegistryProgramInput, - dependencies: RegistryActionDependencies, -) { - return Effect.fn(`selftune.cli.registry.${input.operation}`)(function* () { +export const runRegistryActionWithDependencies = Effect.fn( + function* (input: RegistryProgramInput, dependencies: RegistryActionDependencies) { const runtime = yield* Effect.tryPromise({ try: dependencies.loadModule, catch: importFailure, @@ -112,8 +109,9 @@ export function runRegistryActionWithDependencies( }, catch: (cause) => toCliError(input.operation, cause), }); - })(); -} + }, + (effect, input) => effect.pipe(Effect.withSpan(`selftune.cli.registry.${input.operation}`)), +); export function makeLiveRegistryAction( dependencies: RegistryActionDependencies = LIVE_DEPENDENCIES, diff --git a/apps/cli/src/effect-cli/commands/service.ts b/apps/cli/src/effect-cli/commands/service.ts index b865de4c..f2753aad 100644 --- a/apps/cli/src/effect-cli/commands/service.ts +++ b/apps/cli/src/effect-cli/commands/service.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Argument from "effect/unstable/cli/Argument"; import * as Command from "effect/unstable/cli/Command"; import * as Flag from "effect/unstable/cli/Flag"; @@ -14,6 +15,7 @@ import type { ServiceMaintenanceInput, } from "@selftune/local/service/maintenance/contract"; import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { OperationFailure } from "../operation-failure.js"; export interface ServiceCommandActions { readonly install: (input: ServiceInput) => Effect.Effect; @@ -56,22 +58,6 @@ const LIVE_SERVICE_DEPENDENCIES: ServiceActionDependencies = { loadMaintenanceModule: () => import("@selftune/local/service/maintenance/programs"), }; -interface ServiceFailureShape { - readonly operation: string; - readonly message: string; -} - -function isServiceFailure(cause: unknown): cause is ServiceFailureShape { - return ( - typeof cause === "object" && - cause !== null && - "operation" in cause && - typeof cause.operation === "string" && - "message" in cause && - typeof cause.message === "string" - ); -} - function failureMessage(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } @@ -89,8 +75,9 @@ function serviceImportFailure( function toServiceCliError(cause: unknown): CLIError { if (cause instanceof CLIError) return cause; - const operation = isServiceFailure(cause) ? cause.operation : undefined; - const message = isServiceFailure(cause) ? cause.message : failureMessage(cause); + const failure = Option.getOrNull(Schema.decodeUnknownOption(OperationFailure)(cause)); + const operation = failure?.operation; + const message = failure?.message ?? failureMessage(cause); return new CLIError( message, operation === "parse" ? "INVALID_FLAG" : "OPERATION_FAILED", diff --git a/apps/cli/src/effect-cli/commands/sets.ts b/apps/cli/src/effect-cli/commands/sets.ts index 612b5831..e43bf7d1 100644 --- a/apps/cli/src/effect-cli/commands/sets.ts +++ b/apps/cli/src/effect-cli/commands/sets.ts @@ -66,12 +66,12 @@ function toCliError(operation: string, cause: unknown): CLIError { ); } -export function runSkillSetsActionWithDependencies( - input: SkillSetsProgramInput, - jsonRequested: boolean, - dependencies: SkillSetsActionDependencies, -) { - return Effect.fn(`selftune.cli.sets.${input.operation}`)(function* () { +export const runSkillSetsActionWithDependencies = Effect.fn( + function* ( + input: SkillSetsProgramInput, + jsonRequested: boolean, + dependencies: SkillSetsActionDependencies, + ) { const runtime = yield* Effect.tryPromise({ try: dependencies.loadModule, catch: importFailure, @@ -94,8 +94,9 @@ export function runSkillSetsActionWithDependencies( }, catch: (cause) => toCliError(input.operation, cause), }); - })(); -} + }, + (effect, input) => effect.pipe(Effect.withSpan(`selftune.cli.sets.${input.operation}`)), +); export function makeLiveSkillSetsAction( dependencies: SkillSetsActionDependencies = LIVE_DEPENDENCIES, diff --git a/apps/cli/src/effect-cli/commands/sync.ts b/apps/cli/src/effect-cli/commands/sync.ts index 94405cf5..59fc268b 100644 --- a/apps/cli/src/effect-cli/commands/sync.ts +++ b/apps/cli/src/effect-cli/commands/sync.ts @@ -82,41 +82,39 @@ function toCliError( ); } -export function runSyncActionWithDependencies( +export const runSyncActionWithDependencies = Effect.fn("selftune.cli.sync")(function* ( input: SyncCommandInput, jsonRequested: boolean, dependencies: SyncActionDependencies, ) { - return Effect.fn("selftune.cli.sync")(function* () { - const jsonOutput = yield* Effect.try({ - try: () => jsonRequested || !dependencies.isTTY(), - catch: toCliError, - }); - const runtime = yield* Effect.tryPromise({ - try: dependencies.loadModule, - catch: importFailure, - }); - const program = yield* Effect.try({ - try: () => runtime.runSyncProgram({ ...input, jsonOutput }), - catch: (cause) => toCliError(cause, runtime.isSyncInternalFailure), - }); - const progressLayer = runtime.makeSyncProgressLayer( - jsonOutput ? () => {} : dependencies.writeStderr, - ); - const result = yield* program.pipe( - Effect.provide(Layer.merge(runtime.syncLiveLayer, progressLayer)), - Effect.mapError((cause) => toCliError(cause, runtime.isSyncInternalFailure)), - ); - yield* Effect.try({ - try: () => { - for (const message of result.stdout) dependencies.writeStdout(message); - for (const message of result.stderr) dependencies.writeStderr(message); - dependencies.setExitCode(result.exitCode); - }, - catch: toCliError, - }); - })(); -} + const jsonOutput = yield* Effect.try({ + try: () => jsonRequested || !dependencies.isTTY(), + catch: toCliError, + }); + const runtime = yield* Effect.tryPromise({ + try: dependencies.loadModule, + catch: importFailure, + }); + const program = yield* Effect.try({ + try: () => runtime.runSyncProgram({ ...input, jsonOutput }), + catch: (cause) => toCliError(cause, runtime.isSyncInternalFailure), + }); + const progressLayer = runtime.makeSyncProgressLayer( + jsonOutput ? () => {} : dependencies.writeStderr, + ); + const result = yield* program.pipe( + Effect.provide(Layer.merge(runtime.syncLiveLayer, progressLayer)), + Effect.mapError((cause) => toCliError(cause, runtime.isSyncInternalFailure)), + ); + yield* Effect.try({ + try: () => { + for (const message of result.stdout) dependencies.writeStdout(message); + for (const message of result.stderr) dependencies.writeStderr(message); + dependencies.setExitCode(result.exitCode); + }, + catch: toCliError, + }); +}); export function makeLiveSyncAction( dependencies: SyncActionDependencies = LIVE_DEPENDENCIES, diff --git a/apps/cli/src/effect-cli/commands/uninstall.ts b/apps/cli/src/effect-cli/commands/uninstall.ts index 841ad4c5..bd50355f 100644 --- a/apps/cli/src/effect-cli/commands/uninstall.ts +++ b/apps/cli/src/effect-cli/commands/uninstall.ts @@ -1,6 +1,9 @@ /* oxlint-disable no-console -- the command boundary owns the public JSON result */ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Command from "effect/unstable/cli/Command"; import * as Flag from "effect/unstable/cli/Flag"; @@ -32,15 +35,11 @@ Removes: 8. Ingest marker files 9. npm global package (with --npm-uninstall)`; +const decodeFailureMessage = Schema.decodeUnknownOption(Schema.Struct({ message: Schema.String })); + function failureMessage(cause: unknown): string { - if ( - typeof cause === "object" && - cause !== null && - "message" in cause && - typeof cause.message === "string" - ) { - return cause.message; - } + const decoded = decodeFailureMessage(cause); + if (Option.isSome(decoded)) return decoded.value.message; return cause instanceof Error ? cause.message : String(cause); } @@ -73,8 +72,7 @@ const runLiveUninstall = Effect.fn("selftune.cli.uninstall.live")(function* ( }); const result = yield* runUninstallProgram(input).pipe( - Effect.provide(UninstallDependenciesLive), - Effect.provide(CredentialStoreLive), + Effect.provide(UninstallDependenciesLive.pipe(Layer.provide(CredentialStoreLive))), Effect.mapError(toUninstallCliError), ); yield* Effect.sync(() => console.log(JSON.stringify(result, null, 2))); diff --git a/apps/cli/src/effect-cli/commands/watch.ts b/apps/cli/src/effect-cli/commands/watch.ts index b3e897e5..60ed7049 100644 --- a/apps/cli/src/effect-cli/commands/watch.ts +++ b/apps/cli/src/effect-cli/commands/watch.ts @@ -80,34 +80,32 @@ function toCliError( ); } -export function runWatchActionWithDependencies( +export const runWatchActionWithDependencies = Effect.fn("selftune.cli.watch")(function* ( input: WatchProgramInput, dependencies: WatchActionDependencies, ) { - return Effect.fn("selftune.cli.watch")(function* () { - const runtime = yield* Effect.tryPromise({ - try: dependencies.loadModule, - catch: importFailure, - }); - const program = yield* Effect.try({ - try: () => runtime.runWatchProgram(input), - catch: (cause) => toCliError(cause, runtime.isWatchInternalFailure), - }); - const diagnosticsLayer = runtime.makeWatchDiagnosticsLayer(dependencies.writeStderr); - const result = yield* program.pipe( - Effect.provide(Layer.merge(runtime.watchLiveLayer, diagnosticsLayer)), - Effect.mapError((cause) => toCliError(cause, runtime.isWatchInternalFailure)), - ); - yield* Effect.try({ - try: () => { - for (const message of result.stderr) dependencies.writeStderr(message); - for (const message of result.stdout) dependencies.writeStdout(message); - dependencies.setExitCode(result.exitCode); - }, - catch: toCliError, - }); - })(); -} + const runtime = yield* Effect.tryPromise({ + try: dependencies.loadModule, + catch: importFailure, + }); + const program = yield* Effect.try({ + try: () => runtime.runWatchProgram(input), + catch: (cause) => toCliError(cause, runtime.isWatchInternalFailure), + }); + const diagnosticsLayer = runtime.makeWatchDiagnosticsLayer(dependencies.writeStderr); + const result = yield* program.pipe( + Effect.provide(Layer.merge(runtime.watchLiveLayer, diagnosticsLayer)), + Effect.mapError((cause) => toCliError(cause, runtime.isWatchInternalFailure)), + ); + yield* Effect.try({ + try: () => { + for (const message of result.stderr) dependencies.writeStderr(message); + for (const message of result.stdout) dependencies.writeStdout(message); + dependencies.setExitCode(result.exitCode); + }, + catch: toCliError, + }); +}); export function makeLiveWatchAction( dependencies: WatchActionDependencies = LIVE_DEPENDENCIES, diff --git a/apps/cli/src/effect-cli/commands/workflows.ts b/apps/cli/src/effect-cli/commands/workflows.ts index c491ad7a..4132afe4 100644 --- a/apps/cli/src/effect-cli/commands/workflows.ts +++ b/apps/cli/src/effect-cli/commands/workflows.ts @@ -73,12 +73,12 @@ function toCliError(operation: string, cause: unknown): CLIError { ); } -export function runWorkflowsActionWithDependencies( - input: WorkflowProgramInput, - jsonRequested: boolean, - dependencies: WorkflowsActionDependencies, -) { - return Effect.fn(`selftune.cli.workflows.${input.operation}`)(function* () { +export const runWorkflowsActionWithDependencies = Effect.fn( + function* ( + input: WorkflowProgramInput, + jsonRequested: boolean, + dependencies: WorkflowsActionDependencies, + ) { const runtime = yield* Effect.tryPromise({ try: dependencies.loadModule, catch: importFailure, @@ -102,8 +102,9 @@ export function runWorkflowsActionWithDependencies( }, catch: (cause) => toCliError(input.operation, cause), }); - })(); -} + }, + (effect, input) => effect.pipe(Effect.withSpan(`selftune.cli.workflows.${input.operation}`)), +); export function makeLiveWorkflowsAction( dependencies: WorkflowsActionDependencies = LIVE_DEPENDENCIES, diff --git a/apps/cli/src/effect-cli/operation-failure.ts b/apps/cli/src/effect-cli/operation-failure.ts new file mode 100644 index 00000000..99580f97 --- /dev/null +++ b/apps/cli/src/effect-cli/operation-failure.ts @@ -0,0 +1,7 @@ +import * as Schema from "effect/Schema"; + +/** Fields the CLI consumes from failures returned by lazy-loaded local programs. */ +export const OperationFailure = Schema.Struct({ + operation: Schema.String, + message: Schema.String, +}); diff --git a/apps/cli/src/effect-cli/program.ts b/apps/cli/src/effect-cli/program.ts index b332d851..75dd7335 100644 --- a/apps/cli/src/effect-cli/program.ts +++ b/apps/cli/src/effect-cli/program.ts @@ -84,7 +84,6 @@ const disabledTestAlphaAction = (action: keyof AlphaCommandActions) => () => ); const disabledTestAlphaActions: AlphaCommandActions = { - upload: disabledTestAlphaAction("upload"), relink: disabledTestAlphaAction("relink"), }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 54c59db3..65c04b8f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/desktop", - "version": "0.4.11", + "version": "0.4.12", "private": true, "description": "Native desktop control plane for SelfTune skill observability and improvement", "homepage": "https://selftune.dev", diff --git a/apps/desktop/scripts/build-sidecar.ts b/apps/desktop/scripts/build-sidecar.ts index 904b17c8..ebb43bf6 100644 --- a/apps/desktop/scripts/build-sidecar.ts +++ b/apps/desktop/scripts/build-sidecar.ts @@ -172,14 +172,13 @@ if (prebuiltExecutable) { } await cp(prebuiltPath, executablePath); } else { + const compile: Bun.CompileBuildOptions = { outfile: executablePath }; + if (target) compile.target = target; const result = await Bun.build({ entrypoints: [join(selfTuneRoot, "apps/cli/src/main.ts")], define: { SELFTUNE_DESKTOP_SIDECAR_BUILD: "true" }, minify: true, - compile: { - outfile: executablePath, - ...(target ? { target } : {}), - }, + compile, }); if (!result.success) { @@ -187,13 +186,14 @@ if (prebuiltExecutable) { } } +const reportWorkerCompile: Bun.CompileBuildOptions = { + outfile: join(resourceRoot, reportWorkerExecutable), +}; +if (target) reportWorkerCompile.target = target; const reportWorkerResult = await Bun.build({ entrypoints: [join(selfTuneRoot, "apps/local/src/report-worker.ts")], minify: true, - compile: { - outfile: join(resourceRoot, reportWorkerExecutable), - ...(target ? { target } : {}), - }, + compile: reportWorkerCompile, }); if (!reportWorkerResult.success) { diff --git a/apps/desktop/scripts/duckdb-sidecar-probe.ts b/apps/desktop/scripts/duckdb-sidecar-probe.ts index 207e7ba9..5febc047 100644 --- a/apps/desktop/scripts/duckdb-sidecar-probe.ts +++ b/apps/desktop/scripts/duckdb-sidecar-probe.ts @@ -5,6 +5,8 @@ const databasePath = process.argv[2]; if (!databasePath) throw new Error("Expected a file-backed DuckDB path."); const resourceRoot = process.env.SELFTUNE_DESKTOP_RESOURCE_DIR; if (!resourceRoot) throw new Error("Expected the packaged Desktop resource directory."); +// SAFETY: This fixed SDK entrypoint has @duckdb/node-api's declarations; a file URL +// loses static module resolution. The probe exercises its instance, connection, and reader below. const { DuckDBInstance } = (await import( pathToFileURL(join(resourceRoot, "node_modules/@duckdb/node-api/lib/index.js")).href )) as typeof import("@duckdb/node-api"); @@ -15,7 +17,10 @@ try { await connection.run("CREATE TABLE packaged_duckdb_probe (value INTEGER)"); await connection.run("INSERT INTO packaged_duckdb_probe VALUES (1)"); const reader = await connection.runAndReadAll("SELECT value FROM packaged_duckdb_probe"); - if (reader.getRowObjects().length !== 1) throw new Error("DuckDB probe did not persist a row."); + const rows = reader.getRowObjects(); + if (rows.length !== 1 || rows[0]?.value !== 1) { + throw new Error("DuckDB probe did not persist the expected value."); + } } finally { connection.closeSync(); instance.closeSync(); diff --git a/apps/desktop/scripts/preload-probe.test.ts b/apps/desktop/scripts/preload-probe.test.ts index 22abbe55..d495ed6d 100644 --- a/apps/desktop/scripts/preload-probe.test.ts +++ b/apps/desktop/scripts/preload-probe.test.ts @@ -3,6 +3,7 @@ import { execFile } from "node:child_process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { runInNewContext } from "node:vm"; +import type { SelfTuneDesktopBridge, SelfTuneDesktopTestBridge } from "../src/preload"; async function loadPreload(probeEnabled: boolean) { const { stdout } = await promisify(execFile)(process.execPath, [ @@ -12,7 +13,7 @@ async function loadPreload(probeEnabled: boolean) { "--format=cjs", "--external=electron", ]); - const bridges = new Map(); + const bridges = new Map(); const listeners: Array<() => void> = []; const calls: string[] = []; runInNewContext(stdout, { @@ -27,7 +28,10 @@ async function loadPreload(probeEnabled: boolean) { if (name !== "electron") throw new Error(`Unexpected preload dependency: ${name}`); return { contextBridge: { - exposeInMainWorld: (key: string, value: object) => bridges.set(key, value), + exposeInMainWorld: ( + key: string, + value: SelfTuneDesktopBridge | SelfTuneDesktopTestBridge, + ) => bridges.set(key, value), }, ipcRenderer: { invoke: (channel: string) => { @@ -49,11 +53,9 @@ test("sandboxed preload records one probe at document load without process.env", preload.loaded(); expect(preload.calls).toEqual(["selftune:test-pending-window-ipc"]); const bridge = preload.bridges.get("selftuneDesktopTest"); - if (!bridge) throw new Error("Missing test bridge."); - const probe: unknown = Reflect.get(bridge, "pendingWindowIpc"); - if (typeof probe !== "function") throw new Error("Missing probe."); - const first: unknown = Reflect.apply(probe, bridge, []); - const second: unknown = Reflect.apply(probe, bridge, []); + if (!bridge || !("pendingWindowIpc" in bridge)) throw new Error("Missing test bridge."); + const first = bridge.pendingWindowIpc(); + const second = bridge.pendingWindowIpc(); expect(first).toBe(second); expect(await first).toMatchObject({ ok: false }); expect(preload.calls).toHaveLength(1); diff --git a/apps/desktop/scripts/smoke-packaged.ts b/apps/desktop/scripts/smoke-packaged.ts index d20ebc90..b29c10ad 100644 --- a/apps/desktop/scripts/smoke-packaged.ts +++ b/apps/desktop/scripts/smoke-packaged.ts @@ -45,10 +45,12 @@ const PendingWindowIpcProbe = Schema.Union([ ]); const HealthProbe = Schema.Struct({ - ok: Schema.Boolean, - pid: Schema.Number, - processMode: Schema.String, - service: Schema.String, + payload: Schema.Struct({ + ok: Schema.Boolean, + pid: Schema.Number, + process_mode: Schema.String, + service: Schema.String, + }), status: Schema.Number, }); @@ -101,16 +103,6 @@ function failure(operation: string, cause: unknown): PackagedSmokeFailure { }); } -const decode = Effect.fn("SelfTuneDesktop.smoke.decode")(function* ( - operation: string, - schema: S, - input: unknown, -) { - return yield* Schema.decodeUnknownEffect(schema)(input).pipe( - Effect.mapError((cause) => failure(operation, cause)), - ); -}); - function packagedExecutable(outputRoot: string): string { if (!existsSync(outputRoot)) { throw new Error(`Packaged test output is missing at ${outputRoot}.`); @@ -352,6 +344,18 @@ const launchApplication = Effect.fn("SelfTuneDesktop.smoke.launch")(function* ( ), catch: (cause) => failure("prepare packaged application directories", cause), }); + const env: NonNullable[0]>["env"] = { + ...Object.fromEntries( + Object.entries(isolatedApplicationEnvironment(temporaryRoot, homeDir, configDir)).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ), + SELFTUNE_TEST_DISABLE_UPDATES: "1", + SELFTUNE_DESKTOP_TEST_PATH: options.initialPath, + SELFTUNE_DESKTOP_USER_DATA_DIR: userDataDir, + SELFTUNE_TEST_SKIP_BACKGROUND_SERVICE: "1", + }; + if (options.probePendingWindowIpc) env.SELFTUNE_DESKTOP_TEST_PENDING_WINDOW_IPC = "1"; const application = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => @@ -359,16 +363,7 @@ const launchApplication = Effect.fn("SelfTuneDesktop.smoke.launch")(function* ( args: packagedApplicationArgs(userDataDir), executablePath, timeout: 60_000, - env: { - ...isolatedApplicationEnvironment(temporaryRoot, homeDir, configDir), - SELFTUNE_TEST_DISABLE_UPDATES: "1", - SELFTUNE_DESKTOP_TEST_PATH: options.initialPath, - SELFTUNE_DESKTOP_USER_DATA_DIR: userDataDir, - SELFTUNE_TEST_SKIP_BACKGROUND_SERVICE: "1", - ...(options.probePendingWindowIpc - ? { SELFTUNE_DESKTOP_TEST_PENDING_WINDOW_IPC: "1" } - : {}), - }, + env, }), catch: (cause) => failure("launch packaged application", cause), }), @@ -384,16 +379,6 @@ const launchApplication = Effect.fn("SelfTuneDesktop.smoke.launch")(function* ( return { application, configDir, userDataDir }; }); -const readJsonFile = Effect.fn("SelfTuneDesktop.smoke.readJson")(function* (path: string) { - return yield* Effect.tryPromise({ - try: async () => { - const parsed: unknown = JSON.parse(await readFile(path, "utf8")); - return parsed; - }, - catch: (cause) => failure(`read ${path}`, cause), - }); -}); - const assert = Effect.fn("SelfTuneDesktop.smoke.assert")(function* ( condition: boolean, operation: string, @@ -428,19 +413,15 @@ const readPendingWindowIpcProbe = Effect.fn("SelfTuneDesktop.smoke.pendingWindow try: () => page.waitForLoadState("domcontentloaded", { timeout: 60_000 }), catch: (cause) => failure("wait for pending-window IPC document", cause), }); - const probeUnknown: unknown = yield* Effect.tryPromise({ + const probe = yield* Effect.tryPromise({ try: () => page.evaluate(() => { - const bridge = Reflect.get(window, "selftuneDesktopTest"); - if (typeof bridge !== "object" || bridge === null) { + const bridge = window.selftuneDesktopTest; + if (!bridge) { throw new Error("The pending-window IPC test bridge is missing."); } - const pendingWindowIpc = Reflect.get(bridge, "pendingWindowIpc"); - if (typeof pendingWindowIpc !== "function") { - throw new Error("The pending-window IPC probe is missing."); - } - const probeResult: unknown = Reflect.apply(pendingWindowIpc, bridge, []); - return new Promise((resolveProbe, rejectProbe) => { + const probeResult = bridge.pendingWindowIpc(); + return new Promise>((resolveProbe, rejectProbe) => { const timeout = setTimeout( () => rejectProbe(new Error("Pending-window IPC probe timed out after 15 seconds.")), 15_000, @@ -459,7 +440,9 @@ const readPendingWindowIpcProbe = Effect.fn("SelfTuneDesktop.smoke.pendingWindow }), catch: (cause) => failure("read pending-window IPC probe", cause), }); - return yield* decode("decode pending-window IPC probe", PendingWindowIpcProbe, probeUnknown); + return yield* Schema.decodeUnknownEffect(PendingWindowIpcProbe)(probe).pipe( + Effect.mapError((cause) => failure("decode pending-window IPC probe", cause)), + ); }); const proveWrongOriginPendingWindowRejected = Effect.fn( @@ -514,7 +497,7 @@ const smoke = Effect.scoped( { initialPath: "/settings", probePendingWindowIpc: true }, ); - const appInfoUnknown: unknown = yield* Effect.tryPromise({ + const applicationInfo = yield* Effect.tryPromise({ try: () => application.evaluate(({ app }) => ({ packaged: app.isPackaged, @@ -523,7 +506,9 @@ const smoke = Effect.scoped( })), catch: (cause) => failure("inspect Electron application", cause), }); - const appInfo = yield* decode("decode Electron application", AppInfo, appInfoUnknown); + const appInfo = yield* Schema.decodeUnknownEffect(AppInfo)(applicationInfo).pipe( + Effect.mapError((cause) => failure("decode Electron application", cause)), + ); yield* assert( appInfo.packaged, "verify packaged mode", @@ -544,12 +529,8 @@ const smoke = Effect.scoped( try: () => page.waitForFunction( () => { - const bridge = Reflect.get(window, "selftuneDesktop"); return ( - document.readyState === "complete" && - typeof bridge === "object" && - bridge !== null && - typeof Reflect.get(bridge, "getRuntime") === "function" + document.readyState === "complete" && window.selftuneDesktop?.getRuntime !== undefined ); }, null, @@ -568,65 +549,60 @@ const smoke = Effect.scoped( `Initial preload IPC was handled as ${pendingWindowProbe.source}.`, ); - const runtimeUnknown: unknown = yield* Effect.tryPromise({ + const runtimeResponse = yield* Effect.tryPromise({ try: () => page.evaluate(() => { - const bridge = Reflect.get(window, "selftuneDesktop"); - if (typeof bridge !== "object" || bridge === null) { + const bridge = window.selftuneDesktop; + if (!bridge) { throw new Error("The SelfTune desktop preload bridge is missing."); } - const getRuntime = Reflect.get(bridge, "getRuntime"); - if (typeof getRuntime !== "function") { - throw new Error("The SelfTune runtime IPC method is missing."); - } - return Reflect.apply(getRuntime, bridge, []); + return bridge.getRuntime(); }), catch: (cause) => failure("invoke packaged preload IPC", cause), }); - const runtime = yield* decode("decode packaged runtime IPC", DesktopRuntime, runtimeUnknown); + const runtime = yield* Schema.decodeUnknownEffect(DesktopRuntime)(runtimeResponse).pipe( + Effect.mapError((cause) => failure("decode packaged runtime IPC", cause)), + ); yield* assert( runtime.version === appInfo.version, "verify packaged version", `App version ${appInfo.version} does not match preload version ${runtime.version}.`, ); - const healthUnknown: unknown = yield* Effect.tryPromise({ + const healthResponse = yield* Effect.tryPromise({ try: () => page.evaluate(async () => { const response = await fetch("/api/health"); - const payload: unknown = await response.json(); - if (typeof payload !== "object" || payload === null) { - throw new Error("The health response was not an object."); - } return { - ok: Reflect.get(payload, "ok"), - pid: Reflect.get(payload, "pid"), - processMode: Reflect.get(payload, "process_mode"), - service: Reflect.get(payload, "service"), + payload: await response.json(), status: response.status, }; }), catch: (cause) => failure("request health through packaged renderer", cause), }); - const health = yield* decode("decode packaged health response", HealthProbe, healthUnknown); + const health = yield* Schema.decodeUnknownEffect(HealthProbe)(healthResponse).pipe( + Effect.mapError((cause) => failure("decode packaged health response", cause)), + ); yield* assert( - health.status === 200 && health.ok, + health.status === 200 && health.payload.ok, "verify authenticated renderer", `Renderer health request returned ${health.status}.`, ); yield* assert( - health.service === "selftune-dashboard" && health.processMode === "standalone", + health.payload.service === "selftune-dashboard" && + health.payload.process_mode === "standalone", "verify bundled runtime", - `Unexpected health identity ${health.service}/${health.processMode}.`, + `Unexpected health identity ${health.payload.service}/${health.payload.process_mode}.`, ); const pointerPath = join(userDataDir, "runtime", "current.json"); - const pointerUnknown = yield* readJsonFile(pointerPath); - const pointer = yield* decode( - "decode installed runtime pointer", - RuntimePointer, - pointerUnknown, - ); + const pointerContents = yield* Effect.tryPromise({ + try: () => readFile(pointerPath, "utf8"), + catch: (cause) => failure(`read ${pointerPath}`, cause), + }); + const pointer = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(RuntimePointer))( + pointerContents, + ).pipe(Effect.mapError((cause) => failure("decode installed runtime pointer", cause))); yield* assert( pointer.version === appInfo.version && pointer.path.startsWith(join(userDataDir, "runtime")), "verify stable runtime installation", @@ -634,7 +610,7 @@ const smoke = Effect.scoped( ); yield* closeApplication(application); - yield* waitForRuntimeCleanup(configDir, health.pid); + yield* waitForRuntimeCleanup(configDir, health.payload.pid); yield* Effect.logInfo( `Packaged SelfTune smoke test passed for ${appInfo.version} at ${executablePath}.`, diff --git a/apps/desktop/scripts/smoke-sidecar-package-collector.ts b/apps/desktop/scripts/smoke-sidecar-package-collector.ts index 9c76118a..91169f69 100644 --- a/apps/desktop/scripts/smoke-sidecar-package-collector.ts +++ b/apps/desktop/scripts/smoke-sidecar-package-collector.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; +import * as Schema from "effect/Schema"; import { INTERNAL_PACKAGE_BUNDLE_SMOKE_COMMAND } from "@selftune/runtime/remote-library/package-bundle-collector-command"; @@ -38,16 +39,13 @@ try { }, ); if (stderr.trim()) throw new Error(`Compiled collector wrote to stderr: ${stderr.trim()}`); - const decoded: unknown = JSON.parse(stdout); - if ( - decoded === null || - typeof decoded !== "object" || - !("encoded_bytes" in decoded) || - typeof decoded.encoded_bytes !== "number" || - decoded.encoded_bytes <= 0 - ) { - throw new Error(`Compiled collector returned an invalid proof: ${stdout}`); - } + const decoded = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + encoded_bytes: Schema.Number.check(Schema.isGreaterThan(0)), + }), + ), + )(stdout); process.stdout.write( `Compiled Desktop Sync & Backup collector smoke passed (${decoded.encoded_bytes} encoded bytes).\n`, ); diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index 12681ef5..464ea8fc 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -129,9 +129,9 @@ const assert = Effect.fn("SelfTuneSidecar.smoke.assert")(function* ( const decode = Effect.fn("SelfTuneSidecar.smoke.decode")(function* ( operation: string, schema: S, - input: unknown, + input: string, ) { - return yield* Schema.decodeUnknownEffect(schema)(input).pipe( + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(input).pipe( Effect.mapError((cause) => failure(operation, cause)), ); }); @@ -286,14 +286,11 @@ const startRuntime = Effect.fn("SelfTuneSidecar.smoke.start")(function* (paths: try: () => waitForReady(child, () => stderr), catch: (cause) => failure("wait for isolated compiled runtime", cause), }); - const authUnknown = yield* Effect.tryPromise({ - try: async () => { - const parsed: unknown = JSON.parse(await readFile(localAuthPath(paths.configDir), "utf8")); - return parsed; - }, + const authContents = yield* Effect.tryPromise({ + try: () => readFile(localAuthPath(paths.configDir), "utf8"), catch: (cause) => failure("read compiled runtime auth file", cause), }); - const auth = yield* decode("decode compiled runtime auth file", LocalAuthRecord, authUnknown); + const auth = yield* decode("decode compiled runtime auth file", LocalAuthRecord, authContents); yield* assert( auth.token.length >= 32, "verify compiled runtime auth token", @@ -327,15 +324,12 @@ const request = Effect.fn("SelfTuneSidecar.smoke.request")(function* failure(`request ${pathname}`, cause), }); const body = yield* Effect.tryPromise({ - try: async () => { - const value: unknown = await response.json(); - return value; - }, + try: () => response.text(), catch: (cause) => failure(`read ${pathname} response`, cause), }); if (!response.ok) { return yield* Effect.fail( - failure(`request ${pathname}`, `Received ${response.status}: ${JSON.stringify(body)}`), + failure(`request ${pathname}`, `Received ${response.status}: ${body}`), ); } return yield* decode(`decode ${pathname} response`, schema, body); @@ -443,17 +437,10 @@ const verifyCompiledPackageCollection = Effect.fn("SelfTuneSidecar.smoke.package }), catch: (cause) => failure("collect package through compiled Sync & Backup runtime", cause), }); - const parsed = yield* Effect.try({ - try: () => { - const value: unknown = JSON.parse(output.stdout); - return value; - }, - catch: (cause) => failure("parse compiled package collection proof", cause), - }); const response = yield* decode( "decode compiled package collection proof", PackageBundleSmokeResponse, - parsed, + output.stdout, ); yield* assert( response.encoded_bytes > 0, diff --git a/apps/desktop/src/main/desktop-ipc-input.ts b/apps/desktop/src/main/desktop-ipc-input.ts index 53d6f634..8c4fa3b6 100644 --- a/apps/desktop/src/main/desktop-ipc-input.ts +++ b/apps/desktop/src/main/desktop-ipc-input.ts @@ -9,23 +9,16 @@ export function decodeDesktopBootstrapNoInput(input: ReadonlyArray): vo } } -export function decodeExistingAbsoluteDirectory(input: unknown): string { - let path: string; - try { - path = Schema.decodeUnknownSync(Schema.String)(input); - } catch { - throw new Error("Only existing absolute folder paths can be opened."); - } - if (!isAbsolute(path) || !existsSync(path) || !statSync(path).isDirectory()) { - throw new Error("Only existing absolute folder paths can be opened."); - } - return path; -} +const directoryMessage = "Only existing absolute folder paths can be opened."; +export const decodeExistingAbsoluteDirectory = Schema.decodeUnknownSync( + Schema.String.annotate({ message: directoryMessage }).check( + Schema.makeFilter( + (path) => isAbsolute(path) && existsSync(path) && statSync(path).isDirectory(), + { message: directoryMessage }, + ), + ), +); -export function decodeBackgroundServiceEnabled(input: unknown): boolean { - try { - return Schema.decodeUnknownSync(Schema.Boolean)(input); - } catch { - throw new Error("Background service state must be boolean."); - } -} +export const decodeBackgroundServiceEnabled = Schema.decodeUnknownSync( + Schema.Boolean.annotate({ message: "Background service state must be boolean." }), +); diff --git a/apps/desktop/src/main/desktop-ipc.ts b/apps/desktop/src/main/desktop-ipc.ts index 93fe13a1..4c5be74a 100644 --- a/apps/desktop/src/main/desktop-ipc.ts +++ b/apps/desktop/src/main/desktop-ipc.ts @@ -71,7 +71,7 @@ export function registerDesktopIpc(options: DesktopIpcOptions): DesktopIpcContro options.window.assertTrustedIpc(event, true), ); } - ipcMain.handle("selftune:open-external", (event, input: unknown) => { + ipcMain.handle("selftune:open-external", (event, input) => { options.window.assertTrustedIpc(event); let url: string; try { @@ -84,7 +84,7 @@ export function registerDesktopIpc(options: DesktopIpcOptions): DesktopIpcContro } return shell.openExternal(url); }); - ipcMain.handle("selftune:open-folder", async (event, input: unknown) => { + ipcMain.handle("selftune:open-folder", async (event, input) => { options.window.assertTrustedIpc(event); const error = await shell.openPath(decodeExistingAbsoluteDirectory(input)); if (error) throw new Error(error); @@ -120,7 +120,7 @@ export function registerDesktopIpc(options: DesktopIpcOptions): DesktopIpcContro options.window.assertTrustedIpc(event); return options.runRuntime(options.runtime.backgroundServiceState); }); - ipcMain.handle("selftune:set-background-service", async (event, input: unknown) => { + ipcMain.handle("selftune:set-background-service", async (event, input) => { options.window.assertTrustedIpc(event); await options.runRuntime( options.runtime.setBackgroundServiceEnabled(decodeBackgroundServiceEnabled(input)), diff --git a/apps/desktop/src/main/desktop-protocol.test.ts b/apps/desktop/src/main/desktop-protocol.test.ts index cfce62f3..4e5e35a8 100644 --- a/apps/desktop/src/main/desktop-protocol.test.ts +++ b/apps/desktop/src/main/desktop-protocol.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { createHash } from "node:crypto"; import { + compiledDesktopReleaseTrustPins, desktopProtocolConfiguration, desktopReleaseTrustPinsFromEnvironment, isTrustedPackagedDesktopBuild, @@ -18,6 +19,10 @@ const MAC_PINS: DesktopReleaseTrustPins = { }; describe("Desktop protocol registration", () => { + it("does not trust uncompiled source without release pins", () => { + expect(compiledDesktopReleaseTrustPins("darwin")).toBeNull(); + expect(compiledDesktopReleaseTrustPins("win32")).toBeNull(); + }); it("registers only packaged builds whose production signature is verified", () => { for (const input of [ { isPackaged: false, signatureVerified: false }, @@ -117,6 +122,24 @@ describe("Desktop protocol registration", () => { }), stderr: "", }); + for (const stdout of [ + "null", + "[]", + "{", + JSON.stringify({ Status: "Valid", Subject: pins.publisherSubject, Thumbprint: 123 }), + JSON.stringify({ + Status: "NotSigned", + Subject: pins.publisherSubject, + Thumbprint: pins.certificateThumbprint, + }), + ]) { + expect( + isTrustedPackagedDesktopBuild( + { isPackaged: true, platform: "win32", executablePath: "SelfTune.exe", pins }, + () => ({ status: 0, stdout, stderr: "" }), + ), + ).toBeFalse(); + } expect( isTrustedPackagedDesktopBuild( { isPackaged: true, platform: "win32", executablePath: "SelfTune.exe", pins }, diff --git a/apps/desktop/src/main/desktop-protocol.ts b/apps/desktop/src/main/desktop-protocol.ts index 2259df50..14598ebd 100644 --- a/apps/desktop/src/main/desktop-protocol.ts +++ b/apps/desktop/src/main/desktop-protocol.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; +import * as Schema from "effect/Schema"; import { parseDeveloperIdSigningIdentity } from "./runtime-integrity"; @@ -98,23 +99,23 @@ export function compiledDesktopReleaseTrustPins( ): DesktopReleaseTrustPins | null { return desktopReleaseTrustPinsFromEnvironment(platform, { DESKTOP_MACOS_TEAM_IDENTIFIER: - typeof __SELFTUNE_DESKTOP_MACOS_TEAM_IDENTIFIER__ === "string" + typeof __SELFTUNE_DESKTOP_MACOS_TEAM_IDENTIFIER__ !== "undefined" ? __SELFTUNE_DESKTOP_MACOS_TEAM_IDENTIFIER__ : "", DESKTOP_MACOS_CERTIFICATE_AUTHORITY: - typeof __SELFTUNE_DESKTOP_MACOS_CERTIFICATE_AUTHORITY__ === "string" + typeof __SELFTUNE_DESKTOP_MACOS_CERTIFICATE_AUTHORITY__ !== "undefined" ? __SELFTUNE_DESKTOP_MACOS_CERTIFICATE_AUTHORITY__ : "", DESKTOP_MACOS_DESIGNATED_REQUIREMENT_SHA256: - typeof __SELFTUNE_DESKTOP_MACOS_DESIGNATED_REQUIREMENT_SHA256__ === "string" + typeof __SELFTUNE_DESKTOP_MACOS_DESIGNATED_REQUIREMENT_SHA256__ !== "undefined" ? __SELFTUNE_DESKTOP_MACOS_DESIGNATED_REQUIREMENT_SHA256__ : "", DESKTOP_WINDOWS_PUBLISHER_SUBJECT: - typeof __SELFTUNE_DESKTOP_WINDOWS_PUBLISHER_SUBJECT__ === "string" + typeof __SELFTUNE_DESKTOP_WINDOWS_PUBLISHER_SUBJECT__ !== "undefined" ? __SELFTUNE_DESKTOP_WINDOWS_PUBLISHER_SUBJECT__ : "", DESKTOP_WINDOWS_CERTIFICATE_THUMBPRINT: - typeof __SELFTUNE_DESKTOP_WINDOWS_CERTIFICATE_THUMBPRINT__ === "string" + typeof __SELFTUNE_DESKTOP_WINDOWS_CERTIFICATE_THUMBPRINT__ !== "undefined" ? __SELFTUNE_DESKTOP_WINDOWS_CERTIFICATE_THUMBPRINT__ : "", }); @@ -222,14 +223,18 @@ function windowsSignatureIsTrusted( ]); if (result.status !== 0) return false; try { - const value: unknown = JSON.parse(result.stdout); - if (typeof value !== "object" || value === null) return false; - if (!("Status" in value) || value.Status !== "Valid") return false; - if (!("Subject" in value) || value.Subject !== pins.publisherSubject) return false; + const signature = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + Status: Schema.Literal("Valid"), + Subject: Schema.String, + Thumbprint: Schema.String, + }), + ), + )(result.stdout); return ( - "Thumbprint" in value && - typeof value.Thumbprint === "string" && - value.Thumbprint.toUpperCase() === pins.certificateThumbprint + signature.Subject === pins.publisherSubject && + signature.Thumbprint.toUpperCase() === pins.certificateThumbprint ); } catch { return false; diff --git a/apps/desktop/src/main/desktop-recipient-preview.test.ts b/apps/desktop/src/main/desktop-recipient-preview.test.ts index 67e1c650..97a89032 100644 --- a/apps/desktop/src/main/desktop-recipient-preview.test.ts +++ b/apps/desktop/src/main/desktop-recipient-preview.test.ts @@ -40,6 +40,21 @@ const PREVIEW: DesktopRecipientPreview = { }; describe("Desktop recipient preview client", () => { + it("distinguishes consumed handoffs from malformed conflict bodies", async () => { + for (const [body, code] of [ + ['{"_tag":"RecipientActionReplay"}', "replay"], + ['{"_tag":42}', "forbidden"], + ["null", "forbidden"], + ["not-json", "forbidden"], + ]) { + const resolve = createDesktopRecipientPreviewResolver({ + loadSession: () => ({ origin: "https://api.selftune.dev", accessToken: "secret-key" }), + fetch: async () => new Response(body, { status: 409 }), + }); + expect(await resolve(TOKEN)).toMatchObject({ status: "error", code }); + } + }); + it("sends the opaque token only to the authenticated configured HTTPS SelfTune origin", async () => { const requests: Array<{ url: string; init: RequestInit }> = []; const resolve = createDesktopRecipientPreviewResolver({ diff --git a/apps/desktop/src/main/desktop-recipient-preview.ts b/apps/desktop/src/main/desktop-recipient-preview.ts index 767c71bc..d160037e 100644 --- a/apps/desktop/src/main/desktop-recipient-preview.ts +++ b/apps/desktop/src/main/desktop-recipient-preview.ts @@ -208,15 +208,10 @@ function terminalFailure(status: number, body: string): DesktopPreviewResolution } if (status === 409) { try { - const value: unknown = JSON.parse(body); - if ( - typeof value === "object" && - value !== null && - "_tag" in value && - value._tag === "RecipientActionReplay" - ) { - return { status: "error", code: "replay", message: "This install handoff was used." }; - } + Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ _tag: Schema.Literal("RecipientActionReplay") })), + )(body); + return { status: "error", code: "replay", message: "This install handoff was used." }; } catch { // A malformed failure body remains a terminal, non-secret conflict. } diff --git a/apps/desktop/src/main/desktop-runtime-live.ts b/apps/desktop/src/main/desktop-runtime-live.ts index 4b5c2d74..61840b41 100644 --- a/apps/desktop/src/main/desktop-runtime-live.ts +++ b/apps/desktop/src/main/desktop-runtime-live.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { app, dialog } from "electron"; +import * as Schema from "effect/Schema"; import { getBackgroundServiceStatus, installBackgroundService, @@ -39,10 +40,10 @@ function readBackgroundPreference(configDir: string): boolean | null { const path = backgroundPreferencePath(configDir); if (!existsSync(path)) return null; try { - const value: unknown = JSON.parse(readFileSync(path, "utf8")); - return typeof value === "object" && value !== null && "enabled" in value - ? value.enabled === true - : null; + const value = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ enabled: Schema.Json })), + )(readFileSync(path, "utf8")); + return value.enabled === true; } catch { return null; } diff --git a/apps/desktop/src/main/desktop-runtime.test.ts b/apps/desktop/src/main/desktop-runtime.test.ts index 45770c10..6912dd00 100644 --- a/apps/desktop/src/main/desktop-runtime.test.ts +++ b/apps/desktop/src/main/desktop-runtime.test.ts @@ -363,6 +363,32 @@ describe("scoped desktop runtime", () => { await runtime.dispose(); }); + it.each([ + { body: { error: "Expired authentication" }, message: "Expired authentication" }, + { body: { error: { code: "DENIED", message: "Not authorized" } }, message: "Not authorized" }, + { body: { error: { message: 42 } }, message: "SelfTune local API request failed (401)." }, + { body: null, message: "SelfTune local API request failed (401)." }, + ])( + "retains authenticated HTTP error messages without trusting malformed fields: %j", + async ({ body, message }) => { + const runtime = ManagedRuntime.make( + makeDesktopRuntimeLayer( + dependencies({ fetch: async () => Response.json(body, { status: 401 }) }), + callbacks(), + ), + ); + try { + const service = await loadService(runtime); + await runtime.runPromise(service.boot); + await expect( + runtime.runPromise(service.requestJson("/api/health", TrayHealthResponseSchema)), + ).rejects.toMatchObject({ operation: "request local API", message }); + } finally { + await runtime.dispose(); + } + }, + ); + it("restores a managed runtime after background installation fails without claiming success", async () => { let starts = 0; const preferences: boolean[] = []; diff --git a/apps/desktop/src/main/desktop-runtime.ts b/apps/desktop/src/main/desktop-runtime.ts index 4a7ae2f7..ddf1540b 100644 --- a/apps/desktop/src/main/desktop-runtime.ts +++ b/apps/desktop/src/main/desktop-runtime.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -36,6 +37,16 @@ export interface DesktopRuntimeCallbacks { readonly onRecoveryFailed: (cause: unknown) => Promise; } +type DesktopRuntimeLogDetails = + | DesktopRuntimeError + | ResetStateResult + | { readonly cleanupFailure: DesktopRuntimeError; readonly rebindFailure: DesktopRuntimeError } + | { + readonly attempt: number; + readonly message?: string; + readonly phase?: ManagedConnectionTransitionFailure["phase"]; + }; + export interface DesktopRuntimeDependencies { readonly announceBackup: (backupDir: string) => Promise; readonly attachExistingRuntime: () => Promise; @@ -47,7 +58,11 @@ export interface DesktopRuntimeDependencies { readonly getBackgroundStatus: () => Effect.Effect; readonly installBackgroundService: () => Effect.Effect; readonly installRuntime: () => Effect.Effect; - readonly log: (level: "error" | "info" | "warn", message: string, details?: unknown) => void; + readonly log: ( + level: "error" | "info" | "warn", + message: string, + details?: DesktopRuntimeLogDetails, + ) => void; readonly monitorFailureThreshold: number; readonly monitorIntervalMs: number | null; readonly platform: NodeJS.Platform; @@ -133,21 +148,14 @@ function attemptSync(operation: string, task: () => A): Effect.Effect(); const stoppedManagedSidecars = new WeakSet(); - const log = (level: "error" | "info" | "warn", message: string, details?: unknown) => - Effect.sync(() => dependencies.log(level, message, details)); + const log = ( + level: "error" | "info" | "warn", + message: string, + details?: DesktopRuntimeLogDetails, + ) => Effect.sync(() => dependencies.log(level, message, details)); const runMutation = (effect: Effect.Effect) => Effect.raceFirst( diff --git a/apps/desktop/src/main/desktop-window.ts b/apps/desktop/src/main/desktop-window.ts index b8e38d06..8a4e8b0b 100644 --- a/apps/desktop/src/main/desktop-window.ts +++ b/apps/desktop/src/main/desktop-window.ts @@ -2,7 +2,15 @@ import { randomUUID } from "node:crypto"; import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { BrowserWindow, session, shell, type IpcMainInvokeEvent, type Session } from "electron"; +import { + BrowserWindow, + session, + shell, + type BrowserWindowConstructorOptions, + type IpcMainInvokeEvent, + type Session, + type WebPreferences, +} from "electron"; import { PENDING_WINDOW_IPC_TEST_DOCUMENT, @@ -164,7 +172,16 @@ export function createDesktopWindowController( } const authenticatedSession = navigationTrust === "internal" ? configureSessionAuth(connection) : null; - const window = new BrowserWindow({ + const webPreferences: WebPreferences = { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: preloadPath, + }; + if (testProbeEnabled) + webPreferences.additionalArguments = [PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT]; + if (authenticatedSession) webPreferences.session = authenticatedSession.session; + const windowOptions: BrowserWindowConstructorOptions = { width: 1440, height: 940, minWidth: process.platform === "darwin" ? 1024 : 980, @@ -172,23 +189,13 @@ export function createDesktopWindowController( show: false, backgroundColor: "#07090d", title: "SelfTune", - ...(process.platform === "darwin" - ? { - titleBarStyle: "hidden" as const, - trafficLightPosition: { x: 16, y: 17 }, - } - : {}), - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - preload: preloadPath, - ...(testProbeEnabled - ? { additionalArguments: [PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT] } - : {}), - ...(authenticatedSession ? { session: authenticatedSession.session } : {}), - }, - }); + webPreferences, + }; + if (process.platform === "darwin") { + windowOptions.titleBarStyle = "hidden"; + windowOptions.trafficLightPosition = { x: 16, y: 17 }; + } + const window = new BrowserWindow(windowOptions); let disposed = false; const owned: OwnedWindow = { baseUrl: connection.baseUrl, @@ -405,7 +412,7 @@ export function createDesktopWindowController( function showLaunching(): void { if (quitting || launchWindow) return; - const window = new BrowserWindow({ + const windowOptions: BrowserWindowConstructorOptions = { width: 420, height: 480, resizable: false, @@ -414,17 +421,14 @@ export function createDesktopWindowController( show: false, backgroundColor: "#07090d", title: "SelfTune", - ...(process.platform === "darwin" - ? { - titleBarStyle: "hidden" as const, - } - : {}), webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, }, - }); + }; + if (process.platform === "darwin") windowOptions.titleBarStyle = "hidden"; + const window = new BrowserWindow(windowOptions); launchWindow = window; window.once("ready-to-show", () => { if (launchWindow === window && !window.isDestroyed()) window.show(); diff --git a/apps/desktop/src/main/diagnostics.test.ts b/apps/desktop/src/main/diagnostics.test.ts index 4ec57677..a9746e07 100644 --- a/apps/desktop/src/main/diagnostics.test.ts +++ b/apps/desktop/src/main/diagnostics.test.ts @@ -3,15 +3,20 @@ import { EventEmitter } from "node:events"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { CrashReporterStartOptions } from "electron"; -const crashReporterStarts: unknown[] = []; -const consoleWrites: unknown[] = []; -const consoleTransport: { +const crashReporterStarts: CrashReporterStartOptions[] = []; +interface ConsoleMessage { + message: string; +} +const consoleWrites: ConsoleMessage[] = []; +interface TestConsoleTransport { level: string | false; - writeFn(input: unknown): void; -} = { + writeFn(input: ConsoleMessage): void; +} +const consoleTransport: TestConsoleTransport = { level: "info", - writeFn: (input: unknown) => { + writeFn: (input) => { consoleWrites.push(input); }, }; @@ -26,7 +31,7 @@ mock.module("electron", () => ({ on: () => undefined, }, crashReporter: { - start: (options: unknown) => crashReporterStarts.push(options), + start: (options: CrashReporterStartOptions) => crashReporterStarts.push(options), }, dialog: { showMessageBox: () => Promise.resolve() }, shell: { showItemInFolder: () => undefined }, @@ -94,6 +99,9 @@ describe("desktop diagnostics privacy", () => { expect(isUnavailableLogStream(Object.assign(new Error("broken pipe"), { code: "EPIPE" }))).toBe( true, ); + expect(isUnavailableLogStream({ code: "EIO" })).toBe(true); + expect(isUnavailableLogStream({ code: "EPIPE" })).toBe(true); + expect(isUnavailableLogStream({ code: "EACCES" })).toBe(false); expect( isUnavailableLogStream(Object.assign(new Error("permission denied"), { code: "EACCES" })), ).toBe(false); @@ -115,7 +123,7 @@ describe("desktop diagnostics privacy", () => { const terminal = new EventEmitter(); const unexpectedErrors: unknown[] = []; consoleTransport.level = "info"; - consoleTransport.writeFn = (input: unknown) => { + consoleTransport.writeFn = (input) => { consoleWrites.push(input); }; @@ -188,6 +196,39 @@ describe("desktop diagnostics privacy", () => { }); }); + it("scrubs cycles, errors, and non-plain diagnostic objects without JSON coercion", () => { + class RuntimeDetails { + readonly token = "class-secret"; + readonly file = "/Users/alice/private/runtime.ts"; + } + interface CyclicDetails { + label: string; + self?: CyclicDetails; + } + + const cycle: CyclicDetails = { label: "/Users/alice/private" }; + cycle.self = cycle; + const error = new Error("Bearer error-token-123456 at /Users/alice/private/runtime.ts", { + cause: cycle, + }); + + expect(scrubDiagnosticValue(new RuntimeDetails(), ["/Users/alice"])).toEqual({ + token: "[REDACTED]", + file: "[CONFIG_DIR]/private/runtime.ts", + }); + expect(scrubDiagnosticValue(cycle, ["/Users/alice"])).toEqual({ + label: "[CONFIG_DIR]/private", + self: "[CIRCULAR]", + }); + + const scrubbedError = scrubDiagnosticValue(error, ["/Users/alice"]); + expect(scrubbedError).toMatchObject({ + name: "Error", + message: "Bearer [REDACTED] at [CONFIG_DIR]/private/runtime.ts", + cause: { label: "[CONFIG_DIR]/private", self: "[CIRCULAR]" }, + }); + }); + it("selects only bounded UTF-8 text logs from the two known roots", async () => { const root = mkdtempSync(join(tmpdir(), "selftune-diagnostics-")); roots.push(root); diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts index ab342914..e069c9f8 100644 --- a/apps/desktop/src/main/diagnostics.ts +++ b/apps/desktop/src/main/diagnostics.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { types as utilTypes } from "node:util"; import * as Sentry from "@sentry/electron/main"; import { app, crashReporter, dialog, shell } from "electron"; @@ -10,9 +11,9 @@ import log from "electron-log/main.js"; declare const __SELFTUNE_SENTRY_DSN__: string; const sentryDsn = - typeof __SELFTUNE_SENTRY_DSN__ === "string" - ? __SELFTUNE_SENTRY_DSN__ - : (process.env.SELFTUNE_DESKTOP_SENTRY_DSN ?? ""); + typeof __SELFTUNE_SENTRY_DSN__ === "undefined" + ? (process.env.SELFTUNE_DESKTOP_SENTRY_DSN ?? "") + : __SELFTUNE_SENTRY_DSN__; const doNotTrack = process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK?.toLowerCase() === "true"; export function hasExplicitNativeCrashConsent(value: string | undefined): boolean { @@ -20,6 +21,7 @@ export function hasExplicitNativeCrashConsent(value: string | undefined): boolea } export function isUnavailableLogStream(cause: unknown): boolean { + // SAFETY-TYPEOF: Console transport failures may be Error instances or error-like objects; only EIO/EPIPE are ignored. return ( typeof cause === "object" && cause !== null && @@ -34,7 +36,7 @@ interface ConsoleTransportGuard { } interface LogErrorStream { - on(event: "error", listener: (cause: Error) => void): unknown; + on(event: "error", listener: (cause: Error) => void): void; } function rethrowUnexpectedLogStreamError(cause: Error): void { @@ -212,13 +214,37 @@ export function scrubDiagnosticText(value: string, sensitivePaths: readonly stri return scrubbed; } +type ScrubbedDiagnosticValue = + | bigint + | boolean + | CallableFunction + | null + | number + | string + | symbol + | undefined + | ScrubbedDiagnosticValue[] + | { [key: string]: ScrubbedDiagnosticValue }; + +// SAFETY-UNKNOWN: This is the central privacy boundary for arbitrary runtime and Sentry diagnostic values; every traversable value is scrubbed before export. function scrubValue( value: unknown, sensitivePaths: readonly string[], seen: WeakSet, -): unknown { +): ScrubbedDiagnosticValue { + // SAFETY-TYPEOF: Diagnostics accept arbitrary runtime and Sentry values, so the privacy scrubber must distinguish primitives before traversing objects. if (typeof value === "string") return scrubDiagnosticText(value, sensitivePaths); - if (value === null || typeof value !== "object") return value; + // SAFETY-TYPEOF: Functions are already handled as opaque, non-enumerated diagnostic values and must retain their runtime identity. + if (typeof value === "function") return value; + if (value === null || value === undefined) return value; + // SAFETY-TYPEOF: Numeric primitives are already safe scalar diagnostic values. + if (typeof value === "number") return value; + // SAFETY-TYPEOF: Boolean primitives are already safe scalar diagnostic values. + if (typeof value === "boolean") return value; + // SAFETY-TYPEOF: BigInt primitives are already safe scalar diagnostic values. + if (typeof value === "bigint") return value; + // SAFETY-TYPEOF: Symbol primitives are opaque scalar diagnostic values and have no traversable secret fields. + if (typeof value === "symbol") return value; if (seen.has(value)) return "[CIRCULAR]"; seen.add(value); @@ -226,7 +252,17 @@ function scrubValue( return value.map((entry) => scrubValue(entry, sensitivePaths, seen)); } - const scrubbed: Record = {}; + const scrubbed: { [key: string]: ScrubbedDiagnosticValue } = {}; + if (utilTypes.isNativeError(value)) { + scrubbed.name = scrubDiagnosticText(value.name, sensitivePaths); + scrubbed.message = scrubDiagnosticText(value.message, sensitivePaths); + if (value.stack !== undefined) { + scrubbed.stack = scrubDiagnosticText(value.stack, sensitivePaths); + } + if (value.cause !== undefined) { + scrubbed.cause = scrubValue(value.cause, sensitivePaths, seen); + } + } for (const [key, entry] of Object.entries(value)) { scrubbed[key] = isLikelySecretKey(key) ? REDACTED_SECRET @@ -235,10 +271,11 @@ function scrubValue( return scrubbed; } +// SAFETY-UNKNOWN: Callers send heterogeneous diagnostic payloads here specifically to cross the scrubber boundary before external reporting. export function scrubDiagnosticValue( value: unknown, sensitivePaths: readonly string[] = [], -): unknown { +): ScrubbedDiagnosticValue { return scrubValue(value, sensitivePaths, new WeakSet()); } @@ -327,7 +364,7 @@ function exportStamp(): string { .replace(/\.\d+Z$/, "Z"); } -export function buildDiagnosticsManifest(): Record { +export function buildDiagnosticsManifest() { return { generated_at: new Date().toISOString(), run_id: diagnosticsRunId, @@ -346,6 +383,7 @@ export function buildDiagnosticsManifest(): Record { }; } +// SAFETY-UNKNOWN: electron-log owns an intentionally heterogeneous structured-details boundary and performs its own serialization. export function logRuntimeEvent( level: "error" | "info" | "warn", message: string, @@ -397,6 +435,7 @@ export function initializeDiagnostics(): void { log.errorHandler.startCatching({ showDialog: false }); } +// SAFETY-UNKNOWN: Failure causes are heterogeneous until this reporting boundary scrubs them for Sentry. export function reportRuntimeFailure(message: string, details?: unknown): void { log.error(message, details); if (errorReportingEnabled) { diff --git a/apps/desktop/src/main/initial-path.test.ts b/apps/desktop/src/main/initial-path.test.ts index e038d336..b1a2ae13 100644 --- a/apps/desktop/src/main/initial-path.test.ts +++ b/apps/desktop/src/main/initial-path.test.ts @@ -6,6 +6,24 @@ import { join } from "node:path"; import { resolveInitialDashboardPath } from "./initial-path"; describe("desktop initial path", () => { + test.each([ + '{"preferences":[]}', + '{"preferences":null}', + '{"preferences":"completed"}', + "null", + "[invalid", + ])("invalid preference markers do not skip onboarding: %s", (contents) => { + const configDir = mkdtempSync(join(tmpdir(), "selftune-desktop-path-")); + try { + writeFileSync(join(configDir, "config.json"), contents); + expect(resolveInitialDashboardPath({ configDir })).toBe("/settings"); + writeFileSync(join(configDir, "onboarding.json"), '{"completed":true}'); + expect(resolveInitialDashboardPath({ configDir })).toBe("/"); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + test("opens onboarding until config contains preferences", () => { const configDir = mkdtempSync(join(tmpdir(), "selftune-desktop-path-")); try { diff --git a/apps/desktop/src/main/initial-path.ts b/apps/desktop/src/main/initial-path.ts index 090d4f0e..3ef4c52a 100644 --- a/apps/desktop/src/main/initial-path.ts +++ b/apps/desktop/src/main/initial-path.ts @@ -1,6 +1,10 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import * as Schema from "effect/Schema"; + +const PreferencesMarker = Schema.Struct({ preferences: Schema.Record(Schema.String, Schema.Json) }); +const LegacyOnboarding = Schema.Struct({ completed: Schema.Boolean }); export function resolveInitialDashboardPath(options: { configDir?: string; @@ -10,22 +14,20 @@ export function resolveInitialDashboardPath(options: { if (options.testPath) return options.testPath; const configDir = options.configDir ?? join(options.homeDir ?? homedir(), ".selftune"); try { - const config: unknown = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")); - const preferences = - typeof config === "object" && config !== null && "preferences" in config - ? config.preferences - : null; - if (typeof preferences === "object" && preferences !== null) return "/"; + Schema.decodeUnknownSync(Schema.fromJsonString(PreferencesMarker))( + readFileSync(join(configDir, "config.json"), "utf8"), + ); + return "/"; } catch { // No config yet; check the legacy marker below. } try { // Legacy installs recorded completion in onboarding.json; the daemon // migrates it into config.json.preferences on first settings load. - const legacy: unknown = JSON.parse(readFileSync(join(configDir, "onboarding.json"), "utf8")); - if (typeof legacy === "object" && legacy !== null && "completed" in legacy) { - return legacy.completed === true ? "/" : "/settings"; - } + const legacy = Schema.decodeUnknownSync(Schema.fromJsonString(LegacyOnboarding))( + readFileSync(join(configDir, "onboarding.json"), "utf8"), + ); + return legacy.completed ? "/" : "/settings"; } catch { // No legacy marker either. } diff --git a/apps/desktop/src/main/sidecar-protocol.test.ts b/apps/desktop/src/main/sidecar-protocol.test.ts index a20bd750..305845fc 100644 --- a/apps/desktop/src/main/sidecar-protocol.test.ts +++ b/apps/desktop/src/main/sidecar-protocol.test.ts @@ -1,8 +1,30 @@ import { describe, expect, it } from "bun:test"; +import * as Schema from "effect/Schema"; -import { createLineBuffer, parseReadyPort } from "./sidecar-protocol"; +import { createLineBuffer, parseReadyPort, SidecarHealth } from "./sidecar-protocol"; describe("desktop sidecar protocol", () => { + it("requires a complete standalone health identity before attachment", () => { + const health = { + pid: 123, + runtime_instance_id: "runtime-1", + process_mode: "standalone", + config_dir: "/temporary/config", + } satisfies typeof SidecarHealth.Type; + const decode = Schema.decodeUnknownSync(SidecarHealth); + expect(decode(health)).toEqual(health); + for (const invalid of [ + null, + [], + {}, + { ...health, pid: "123" }, + { ...health, runtime_instance_id: null }, + { ...health, process_mode: "embedded" }, + { ...health, config_dir: 3 }, + ]) { + expect(() => decode(invalid)).toThrow(); + } + }); it("accepts only a valid readiness sentinel port", () => { expect(parseReadyPort("SELFTUNE_READY:3141")).toBe(3141); expect(parseReadyPort("server running on 3141")).toBeNull(); diff --git a/apps/desktop/src/main/sidecar-protocol.ts b/apps/desktop/src/main/sidecar-protocol.ts index a867bdf7..cc162a3a 100644 --- a/apps/desktop/src/main/sidecar-protocol.ts +++ b/apps/desktop/src/main/sidecar-protocol.ts @@ -15,3 +15,11 @@ export function createLineBuffer(onLine: (line: string) => void): (chunk: string for (const line of lines) onLine(line); }; } +import * as Schema from "effect/Schema"; + +export const SidecarHealth = Schema.Struct({ + pid: Schema.Number, + runtime_instance_id: Schema.String, + process_mode: Schema.Literal("standalone"), + config_dir: Schema.String, +}); diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 28066d3e..98660b3f 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -6,6 +6,7 @@ import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { app } from "electron"; +import { Option, Schema } from "effect"; import { loadOrCreateLocalAuthToken, @@ -18,7 +19,7 @@ import { } from "@selftune/local/local-runtime"; import { resolveLoginShellPath } from "@selftune/runtime/login-shell-path"; import { developmentRendererProxyUrl } from "./development-renderer"; -import { createLineBuffer, parseReadyPort } from "./sidecar-protocol"; +import { createLineBuffer, parseReadyPort, SidecarHealth } from "./sidecar-protocol"; import { installedRuntimeRoot } from "./runtime-install"; const STARTUP_TIMEOUT_MS = 20_000; @@ -43,14 +44,7 @@ function selfTuneRoot(): string { return resolve(app.getAppPath(), "../.."); } -function resolveCommand(): { - command: string; - cliArgs: string[]; - cwd: string; - spaDir: string; - spaProxyUrl: string | null; - taskCliPath?: string; -} { +function resolveCommand() { if (app.isPackaged) { const executable = process.platform === "win32" ? "selftune.exe" : "selftune"; const resourceRoot = installedRuntimeRoot(); @@ -114,6 +108,22 @@ export async function startSidecar(signal?: AbortSignal): Promise { const { command, cliArgs, cwd, taskCliPath } = resolveCommand(); + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: resolveLoginShellPath(), + SELFTUNE_CONFIG_DIR: configDir(), + SELFTUNE_DESKTOP: "1", + SELFTUNE_RUNTIME_OWNER: "desktop", + SELFTUNE_VERSION: app.getVersion(), + }; + if (taskCliPath) env.SELFTUNE_BIN_PATH = taskCliPath; await execFileAsync( command, [ @@ -257,15 +260,7 @@ async function stopDetachedDesktopChild(connection: SidecarConnection): Promise< { cwd, encoding: "utf8", - env: { - ...process.env, - PATH: resolveLoginShellPath(), - SELFTUNE_CONFIG_DIR: configDir(), - SELFTUNE_DESKTOP: "1", - SELFTUNE_RUNTIME_OWNER: "desktop", - SELFTUNE_VERSION: app.getVersion(), - ...(taskCliPath ? { SELFTUNE_BIN_PATH: taskCliPath } : {}), - }, + env, timeout: 15_000, }, ); @@ -291,10 +286,6 @@ function isPidAlive(pid: number): boolean { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - async function attachToManifest(manifest: ServerManifest): Promise { const localConfigDir = configDir(); const authToken = loadOrCreateLocalAuthToken(localConfigDir); @@ -303,14 +294,14 @@ async function attachToManifest(manifest: ServerManifest): Promise { - reportFailure("Could not open SelfTune", error); + void options.openDashboardPath(pathname).catch((cause: unknown) => { + reportFailure("Could not open SelfTune", cause); }); } @@ -265,8 +265,8 @@ export function createTrayMenuController(options: TrayMenuControllerOptions): Tr click: () => { const path = remoteState?.health.log_dir; if (path) { - void options.openLogs(path).catch((error: unknown) => { - reportFailure("Could not open logs", error); + void options.openLogs(path).catch((cause: unknown) => { + reportFailure("Could not open logs", cause); }); } }, @@ -279,8 +279,8 @@ export function createTrayMenuController(options: TrayMenuControllerOptions): Tr options.installUpdate(); return; } - void options.checkForUpdates().catch((error: unknown) => { - reportFailure("Update check failed", error); + void options.checkForUpdates().catch((cause: unknown) => { + reportFailure("Update check failed", cause); }); }, }, diff --git a/apps/desktop/src/preload/bridge-globals.d.ts b/apps/desktop/src/preload/bridge-globals.d.ts new file mode 100644 index 00000000..b401635f --- /dev/null +++ b/apps/desktop/src/preload/bridge-globals.d.ts @@ -0,0 +1,25 @@ +import type { SelfTuneDesktopBridge as NativeBridge, SelfTuneDesktopTestBridge } from "./index"; + +declare global { + type SelfTuneBackgroundServiceState = Awaited>; + + interface SelfTuneDesktopBridge extends Pick< + NativeBridge, + | "getRuntime" + | "focus" + | "getBackgroundService" + | "getThisMacProfile" + | "openFolder" + | "chooseFolder" + | "openExternal" + | "setBackgroundService" + > { + readonly getUpdateStatus?: NativeBridge["getUpdateStatus"]; + readonly checkForUpdates?: NativeBridge["checkForUpdates"]; + } + + interface Window { + readonly selftuneDesktop?: SelfTuneDesktopBridge; + readonly selftuneDesktopTest?: SelfTuneDesktopTestBridge; + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 5513161f..11801929 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -15,23 +15,28 @@ type PendingWindowIpcProbe = | { readonly ok: true; readonly source: unknown } | { readonly ok: false; readonly message: string }; +export interface SelfTuneDesktopTestBridge { + readonly pendingWindowIpc: () => Promise; +} + function createPendingWindowIpcProbe(): Promise { return new Promise((resolveProbe) => { - const runProbe = (): void => { - void ipcRenderer.invoke(PENDING_WINDOW_IPC_TEST_CHANNEL).then( - (source: unknown) => resolveProbe({ ok: true, source }), - (cause: unknown) => - resolveProbe({ - ok: false, - message: cause instanceof Error ? cause.message : String(cause), - }), - ); + const runProbe = async (): Promise => { + try { + const source: unknown = await ipcRenderer.invoke(PENDING_WINDOW_IPC_TEST_CHANNEL); + resolveProbe({ ok: true, source }); + } catch (cause) { + resolveProbe({ + ok: false, + message: cause instanceof Error ? cause.message : String(cause), + }); + } }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", runProbe, { once: true }); return; } - runProbe(); + void runProbe(); }); } @@ -103,7 +108,7 @@ contextBridge.exposeInMainWorld("selftuneDesktop", desktop); if (pendingWindowIpcProbe) { contextBridge.exposeInMainWorld("selftuneDesktopTest", { pendingWindowIpc: () => pendingWindowIpcProbe, - }); + } satisfies SelfTuneDesktopTestBridge); } export type SelfTuneDesktopBridge = typeof desktop; diff --git a/apps/local-dashboard/package.json b/apps/local-dashboard/package.json index 5ea77881..ec8f48b1 100644 --- a/apps/local-dashboard/package.json +++ b/apps/local-dashboard/package.json @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "effect": "4.0.0-beta.66", "lucide-react": "^0.577.0", "next-themes": "^0.4.6", "react": "^19.1.0", diff --git a/apps/local-dashboard/src/api-boundary.test.ts b/apps/local-dashboard/src/api-boundary.test.ts new file mode 100644 index 00000000..7cabf33d --- /dev/null +++ b/apps/local-dashboard/src/api-boundary.test.ts @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultSyncPreferences } from "@selftune/control-plane"; +import type { DesktopSettingsResponse } from "./types"; +import { + applyOnboarding, + fetchSettings, + updateScheduleSettings, + updateRemoteLibrarySettings, + runDashboardAction, + revokeProjectSkillSetPack, + previewLibrarySkillLicense, + shareProjectSkillSet, +} from "./api"; + +const settings = { + harnesses: [ + { + id: "custom-harness", + name: "Custom", + description: "An installed harness", + icon: { src: "/custom.svg", fit: "contain", inset: "none" }, + documentation_url: null, + source_merge: null, + status: "connected", + detected: true, + connected: true, + import_available: true, + hooks_supported: false, + hooks_installed: false, + detail: "Connected locally", + }, + ], + agent_skill: { + installed: true, + locations: ["/tmp/skills/selftune"], + install_command: "npx skills add selftune-dev/selftune", + }, + onboarding: { + version: 1, + completed: true, + import_sources: { custom: true }, + hook_harnesses: {}, + features: { observability: true, health_recommendations: true, autonomous_improvement: false }, + }, + cloud_account: { linked: false, cloud_user_id: null, cloud_org_id: null }, + remote_library: { + configured: false, + credential_provider: null, + url: null, + preferences: defaultSyncPreferences, + }, + schedule: { supported: false, format: "unsupported", settings_path: "/tmp/jobs.json", jobs: [] }, +} satisfies DesktopSettingsResponse; + +afterEach(() => vi.restoreAllMocks()); + +describe("active Desktop response contracts", () => { + it("preserves valid settings and package-defined harness identities", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(settings)); + await expect(fetchSettings()).resolves.toEqual(settings); + }); + + it.each([ + null, + { ...settings, harnesses: [{ ...settings.harnesses[0], detected: "true" }] }, + { ...settings, onboarding: { ...settings.onboarding, completed: "yes" } }, + { ...settings, remote_library: { ...settings.remote_library, preferences: { drafts: true } } }, + { ...settings, schedule: { ...settings.schedule, jobs: [{ id: "unknown-job" }] } }, + ])("rejects malformed successful settings: %j", async (payload) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload)); + await expect(fetchSettings()).rejects.toMatchObject({ code: "INVALID_RESPONSE", status: 200 }); + }); + + it("validates both settings mutation responses and preserves their request bodies", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json(settings)) + .mockResolvedValueOnce(Response.json(settings)); + const schedule = { jobs: [] }; + const remote = { url: "https://library.example", preferences: defaultSyncPreferences }; + await expect(updateScheduleSettings(schedule)).resolves.toEqual(settings); + await expect(updateRemoteLibrarySettings(remote)).resolves.toEqual(settings); + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/api/v2/settings/schedule", + expect.objectContaining({ method: "POST", body: JSON.stringify(schedule) }), + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + "/api/v2/settings/remote-library", + expect.objectContaining({ method: "POST", body: JSON.stringify(remote) }), + ); + }); + + it("requires onboarding installation results and retains valid failures for review", async () => { + const complete = { + ...settings, + install_results: [{ harness_id: "codex", status: "failed", message: "Read-only directory" }], + source_sync: { status: "skipped", message: null }, + }; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json(settings)) + .mockResolvedValueOnce(Response.json(complete)); + const input = { + import_sources: [], + hook_harnesses: [], + features: settings.onboarding.features, + }; + await expect(applyOnboarding(input)).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + await expect(applyOnboarding(input)).resolves.toEqual(complete); + }); + + it.each([ + { error: "Permission denied" }, + { + error: { + code: "NOT_ALLOWED", + message: "Permission denied", + suggestion: "Choose another directory", + retryable: false, + }, + }, + ])("decodes supported service errors: %j", async (payload) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload, { status: 403 })); + await expect(updateScheduleSettings({ jobs: [] })).rejects.toMatchObject({ + message: "Permission denied", + status: 403, + retryable: false, + }); + }); + + it("does not trust malformed error envelopes", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json( + { error: { message: { secret: "not a message" }, retryable: "yes" } }, + { status: 503 }, + ), + ); + await expect(fetchSettings()).rejects.toMatchObject({ + message: "API error: 503", + retryable: true, + }); + }); + + it("keeps missing-route guidance and handles non-JSON failures", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Not Found", { status: 404 })) + .mockResolvedValueOnce(new Response("Unavailable", { status: 503 })); + await expect(fetchSettings()).rejects.toMatchObject({ code: "ROUTE_NOT_FOUND" }); + await expect(revokeProjectSkillSetPack("research")).rejects.toMatchObject({ status: 503 }); + }); + + it("validates action results without converting a failed command into success", async () => { + const result = { + success: false, + output: "Command failed", + error: "Invalid arguments", + exitCode: 1, + }; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json(result)) + .mockResolvedValueOnce(Response.json({ ...result, success: "false" })); + const request = { skill: "review", skillPath: "/tmp/review/SKILL.md" }; + await expect(runDashboardAction("generate-evals", request)).resolves.toEqual(result); + await expect(runDashboardAction("generate-evals", request)).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + }); + }); + + it("omits absent optional JSON fields and includes explicitly selected sharing fields", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () => Response.json({})); + const terms = { copyrightHolder: "Author", licensedOrganization: "Team", year: 2026 }; + await previewLibrarySkillLicense({ skillId: "review", terms }); + expect(fetch).toHaveBeenLastCalledWith( + "/api/v2/library/license/preview", + expect.objectContaining({ + body: JSON.stringify({ + skill_id: "review", + terms: { copyright_holder: "Author", licensed_organization: "Team", year: 2026 }, + }), + }), + ); + await shareProjectSkillSet({ + skillSetId: "research", + mode: "private_single_claim", + delivery: "email", + recipientEmail: "review@example.com", + }); + expect(fetch).toHaveBeenLastCalledWith( + "/api/v2/skill-sets/share", + expect.objectContaining({ + body: JSON.stringify({ + set_id: "research", + mode: "private_single_claim", + delivery: "email", + recipient_email: "review@example.com", + }), + }), + ); + }); +}); diff --git a/apps/local-dashboard/src/api.ts b/apps/local-dashboard/src/api.ts index b629792a..613a8342 100644 --- a/apps/local-dashboard/src/api.ts +++ b/apps/local-dashboard/src/api.ts @@ -1,6 +1,13 @@ +import { decodeResponse, portfolioRequest, responseError, schemaRequest } from "./dashboard-http"; +export { DashboardApiError } from "./dashboard-http"; +import { Schema } from "effect"; +import { HealthResponse } from "@selftune/runtime/dashboard-contract/health"; +import { + ApplyOnboardingResponse, + DesktopSettingsResponse, +} from "@selftune/runtime/dashboard-contract/local-management"; import type { ApplyOnboardingRequest, - ApplyOnboardingResponse, CompleteCloudAccountLinkRequest, CompleteCloudAccountLinkResponse, ApplySkillSetRequest, @@ -14,7 +21,6 @@ import type { SkillSourceUpdateReceipt, SourceMergeDecision, StartCloudAccountLinkResponse, - DesktopSettingsResponse, DesktopBillingCheckoutFinalizeRequest, DesktopBillingCheckoutFinalizeResult, DesktopBillingCheckoutRequest, @@ -59,6 +65,7 @@ import type { DurableDashboardDecision, } from "./types"; import type { + LibraryArchiveInput, PluginInventoryModel, PluginManagementInputModel, PluginManagementReceiptModel, @@ -73,25 +80,6 @@ import type { SkillSetPackManagementList, SkillSetPackPreview } from "@selftune/ const BASE = ""; -export class DashboardApiError extends Error { - constructor( - public readonly code: string, - message: string, - public readonly suggestion: string | null, - public readonly retryable: boolean, - public readonly status: number, - ) { - super(message); - this.name = "DashboardApiError"; - } -} - -export interface CloudEvaluationSubmissionReceipt { - readonly run_id: string; - readonly status: string; - readonly dispatch: "scheduled"; -} - export async function fetchOverview(): Promise { const res = await fetch(`${BASE}/api/v2/overview`); if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); @@ -125,16 +113,18 @@ export async function fetchAnalytics(): Promise { return res.json(); } +export function fetchRuntimeHealth(): Promise { + return schemaRequest("/api/health", HealthResponse); +} + export async function fetchDoctor(): Promise { const res = await fetch(`${BASE}/api/v2/doctor`); if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); return res.json(); } -export async function fetchSettings(): Promise { - const res = await fetch(`${BASE}/api/v2/settings`); - if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); - return res.json(); +export function fetchSettings(): Promise { + return schemaRequest("/api/v2/settings", DesktopSettingsResponse); } export async function fetchPlugins(): Promise { @@ -146,21 +136,24 @@ export async function fetchPlugins(): Promise { export function managePlugin( input: PluginManagementInputModel, ): Promise { - return portfolioRequest("/api/v2/plugins/manage", { - host: input.host, - plugin_id: input.pluginId, - action: input.action, - }); + return portfolioRequest( + "/api/v2/plugins/manage", + JSON.stringify({ + host: input.host, + plugin_id: input.pluginId, + action: input.action, + }), + ); } export function startCloudAccountLink(): Promise { - return portfolioRequest("/api/v2/settings/cloud-account/link/start", {}); + return portfolioRequest("/api/v2/settings/cloud-account/link/start", JSON.stringify({})); } export function completeCloudAccountLink( input: CompleteCloudAccountLinkRequest, ): Promise { - return portfolioRequest("/api/v2/settings/cloud-account/link/complete", input); + return portfolioRequest("/api/v2/settings/cloud-account/link/complete", JSON.stringify(input)); } export function fetchCloudBillingStatus(): Promise { @@ -170,19 +163,22 @@ export function fetchCloudBillingStatus(): Promise { export function createCloudBillingCheckout( input: DesktopBillingCheckoutRequest, ): Promise { - return portfolioRequest("/api/v2/settings/billing/checkout", input); + return portfolioRequest("/api/v2/settings/billing/checkout", JSON.stringify(input)); } export function createCloudBillingPortal(): Promise { - return portfolioRequest("/api/v2/settings/billing/portal", {}); + return portfolioRequest("/api/v2/settings/billing/portal", JSON.stringify({})); } export function finalizeCloudBillingCheckout( input: DesktopBillingCheckoutFinalizeRequest, ): Promise { - return portfolioRequest("/api/v2/settings/billing/checkout/finalize", { - session_id: input.sessionId, - }); + return portfolioRequest( + "/api/v2/settings/billing/checkout/finalize", + JSON.stringify({ + session_id: input.sessionId, + }), + ); } export async function fetchLibrary(): Promise { @@ -196,7 +192,7 @@ export function backupLibrarySkill(skillId: string): Promise<{ unchanged: number; snapshot: { snapshotId: string }; }> { - return portfolioRequest("/api/v2/library/backup", { skill_id: skillId }); + return portfolioRequest("/api/v2/library/backup", JSON.stringify({ skill_id: skillId })); } export function shareLibrarySkill( @@ -221,14 +217,16 @@ export function shareLibrarySkill( }> { return portfolioRequest( "/api/v2/library/share", - input.delivery === "email" - ? { - skill_id: input.skillId, - mode: input.mode, - delivery: input.delivery, - recipient_email: input.recipientEmail, - } - : { skill_id: input.skillId, mode: input.mode, delivery: input.delivery }, + JSON.stringify( + input.delivery === "email" + ? { + skill_id: input.skillId, + mode: input.mode, + delivery: input.delivery, + recipient_email: input.recipientEmail, + } + : { skill_id: input.skillId, mode: input.mode, delivery: input.delivery }, + ), ); } @@ -254,16 +252,19 @@ function licenseDraftRequest( previewId?: string; }, ): Promise { - return portfolioRequest(path, { - skill_id: input.skillId, - ...(input.skillSetId ? { set_id: input.skillSetId } : {}), - ...(input.previewId ? { preview_id: input.previewId } : {}), - terms: { - copyright_holder: input.terms.copyrightHolder, - licensed_organization: input.terms.licensedOrganization, - year: input.terms.year, - }, - }); + return portfolioRequest( + path, + JSON.stringify({ + skill_id: input.skillId, + set_id: input.skillSetId || undefined, + preview_id: input.previewId || undefined, + terms: { + copyright_holder: input.terms.copyrightHolder, + licensed_organization: input.terms.licensedOrganization, + year: input.terms.year, + }, + }), + ); } export function previewLibrarySkillLicense(input: { @@ -291,26 +292,35 @@ export function installLibrarySkill(input: { targetAgent: typeof input.targetAgent; targetPath: string; }> { - return portfolioRequest("/api/v2/library/install", { - skill_id: input.skillId, - target_agent: input.targetAgent, - }); + return portfolioRequest( + "/api/v2/library/install", + JSON.stringify({ + skill_id: input.skillId, + target_agent: input.targetAgent, + }), + ); } export function previewSkillSourceUpdate(skillName: string): Promise { - return portfolioRequest("/api/v2/library/source-update/preview", { - skill_name: skillName, - }); + return portfolioRequest( + "/api/v2/library/source-update/preview", + JSON.stringify({ + skill_name: skillName, + }), + ); } export function applySkillSourceUpdate(input: { skillName: string; strategy: "abort" | "take_upstream"; }): Promise { - return portfolioRequest("/api/v2/library/source-update/apply", { - skill_name: input.skillName, - strategy: input.strategy, - }); + return portfolioRequest( + "/api/v2/library/source-update/apply", + JSON.stringify({ + skill_name: input.skillName, + strategy: input.strategy, + }), + ); } export function prepareSkillSourceMerge(input: { @@ -318,17 +328,20 @@ export function prepareSkillSourceMerge(input: { harnessId: HarnessId; model?: string | null; }): Promise { - return portfolioRequest("/api/v2/library/source-update/merge/prepare", { - skill_name: input.skillName, - harness_id: input.harnessId, - model: input.model ?? null, - }); + return portfolioRequest( + "/api/v2/library/source-update/merge/prepare", + JSON.stringify({ + skill_name: input.skillName, + harness_id: input.harnessId, + model: input.model ?? null, + }), + ); } export function applySkillSourceMerge(mergeId: string): Promise { return portfolioRequest( `/api/v2/decisions/${encodeURIComponent(mergeId)}/approve`, - {}, + JSON.stringify({}), ).then((decision) => { if (decision.status === "approved" && decision.receipt) return decision.receipt; throw new Error( @@ -337,46 +350,20 @@ export function applySkillSourceMerge(mergeId: string): Promise { - const res = await fetch(`${BASE}/api/v2/settings/schedule`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - const data = (await res.json()) as DesktopSettingsResponse & { - error?: { message?: string } | string; - }; - if (!res.ok) { - const message = - typeof data.error === "string" - ? data.error - : (data.error?.message ?? `API error: ${res.status}`); - throw new Error(message); - } - return data; + return schemaRequest("/api/v2/settings/schedule", DesktopSettingsResponse, JSON.stringify(input)); } -export async function updateRemoteLibrarySettings( +export function updateRemoteLibrarySettings( input: UpdateRemoteLibraryRequest, ): Promise { - const res = await fetch(`${BASE}/api/v2/settings/remote-library`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - const data = (await res.json()) as DesktopSettingsResponse & { - error?: { message?: string } | string; - }; - if (!res.ok) { - const message = - typeof data.error === "string" - ? data.error - : (data.error?.message ?? `API error: ${res.status}`); - throw new Error(message); - } - return data; + return schemaRequest( + "/api/v2/settings/remote-library", + DesktopSettingsResponse, + JSON.stringify(input), + ); } export function previewRemoteLibrary(input: { @@ -391,7 +378,7 @@ export function previewRemoteLibrary(input: { }>; totalBytes: number; }> { - return portfolioRequest("/api/v2/settings/remote-library/preview", input); + return portfolioRequest("/api/v2/settings/remote-library/preview", JSON.stringify(input)); } export interface RemoteLibraryStatus { @@ -425,18 +412,18 @@ export function syncRemoteLibraryNow(): Promise<{ uploaded: number; unchanged: number; }> { - return portfolioRequest("/api/v2/settings/remote-library/sync", {}); + return portfolioRequest("/api/v2/settings/remote-library/sync", JSON.stringify({})); } export function exportRemoteLibraryNow(): Promise<{ outputPath: string }> { - return portfolioRequest("/api/v2/settings/remote-library/export", {}); + return portfolioRequest("/api/v2/settings/remote-library/export", JSON.stringify({})); } export function restoreRemoteLibraryNow(): Promise<{ targetRoot: string; restored: number; }> { - return portfolioRequest("/api/v2/settings/remote-library/restore", {}); + return portfolioRequest("/api/v2/settings/remote-library/restore", JSON.stringify({})); } export function fetchRemoteLibraryShares(): Promise { @@ -446,7 +433,7 @@ export function fetchRemoteLibraryShares(): Promise export function createPrivateRemoteLibraryShare( input: CreateRemoteLibraryShareRequest, ): Promise { - return portfolioRequest("/api/v2/settings/remote-library/shares", input); + return portfolioRequest("/api/v2/settings/remote-library/shares", JSON.stringify(input)); } export function actOnPrivateRemoteLibraryShare(input: { @@ -455,7 +442,7 @@ export function actOnPrivateRemoteLibraryShare(input: { }): Promise { return portfolioRequest( `/api/v2/settings/remote-library/shares/${encodeURIComponent(input.shareId)}/${input.action}`, - {}, + JSON.stringify({}), ); } @@ -470,7 +457,7 @@ export function updateWorkspaceSkillSetPolicy(input: { }): Promise { return portfolioRequest( `/api/v2/settings/workspace/policies/${encodeURIComponent(input.skillSetId)}`, - { action: input.action, reason: input.reason }, + JSON.stringify({ action: input.action, reason: input.reason }), ); } @@ -479,7 +466,7 @@ export function resetWorkspaceSkillSetPolicy(input: { }): Promise<{ success: true }> { return portfolioRequest( `/api/v2/settings/workspace/policies/${encodeURIComponent(input.skillSetId)}/reset`, - {}, + JSON.stringify({}), ); } @@ -495,7 +482,7 @@ export function inviteWorkspaceMember(input: { email: string; role: WorkspaceMemberRole; }): Promise<{ status: "invited" | "joined" }> { - return portfolioRequest("/api/v2/settings/workspace/invite", input); + return portfolioRequest("/api/v2/settings/workspace/invite", JSON.stringify(input)); } export function updateWorkspaceMemberRole(input: { @@ -504,96 +491,23 @@ export function updateWorkspaceMemberRole(input: { }): Promise<{ success: true }> { return portfolioRequest( `/api/v2/settings/workspace/members/${encodeURIComponent(input.userId)}/role`, - { role: input.role }, + JSON.stringify({ role: input.role }), ); } export function removeWorkspaceMember(input: { userId: string }): Promise<{ success: true }> { return portfolioRequest( `/api/v2/settings/workspace/members/${encodeURIComponent(input.userId)}/remove`, - {}, + JSON.stringify({}), ); } -export async function applyOnboarding( - input: ApplyOnboardingRequest, -): Promise { - const res = await fetch(`${BASE}/api/v2/settings/onboarding`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - const data = (await res.json()) as ApplyOnboardingResponse & { - error?: { message?: string } | string; - }; - if (!res.ok) { - const message = - typeof data.error === "string" - ? data.error - : (data.error?.message ?? `API error: ${res.status}`); - throw new Error(message); - } - return data; -} - -export async function portfolioRequest(path: string, body?: unknown): Promise { - const res = await fetch(`${BASE}${path}`, { - method: body ? "POST" : "GET", - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - const responseText = await res.text(); - let data: T & { - error?: - | { - code?: string; - message?: string; - suggestion?: string; - retryable?: boolean; - } - | string; - }; - try { - data = JSON.parse(responseText); - } catch { - if (res.status === 404 && responseText.trim() === "Not Found") { - throw new DashboardApiError( - "ROUTE_NOT_FOUND", - "The Desktop service is out of date. Restart SelfTune Desktop and try again.", - "Restart SelfTune Desktop to load the updated local service.", - false, - res.status, - ); - } - throw new DashboardApiError( - "INVALID_RESPONSE", - res.ok - ? "The local Desktop service returned an invalid response." - : `API error: ${res.status} ${res.statusText}`.trim(), - null, - res.ok || res.status >= 500, - res.status, - ); - } - if (!res.ok) { - if (typeof data.error === "object" && data.error !== null) { - throw new DashboardApiError( - data.error.code ?? "API_ERROR", - data.error.message ?? `API error: ${res.status}`, - data.error.suggestion ?? null, - data.error.retryable === true, - res.status, - ); - } - throw new DashboardApiError( - "API_ERROR", - typeof data.error === "string" ? data.error : `API error: ${res.status}`, - null, - res.status >= 500, - res.status, - ); - } - return data; +export function applyOnboarding(input: ApplyOnboardingRequest): Promise { + return schemaRequest( + "/api/v2/settings/onboarding", + ApplyOnboardingResponse, + JSON.stringify(input), + ); } export function fetchPortfolio(): Promise { @@ -607,27 +521,30 @@ export function fetchInsights(): Promise { export function reviewInsight(input: ReviewInsightRequest) { return portfolioRequest( "/api/v2/insights/review", - input, + JSON.stringify(input), ); } export function draftInsight( input: DraftInsightRequest, ): Promise<{ draft: { skill_dir: string } }> { - return portfolioRequest<{ draft: { skill_dir: string } }>("/api/v2/insights/draft", input); + return portfolioRequest<{ draft: { skill_dir: string } }>( + "/api/v2/insights/draft", + JSON.stringify(input), + ); } export function evaluateInsight(input: { candidate_id: string }): Promise<{ recommended: boolean; blockers: string[]; }> { - return portfolioRequest("/api/v2/insights/evaluate", input); + return portfolioRequest("/api/v2/insights/evaluate", JSON.stringify(input)); } export function releaseInsight(input: { candidate_id: string }): Promise<{ package_path: string; }> { - return portfolioRequest("/api/v2/insights/release", input); + return portfolioRequest("/api/v2/insights/release", JSON.stringify(input)); } export function quarantinePortfolioSkill(input: { @@ -635,22 +552,30 @@ export function quarantinePortfolioSkill(input: { skillPath: string; confirm?: boolean; }): Promise { - return portfolioRequest("/api/v2/portfolio/quarantine", { - skill_name: input.skillName, - skill_path: input.skillPath, - confirm: input.confirm ?? true, - }); + return portfolioRequest( + "/api/v2/portfolio/quarantine", + JSON.stringify({ + skill_name: input.skillName, + skill_path: input.skillPath, + confirm: input.confirm ?? true, + }), + ); } export function quarantinePortfolioSkills( - inputs: readonly { skillName: string; skillPath: string }[], + inputs: readonly (LibraryArchiveInput & { keepSearchable?: boolean })[], ): Promise { - return portfolioRequest("/api/v2/portfolio/quarantine-batch", { - skills: inputs.map((input) => ({ - skill_name: input.skillName, - skill_path: input.skillPath, - })), - }); + return portfolioRequest( + "/api/v2/portfolio/quarantine-batch", + JSON.stringify({ + skills: inputs.map((input) => ({ + skill_name: input.skillName, + skill_path: input.skillPath, + keep_searchable: input.keepSearchable, + expected_content_hash: input.expectedContentHash, + })), + }), + ); } export function previewQuarantinePortfolioSkill(input: { @@ -661,9 +586,12 @@ export function previewQuarantinePortfolioSkill(input: { } export function restorePortfolioSkill(quarantineId: string): Promise { - return portfolioRequest("/api/v2/portfolio/restore", { - quarantine_id: quarantineId, - }); + return portfolioRequest( + "/api/v2/portfolio/restore", + JSON.stringify({ + quarantine_id: quarantineId, + }), + ); } export function fetchDurableDecisions(): Promise<{ @@ -676,13 +604,16 @@ export function prepareSkillRemovalDecision(input: { skillName: string; locations: Array<{ skillPath: string; connection: string | null }>; }): Promise { - return portfolioRequest("/api/v2/decisions/removals", { - skill_name: input.skillName, - locations: input.locations.map((location) => ({ - skill_path: location.skillPath, - connection: location.connection, - })), - }); + return portfolioRequest( + "/api/v2/decisions/removals", + JSON.stringify({ + skill_name: input.skillName, + locations: input.locations.map((location) => ({ + skill_path: location.skillPath, + connection: location.connection, + })), + }), + ); } export function prepareSkillConsolidationDecision(input: { @@ -690,21 +621,27 @@ export function prepareSkillConsolidationDecision(input: { canonicalSkillPath: string; targetSkillPaths: string[]; }): Promise { - return portfolioRequest("/api/v2/decisions/consolidations", { - skill_name: input.skillName, - canonical_skill_path: input.canonicalSkillPath, - target_skill_paths: input.targetSkillPaths, - }); + return portfolioRequest( + "/api/v2/decisions/consolidations", + JSON.stringify({ + skill_name: input.skillName, + canonical_skill_path: input.canonicalSkillPath, + target_skill_paths: input.targetSkillPaths, + }), + ); } export function prepareProjectConflictDecision(input: { skillSetId: string; projectRoot: string; }): Promise { - return portfolioRequest("/api/v2/decisions/skill-set-conflicts", { - set_id: input.skillSetId, - project_root: input.projectRoot, - }); + return portfolioRequest( + "/api/v2/decisions/skill-set-conflicts", + JSON.stringify({ + set_id: input.skillSetId, + project_root: input.projectRoot, + }), + ); } export function decideDurableDecision(input: { @@ -713,12 +650,15 @@ export function decideDurableDecision(input: { }): Promise { return portfolioRequest( `/api/v2/decisions/${encodeURIComponent(input.decisionId)}/${input.action}`, - {}, + JSON.stringify({}), ); } export function rollbackDurableDecision(decisionId: string): Promise { - return portfolioRequest(`/api/v2/decisions/${encodeURIComponent(decisionId)}/rollback`, {}); + return portfolioRequest( + `/api/v2/decisions/${encodeURIComponent(decisionId)}/rollback`, + JSON.stringify({}), + ); } export function fetchSkillSets(): Promise { @@ -745,39 +685,12 @@ export interface LocalTraceCandidateReview { } | null; } export function prepareTraceCandidate(pattern_id: string): Promise { - return portfolioRequest("/api/v2/trace-candidates/prepare", { - pattern_id, - }); -} - -export interface LocalCloudEvaluationTarget { - source_id: string; - snapshot_id: string; - skill_id: string; - suite_id: string; - suite_name: string; - manifest_digest: string; - lane: "outcome_task"; -} - -export function fetchTraceCandidateTargets(draftId: string): Promise<{ - draft_id: string; - lifecycle: "prepared" | "submitted" | "stale"; - run_id: string | null; - targets: LocalCloudEvaluationTarget[]; - blockers: { code: string; message: string }[]; -}> { - return portfolioRequest(`/api/v2/trace-candidates/${encodeURIComponent(draftId)}/targets`); -} - -export function submitTraceCandidateTarget( - draftId: string, - target: Pick< - LocalCloudEvaluationTarget, - "source_id" | "snapshot_id" | "skill_id" | "suite_id" | "manifest_digest" - >, -): Promise { - return portfolioRequest(`/api/v2/trace-candidates/${encodeURIComponent(draftId)}/submit`, target); + return portfolioRequest( + "/api/v2/trace-candidates/prepare", + JSON.stringify({ + pattern_id, + }), + ); } export function updateSkillClassification( @@ -785,7 +698,7 @@ export function updateSkillClassification( ): Promise { return portfolioRequest( "/api/v2/skill-intelligence/classification", - input, + JSON.stringify(input), ); } @@ -794,16 +707,16 @@ export function reviewSkillSetSuggestion( ): Promise { return portfolioRequest( "/api/v2/skill-intelligence/suggestions/review", - input, + JSON.stringify(input), ); } export function createProjectSkillSet(input: CreateSkillSetRequest): Promise { - return portfolioRequest("/api/v2/skill-sets", input); + return portfolioRequest("/api/v2/skill-sets", JSON.stringify(input)); } export function updateProjectSkillSet(input: UpdateSkillSetRequest): Promise { - return portfolioRequest("/api/v2/skill-sets/update", input); + return portfolioRequest("/api/v2/skill-sets/update", JSON.stringify(input)); } export async function deleteProjectSkillSet(setId: string): Promise { @@ -817,13 +730,16 @@ export async function deleteProjectSkillSet(setId: string): Promise { } export function deriveProjectSkillSet(input: DeriveSkillSetRequest): Promise { - return portfolioRequest("/api/v2/skill-sets/derive", input); + return portfolioRequest("/api/v2/skill-sets/derive", JSON.stringify(input)); } export function exportProjectSkillSet( input: ExportSkillSetRequest, ): Promise<{ output_path: string }> { - return portfolioRequest<{ output_path: string }>("/api/v2/skill-sets/export", input); + return portfolioRequest<{ output_path: string }>( + "/api/v2/skill-sets/export", + JSON.stringify(input), + ); } export type LocalPluginExportTarget = "claude" | "openai" | "agent-plugins-v1" | "dual" | "all"; @@ -832,32 +748,41 @@ export function exportProjectSkillSetPlugin(input: { set_id: string; target: LocalPluginExportTarget; }): Promise<{ filename: string; content_base64: string }> { - return portfolioRequest("/api/v2/skill-sets/plugin-export", input); + return portfolioRequest("/api/v2/skill-sets/plugin-export", JSON.stringify(input)); } export function previewProjectSkillSetPluginInstall( skillSetId: string, ): Promise { - return portfolioRequest("/api/v2/skill-sets/plugin-install/preview", { - set_id: skillSetId, - }); + return portfolioRequest( + "/api/v2/skill-sets/plugin-install/preview", + JSON.stringify({ + set_id: skillSetId, + }), + ); } export function installProjectSkillSetPlugin( input: ProjectSkillSetPluginInstallInput, ): Promise { - return portfolioRequest("/api/v2/skill-sets/plugin-install", { - set_id: input.skillSetId, - expected_revision_hash: input.expectedRevisionHash, - hosts: input.hosts, - }); + return portfolioRequest( + "/api/v2/skill-sets/plugin-install", + JSON.stringify({ + set_id: input.skillSetId, + expected_revision_hash: input.expectedRevisionHash, + hosts: input.hosts, + }), + ); } export function previewProjectSkillSetPack(packUrl: string): Promise<{ packUrl: string; preview: SkillSetPackPreview; }> { - return portfolioRequest("/api/v2/skill-sets/packs/preview", { pack_url: packUrl }); + return portfolioRequest( + "/api/v2/skill-sets/packs/preview", + JSON.stringify({ pack_url: packUrl }), + ); } export function importProjectSkillSetPack(input: { @@ -868,10 +793,13 @@ export function importProjectSkillSetPack(input: { sourceRevisionSha256: string; objectSha256: string; }> { - return portfolioRequest("/api/v2/skill-sets/packs/import", { - pack_url: input.packUrl, - expected_object_sha256: input.expectedObjectSha256, - }); + return portfolioRequest( + "/api/v2/skill-sets/packs/import", + JSON.stringify({ + pack_url: input.packUrl, + expected_object_sha256: input.expectedObjectSha256, + }), + ); } export function fetchProjectSkillSetPacks(): Promise { @@ -883,14 +811,7 @@ export async function revokeProjectSkillSetPack(packId: string): Promise { method: "DELETE", }); if (response.ok) return; - const payload = (await response.json().catch(() => null)) as { - error?: { message?: string } | string; - } | null; - const message = - typeof payload?.error === "string" - ? payload.error - : (payload?.error?.message ?? `Pack revocation failed (${response.status}).`); - throw new Error(message); + throw responseError(response, await response.text()); } export function shareProjectSkillSet(input: { @@ -905,32 +826,41 @@ export function shareProjectSkillSet(input: { shareUrl: string | null; expiresAt: string; }> { - return portfolioRequest("/api/v2/skill-sets/share", { - set_id: input.skillSetId, - mode: input.mode, - delivery: input.delivery, - ...(input.recipientEmail ? { recipient_email: input.recipientEmail } : {}), - }); + return portfolioRequest( + "/api/v2/skill-sets/share", + JSON.stringify({ + set_id: input.skillSetId, + mode: input.mode, + delivery: input.delivery, + recipient_email: input.recipientEmail || undefined, + }), + ); } export function previewProjectSkillSet(input: PlanSkillSetRequest): Promise { - return portfolioRequest("/api/v2/skill-sets/plan", input); + return portfolioRequest("/api/v2/skill-sets/plan", JSON.stringify(input)); } export function applyProjectSkillSet( input: ApplySkillSetRequest, ): Promise { - return portfolioRequest("/api/v2/skill-sets/apply", input); + return portfolioRequest( + "/api/v2/skill-sets/apply", + JSON.stringify(input), + ); } export function previewProjectProvision( input: ProjectProvisionInput, ): Promise { - return portfolioRequest("/api/v2/skill-sets/project-plan", { - project_root: input.projectRoot, - set_ids: input.skillSetIds, - harnesses: input.harnesses, - }); + return portfolioRequest( + "/api/v2/skill-sets/project-plan", + JSON.stringify({ + project_root: input.projectRoot, + set_ids: input.skillSetIds, + harnesses: input.harnesses, + }), + ); } export function applyProjectProvision( @@ -938,12 +868,12 @@ export function applyProjectProvision( ): Promise { return portfolioRequest<{ project_root: string; receipt_count: number }>( "/api/v2/skill-sets/project-apply", - { + JSON.stringify({ project_root: input.projectRoot, set_ids: input.skillSetIds, harnesses: input.harnesses, create_react_project: input.createReactProject, - }, + }), ).then((result) => ({ projectRoot: result.project_root, receiptCount: result.receipt_count, @@ -951,7 +881,7 @@ export function applyProjectProvision( } export function rollbackProjectSkillSet(input: RollbackSkillSetRequest): Promise { - return portfolioRequest("/api/v2/skill-sets/rollback", input); + return portfolioRequest("/api/v2/skill-sets/rollback", JSON.stringify(input)); } export interface DashboardActionRequest { @@ -961,12 +891,13 @@ export interface DashboardActionRequest { autoSynthetic?: boolean; } -export interface DashboardActionResponse { - success: boolean; - output: string; - error: string | null; - exitCode?: number | null; -} +export const DashboardActionResponse = Schema.Struct({ + success: Schema.Boolean, + output: Schema.String, + error: Schema.NullOr(Schema.String), + exitCode: Schema.optionalKey(Schema.NullOr(Schema.Number)), +}); +export type DashboardActionResponse = typeof DashboardActionResponse.Type; export async function runDashboardAction( action: DashboardActionName, @@ -979,13 +910,9 @@ export async function runDashboardAction( }, body: JSON.stringify(payload), }); - const data = (await res.json()) as DashboardActionResponse & { - error?: string | null; - }; - if (!res.ok) { - throw new Error(data.error || `API error: ${res.status} ${res.statusText}`); - } - return data; + const responseText = await res.text(); + if (!res.ok) throw responseError(res, responseText); + return decodeResponse(res, responseText, DashboardActionResponse); } export class NotFoundError extends Error { diff --git a/apps/local-dashboard/src/assigned-skill-sets.ts b/apps/local-dashboard/src/assigned-skill-sets.ts index 6f88b4b0..1016659c 100644 --- a/apps/local-dashboard/src/assigned-skill-sets.ts +++ b/apps/local-dashboard/src/assigned-skill-sets.ts @@ -21,7 +21,7 @@ import type { TeamContributionPreviewInput, } from "@selftune/runtime/team-contribution"; -import { portfolioRequest } from "./api"; +import { portfolioRequest } from "./dashboard-http"; const QUERY_KEY = ["assigned-skill-sets"] as const; @@ -41,14 +41,13 @@ export function mapAssignedSkillSet(item: TeamAssignmentListItem): ProjectAssign canInstall: item.canInstall, canRollback: item.canRollback, syncStatus: item.syncStatus, - ...(item.localStatus === "current" - ? { - contribution: { + contribution: + item.localStatus === "current" + ? { status: "local_only" as const, summary: "Review local edits before choosing whether to send them to your team.", - }, - } - : {}), + } + : undefined, }; if (item.localStatus === "unknown" || item.localReceiptId === null) { return { ...base, status: "unknown", receiptId: null, failure: null }; @@ -81,44 +80,56 @@ export function fetchAssignedSkillSets(): Promise { - return portfolioRequest("/api/v2/skill-sets/assignments/preview", { - assignment_id: assignmentId, - }); + return portfolioRequest( + "/api/v2/skill-sets/assignments/preview", + JSON.stringify({ + assignment_id: assignmentId, + }), + ); } export function installAssignedSkillSet( input: ProjectAssignedSkillSetInstallInput, ): Promise { - return portfolioRequest("/api/v2/skill-sets/assignments/install", { - assignment_id: input.assignmentId, - request_id: input.requestId, - expected_release_id: input.expectedReleaseId, - expected_skill_set_revision_sha256: input.expectedSkillSetRevisionSha256, - expected_envelope_sha256: input.expectedEnvelopeSha256, - confirm_install: input.confirmInstall, - }); + return portfolioRequest( + "/api/v2/skill-sets/assignments/install", + JSON.stringify({ + assignment_id: input.assignmentId, + request_id: input.requestId, + expected_release_id: input.expectedReleaseId, + expected_skill_set_revision_sha256: input.expectedSkillSetRevisionSha256, + expected_envelope_sha256: input.expectedEnvelopeSha256, + confirm_install: input.confirmInstall, + }), + ); } export function rollbackAssignedSkillSet( input: ProjectAssignedSkillSetRollbackInput, ): Promise { - return portfolioRequest("/api/v2/skill-sets/assignments/undo", { - assignment_id: input.assignmentId, - receipt_id: input.receiptId, - confirm_rollback: input.confirmRollback, - }); + return portfolioRequest( + "/api/v2/skill-sets/assignments/undo", + JSON.stringify({ + assignment_id: input.assignmentId, + receipt_id: input.receiptId, + confirm_rollback: input.confirmRollback, + }), + ); } /** Local host seam for the contribution UI; canonical dashboard composition can adopt it separately. */ export function previewTeamContribution( input: TeamContributionPreviewInput, ): Promise { - return portfolioRequest("/api/v2/skill-sets/contributions/preview", { - assignment_id: input.assignmentId, - title: input.title, - message: input.message, - ...(input.sourceReceiptIds ? { source_receipt_ids: input.sourceReceiptIds } : {}), - }); + return portfolioRequest( + "/api/v2/skill-sets/contributions/preview", + JSON.stringify({ + assignment_id: input.assignmentId, + title: input.title, + message: input.message, + source_receipt_ids: input.sourceReceiptIds, + }), + ); } export function submitTeamContribution(input: { @@ -129,14 +140,17 @@ export function submitTeamContribution(input: { readonly contributionId: string | null; readonly syncStatus: "pending" | "synced"; }> { - return portfolioRequest("/api/v2/skill-sets/contributions/submit", { - preview_token: input.previewToken, - confirm_submit: input.confirmSubmit, - }); + return portfolioRequest( + "/api/v2/skill-sets/contributions/submit", + JSON.stringify({ + preview_token: input.previewToken, + confirm_submit: input.confirmSubmit, + }), + ); } export function syncTeamContributions() { - return portfolioRequest("/api/v2/skill-sets/contributions/sync", {}); + return portfolioRequest("/api/v2/skill-sets/contributions/sync", JSON.stringify({})); } function mapContributionPreview( diff --git a/apps/local-dashboard/src/components/SetupWizard.test.tsx b/apps/local-dashboard/src/components/SetupWizard.test.tsx index 1cdf6ed2..b0f7fe2b 100644 --- a/apps/local-dashboard/src/components/SetupWizard.test.tsx +++ b/apps/local-dashboard/src/components/SetupWizard.test.tsx @@ -1,45 +1,12 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ApplyOnboardingResponse, DesktopSettingsResponse } from "@/types"; -const navigate = vi.fn(); -const mutate = vi.fn(); - -vi.mock("react-router-dom", () => ({ - useNavigate: () => navigate, -})); - -vi.mock("@/hooks/useSettings", () => ({ - useApplyOnboarding: () => ({ isPending: false, mutate }), -})); - -vi.mock("sonner", () => ({ - toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, -})); - -vi.mock("@/components/HarnessLogo", () => ({ HarnessLogo: () => null })); -vi.mock("@/components/ui/switch", () => ({ - Switch: ({ "aria-label": ariaLabel }: { "aria-label": string }) => ( - - ), -})); -vi.mock("@/components/ui/dialog", () => ({ - Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, - DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, -})); - import { SetupWizard } from "./SetupWizard"; const settings: DesktopSettingsResponse = { @@ -89,10 +56,27 @@ const settings: DesktopSettingsResponse = { schedule: { supported: true, format: "launchd", settings_path: "/tmp/jobs.json", jobs: [] }, }; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + +function renderSetup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + render( + + + + } /> + Skills Library} /> + + + , + ); +} + afterEach(() => { cleanup(); - navigate.mockReset(); - mutate.mockReset(); + vi.restoreAllMocks(); + if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard); + else Reflect.deleteProperty(navigator, "clipboard"); }); describe("SetupWizard", () => { @@ -103,31 +87,54 @@ describe("SetupWizard", () => { value: { writeText }, }); - render(); + const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected request")); + renderSetup(); fireEvent.click(screen.getByRole("button", { name: /copy prompt for my ai agent/i })); expect(writeText).toHaveBeenCalledWith( expect.stringContaining("If the SelfTune skill is not already installed"), ); expect(screen.getByText("npx skills add selftune-dev/selftune")).toBeTruthy(); + expect(fetch).not.toHaveBeenCalled(); }); - it("opens the cleanup overview after selected history is processed", () => { - mutate.mockImplementation((_request, callbacks) => { - callbacks.onSuccess({ - ...settings, - onboarding: { ...settings.onboarding, completed: true }, - install_results: [], - source_sync: { status: "processed", message: null }, - } satisfies ApplyOnboardingResponse); - }); - - render(); - fireEvent.click(screen.getByRole("button", { name: /continue/i })); - fireEvent.click(screen.getByRole("button", { name: /continue/i })); + it("explains on-demand use, saves selected features, and opens the Library after setup", async () => { + const completed = { + ...settings, + onboarding: { ...settings.onboarding, completed: true }, + install_results: [], + source_sync: { status: "processed", message: null }, + } satisfies ApplyOnboardingResponse; + const fetch = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json(completed)); + renderSetup(); + for (let step = 0; step < 3; step += 1) { + fireEvent.click(screen.getByRole("button", { name: /continue/i })); + } + fireEvent.click(screen.getByRole("switch", { name: /Enable Health recommendations$/ })); fireEvent.click(screen.getByRole("button", { name: /continue/i })); - fireEvent.click(screen.getByRole("button", { name: /apply setup/i })); - expect(navigate).toHaveBeenCalledWith("/", { replace: true }); + expect(screen.getByText(/keep specialist skills ready/i)).toBeTruthy(); + expect(screen.getByText(/Corey Haines marketing skills/i)).toBeTruthy(); + expect(fetch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /apply setup/i })); + await screen.findByRole("heading", { name: "Skills Library" }); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith( + "/api/v2/settings/onboarding", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + import_sources: [], + hook_harnesses: [], + features: { + observability: true, + health_recommendations: false, + autonomous_improvement: false, + }, + }), + }), + ); }); }); diff --git a/apps/local-dashboard/src/components/SetupWizard.tsx b/apps/local-dashboard/src/components/SetupWizard.tsx index 33508d7d..5aac8906 100644 --- a/apps/local-dashboard/src/components/SetupWizard.tsx +++ b/apps/local-dashboard/src/components/SetupWizard.tsx @@ -6,12 +6,15 @@ import { CopyIcon, DatabaseIcon, HeartPulseIcon, + LibraryIcon, + SearchIcon, SparklesIcon, WandSparklesIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { ON_DEMAND_SKILL_PROMPT } from "@selftune/dashboard-core/screens/skills"; import { Button } from "@/components/ui/button"; import { HarnessLogo } from "@/components/HarnessLogo"; @@ -33,10 +36,14 @@ import type { } from "@/types"; type HookHarnessId = Exclude; -type Selection = Record; -type HookSelection = Record; -const STEPS = ["Connect agent", "Import history", "Install hooks", "Choose features"] as const; +const STEPS = [ + "Connect agent", + "Import history", + "Install hooks", + "Choose features", + "Use on demand", +] as const; const AGENT_SETUP_PROMPT = `Connect this AI agent to the SelfTune Mac app. @@ -75,11 +82,7 @@ const FEATURES: Array<{ }, ]; -function selectionFromSettings(settings: DesktopSettingsResponse): { - imports: Selection; - hooks: HookSelection; - features: Record; -} { +function selectionFromSettings(settings: DesktopSettingsResponse) { const imports = { ...settings.onboarding.import_sources }; const hooks = { ...settings.onboarding.hook_harnesses }; if (!settings.onboarding.completed) { @@ -148,7 +151,7 @@ export function SetupWizard({ settings }: { settings: DesktopSettingsResponse }) }); } setOpen(false); - navigate("/", { replace: true }); + navigate("/skills", { replace: true }); }, onError: (error) => toast.error("Setup failed", { @@ -166,6 +169,15 @@ export function SetupWizard({ settings }: { settings: DesktopSettingsResponse }) } } + async function copyOnDemandPrompt() { + try { + await navigator.clipboard.writeText(ON_DEMAND_SKILL_PROMPT); + toast.success("Example request copied"); + } catch { + toast.error("Could not copy the example request"); + } + } + return ( <> + + +

+ After setup, review usage-based suggestions in the Skills Library. Select the + skills you need occasionally, review context savings by harness, and choose Keep + selected on demand. You can undo the move or activate them for a task later. +

+ + )} diff --git a/apps/local-dashboard/src/components/live-action-feed.test.tsx b/apps/local-dashboard/src/components/live-action-feed.test.tsx index ebee9650..4c061764 100644 --- a/apps/local-dashboard/src/components/live-action-feed.test.tsx +++ b/apps/local-dashboard/src/components/live-action-feed.test.tsx @@ -1,80 +1,31 @@ -import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; -const entry = { - id: "evt-live-1", - action: "measure-baseline" as const, - skillName: "Taxes", - skillPath: "/tmp/Taxes/SKILL.md", - status: "running" as const, - startedAt: Date.parse("2026-04-15T10:00:00.000Z"), - updatedAt: Date.parse("2026-04-15T10:01:00.000Z"), - output: ["Replaying package evals"], - logs: [], - error: null, - exitCode: null, - summary: null, - metrics: null, - progress: null, -}; - -vi.mock("lucide-react", () => ({ - __esModule: true, - Activity: () => null, - ArrowRight: () => null, - Loader2: () => null, - default: { - Activity: () => null, - ArrowRight: () => null, - Loader2: () => null, - }, -})); - -vi.mock("@selftune/ui/lib", () => ({ - timeAgo: () => "just now", -})); - -vi.mock("@selftune/ui/primitives", () => ({ - Badge: ({ children }: { children?: ReactNode }) =>
{children}
, - Card: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
- ), - CardContent: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
- ), - CardHeader: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
- ), - CardTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
- ), -})); - -vi.mock("react-router-dom", () => ({ - Link: ({ children, to, className }: { children: ReactNode; to: string; className?: string }) => ( - - {children} - - ), -})); - -vi.mock("@/lib/live-action-feed", () => ({ - formatActionLabel: () => "Measure baseline", - useLiveActionFeed: () => [entry], -})); +import { ingestDashboardActionEvent } from "@/lib/live-action-feed"; +import { LiveActionFeed } from "./live-action-feed"; describe("LiveActionFeed", () => { - it("links live lifecycle entries to the exact live run", async () => { - const { LiveActionFeed } = await import("./live-action-feed"); - const html = renderToStaticMarkup(); + it("renders an ingested run and its output with a link to that exact event", () => { + const event = { + event_id: "evt-live-1", + action: "measure-baseline", + skill_name: "Taxes", + skill_path: "/tmp/Taxes/SKILL.md", + ts: Date.now(), + } as const; + ingestDashboardActionEvent({ ...event, stage: "started" }); + ingestDashboardActionEvent({ ...event, stage: "stdout", chunk: "Replaying package evals" }); + const html = renderToStaticMarkup( + + + , + ); expect(html).toContain( 'href="/live-run?event=evt-live-1&action=measure-baseline&skill=Taxes"', ); - expect(html).toContain("Live lifecycle actions"); expect(html).toContain("Measure baseline"); - expect(html).toContain("Live run"); expect(html).toContain("Replaying package evals"); }); }); diff --git a/apps/local-dashboard/src/components/settings/BillingSettingsPanel.test.tsx b/apps/local-dashboard/src/components/settings/BillingSettingsPanel.test.tsx index 6a6e1e1b..f2df5d4c 100644 --- a/apps/local-dashboard/src/components/settings/BillingSettingsPanel.test.tsx +++ b/apps/local-dashboard/src/components/settings/BillingSettingsPanel.test.tsx @@ -1,36 +1,11 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DesktopBillingStatus } from "@/types"; -const checkoutMutate = vi.fn(); -const portalMutate = vi.fn(); -const refetch = vi.fn(); -let billingState: { - isLoading: boolean; - isError: boolean; - data: DesktopBillingStatus | undefined; - error: Error | null; - refetch: () => void; -}; - -vi.mock("@/hooks/useSettings", () => ({ - useCloudBillingStatus: () => billingState, - useCloudBillingCheckout: () => ({ isPending: false, mutate: checkoutMutate }), - useCloudBillingPortal: () => ({ isPending: false, mutate: portalMutate }), -})); -vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); -vi.mock("@/components/ui/button", () => ({ - Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( - - ), -})); -vi.mock("@/components/ui/input", () => ({ - Input: (props: React.InputHTMLAttributes) => , -})); - import { BillingSettingsPanel } from "./BillingSettingsPanel"; const plans = [ @@ -70,74 +45,115 @@ function status(overrides: Partial = {}): DesktopBillingSt } function renderPanel(props: Partial> = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); return render( - , + + + , ); } afterEach(() => { cleanup(); - checkoutMutate.mockReset(); - portalMutate.mockReset(); - refetch.mockReset(); - billingState = { isLoading: false, isError: false, data: status(), error: null, refetch }; + vi.restoreAllMocks(); }); describe("BillingSettingsPanel", () => { it("offers Connect Cloud while Desktop is not linked", () => { + const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected request")); const onConnect = vi.fn(); renderPanel({ cloudConfigured: false, onConnect }); fireEvent.click(screen.getByRole("button", { name: "Connect Cloud" })); expect(onConnect).toHaveBeenCalledOnce(); + expect(fetch).not.toHaveBeenCalled(); }); - it("shows the linked workspace plan with Cloud-compatible price formatting", () => { + it("uses a user-facing Enterprise fallback when Cloud omits that plan", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => + Response.json(status({ plan: "enterprise", availablePlans: [] })), + ); renderPanel(); - expect(screen.getByText("Current plan: Community")).not.toBeNull(); + await screen.findByText("Current plan: Enterprise"); + }); + + it("shows pricing, sends the selected Team seats, and opens the returned checkout URL", async () => { + const openExternal = vi.fn(); + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (url === "/api/v2/settings/billing/status") return Response.json(status()); + if (url === "/api/v2/settings/billing/checkout") + return Response.json({ url: "https://stripe.test/checkout" }); + throw new Error(`Unexpected request: ${url}`); + }); + renderPanel({ openExternal }); + await screen.findByText("Current plan: Community"); expect(screen.getByText("$12/month")).not.toBeNull(); + fireEvent.change(screen.getByLabelText("Team seats"), { target: { value: "7" } }); + const [pro, team] = screen.getAllByRole("button", { name: "Choose plan" }); + if (!pro || !team) throw new Error("Expected Pro and Team choices"); + fireEvent.click(team); + await waitFor(() => expect(openExternal).toHaveBeenCalledWith("https://stripe.test/checkout")); + expect(fetch).toHaveBeenCalledWith( + "/api/v2/settings/billing/checkout", + expect.objectContaining({ method: "POST", body: JSON.stringify({ plan: "team", seats: 7 }) }), + ); }); - it("uses a user-facing Enterprise fallback when Cloud omits that plan", () => { - billingState = { - isLoading: false, - isError: false, - data: status({ plan: "enterprise", availablePlans: [] }), - error: null, - refetch, - }; + it("recovers after an offline response without asking to reconnect", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + Response.json( + { error: { code: "UNAVAILABLE", message: "Cloud offline" } }, + { status: 503 }, + ), + ) + .mockImplementation(async () => Response.json(status())); renderPanel(); - expect(screen.getByText("Current plan: Enterprise")).not.toBeNull(); + expect((await screen.findByRole("alert")).textContent).toBe("Cloud offline"); + expect(screen.queryByRole("button", { name: "Connect Cloud" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + await screen.findByText("Current plan: Community"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).toBeNull(); }); - it("sends the owner-selected Team seat count and opens checkout externally", () => { + it("opens the portal for an existing subscription instead of creating checkout", async () => { const openExternal = vi.fn(); - checkoutMutate.mockImplementation((input, callbacks) => - callbacks.onSuccess({ url: "https://stripe.test" }), - ); + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (url === "/api/v2/settings/billing/status") + return Response.json( + status({ plan: "pro", subscriptionStatus: "active", hasStripeCustomer: true }), + ); + if (url === "/api/v2/settings/billing/portal") + return Response.json({ url: "https://stripe.test/portal" }); + throw new Error(`Unexpected request: ${url}`); + }); renderPanel({ openExternal }); - fireEvent.change(screen.getByLabelText("Team seats"), { target: { value: "7" } }); - fireEvent.click(screen.getAllByRole("button", { name: "Choose plan" })[1]!); - expect(checkoutMutate).toHaveBeenCalledWith({ plan: "team", seats: 7 }, expect.any(Object)); - expect(openExternal).toHaveBeenCalledWith("https://stripe.test"); + fireEvent.click(await screen.findByRole("button", { name: "Manage subscription" })); + await waitFor(() => expect(openExternal).toHaveBeenCalledWith("https://stripe.test/portal")); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenLastCalledWith( + "/api/v2/settings/billing/portal", + expect.objectContaining({ method: "POST", body: "{}" }), + ); }); - it("renders a retryable inline error without disconnecting the local app", () => { - billingState = { - isLoading: false, - isError: true, - data: undefined, - error: new Error("Cloud offline"), - refetch, - }; + it("does not let a non-owner start checkout", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json(status({ canManageBilling: false }))); renderPanel(); - expect(screen.getByRole("alert").textContent).toBe("Cloud offline"); - fireEvent.click(screen.getByRole("button", { name: /retry/i })); - expect(refetch).toHaveBeenCalledOnce(); + await screen.findByText("Only workspace owners can manage billing."); + for (const button of screen.getAllByRole("button", { name: "Choose plan" })) { + expect(button.hasAttribute("disabled")).toBe(true); + fireEvent.click(button); + } + expect(fetch).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/local-dashboard/src/components/ui/input-group.tsx b/apps/local-dashboard/src/components/ui/input-group.tsx index cdbd303f..3fe3680a 100644 --- a/apps/local-dashboard/src/components/ui/input-group.tsx +++ b/apps/local-dashboard/src/components/ui/input-group.tsx @@ -50,7 +50,7 @@ function InputGroupAddon({ data-align={align} className={cn(inputGroupAddonVariants({ align }), className)} onClick={(e) => { - if ((e.target as HTMLElement).closest("button")) { + if (e.target instanceof Element && e.target.closest("button")) { return; } e.currentTarget.parentElement diff --git a/apps/local-dashboard/src/components/ui/sidebar.tsx b/apps/local-dashboard/src/components/ui/sidebar.tsx deleted file mode 100644 index 2d9c31c1..00000000 --- a/apps/local-dashboard/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,689 +0,0 @@ -import { mergeProps } from "@base-ui/react/merge-props"; -import { useRender } from "@base-ui/react/use-render"; -import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@selftune/ui/primitives"; -import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; -import * as React from "react"; - -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/lib/utils"; - -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_MOBILE = "18rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; -const SIDEBAR_KEYBOARD_SHORTCUT = "b"; - -type SidebarContextProps = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open], - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; - - const contextValue = React.useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], - ); - - return ( - -
- {children} -
-
- ); -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - dir, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - - if (collapsible === "none") { - return ( -
- {children} -
- ); - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ); - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ); -} - -function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { - const { toggleSidebar } = useSidebar(); - - return ( - - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); - - return ( - , - Card: ({ children }: { children: ReactNode }) =>
{children}
, - CardContent: ({ children }: { children: ReactNode }) =>
{children}
, - CardDescription: ({ children }: { children: ReactNode }) =>
{children}
, - CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, - CardTitle: ({ children }: { children: ReactNode }) =>
{children}
, -})); - -vi.mock("react-router-dom", () => ({ - Link: ({ children }: { children?: ReactNode }) =>
{children}
, - useSearchParams: () => [new URLSearchParams(), () => {}], -})); - -vi.mock("@/hooks/useSkillReport", () => ({ - useSkillReport: () => ({ - data: { - session_metadata: [], - token_usage: { - total_input_tokens: 1234, - total_output_tokens: 567, - }, - }, - }), -})); - -vi.mock("@/lib/live-action-feed", () => ({ - formatActionLabel: () => "Deploy candidate", - useLiveActionFeed: () => [selectedEntry], - useSelectedLiveActionEntry: () => selectedEntry, -})); +function renderRun(eventId: string, summary: DashboardActionResultSummary) { + const event = { + event_id: eventId, + action: "deploy-candidate", + skill_name: "Taxes", + skill_path: "/tmp/Taxes/SKILL.md", + ts: Date.now(), + } as const; + ingestDashboardActionEvent({ ...event, stage: "started" }); + ingestDashboardActionEvent({ ...event, stage: "finished", success: true, summary }); + const client = new QueryClient(); + try { + return renderToStaticMarkup( + + + + + , + ); + } finally { + client.clear(); + } +} describe("LiveRun", () => { - it("renders measured package evidence, efficiency, watch signal, and next-command guidance", async () => { - const { LiveRun } = await import("./LiveRun"); - const html = renderToStaticMarkup(); + it("renders measured package evidence, efficiency, watch signal, and next-command guidance", () => { + const html = renderRun("event-evidence", selectedSummary); expect(html).toContain("Measured package evidence"); expect(html).toContain("Evaluation source"); @@ -257,33 +209,29 @@ describe("LiveRun", () => { expect(html).toContain("selftune publish --skill-path /tmp/Taxes/SKILL.md"); }); - it("renders bounded search surface budgeting when present", async () => { - selectedEntry.summary.search_run = { - search_id: "sr_123", - parent_candidate_id: "pkgcand_parent123456", - winner_candidate_id: "pkgcand_winner123456", - winner_rationale: "Routing weakness dominated the measured gap.", - candidates_evaluated: 5, - frontier_size: 2, - parent_selection_method: "highest_ranked_frontier", - surface_plan: { - routing_count: 4, - body_count: 1, - weakness_source: "accepted_frontier", - routing_weakness: 0.9, - body_weakness: 0.1, + it("renders bounded search surface budgeting from the selected event", () => { + const html = renderRun("event-search", { + ...selectedSummary, + search_run: { + search_id: "sr_123", + parent_candidate_id: "pkgcand_parent123456", + winner_candidate_id: "pkgcand_winner123456", + winner_rationale: "Routing weakness dominated the measured gap.", + candidates_evaluated: 5, + frontier_size: 2, + parent_selection_method: "highest_ranked_frontier", + surface_plan: { + routing_count: 4, + body_count: 1, + weakness_source: "accepted_frontier", + routing_weakness: 0.9, + body_weakness: 0.1, + }, }, - }; - - try { - const { LiveRun } = await import("./LiveRun"); - const html = renderToStaticMarkup(); + }); - expect(html).toContain("Surface budget"); - expect(html).toContain("Routing 4, body 1"); - expect(html).toContain("accepted_frontier"); - } finally { - selectedEntry.summary.search_run = null; - } + expect(html).toContain("Surface budget"); + expect(html).toContain("Routing 4, body 1"); + expect(html).toContain("accepted_frontier"); }); }); diff --git a/apps/local-dashboard/src/pages/LiveRun.tsx b/apps/local-dashboard/src/pages/LiveRun.tsx index f6c81358..b66c2aee 100644 --- a/apps/local-dashboard/src/pages/LiveRun.tsx +++ b/apps/local-dashboard/src/pages/LiveRun.tsx @@ -1,4 +1,6 @@ import { timeAgo } from "@selftune/ui/lib"; +import { DashboardActionName } from "@selftune/runtime/dashboard-contract/action-name"; +import * as Schema from "effect/Schema"; import { Badge, Button, @@ -18,12 +20,7 @@ import { useSelectedLiveActionEntry, } from "@/lib/live-action-feed"; import { normalizeLifecycleCommand } from "@/lib/lifecycle-surface"; -import type { - DashboardActionName, - DashboardActionResultSummary, - DashboardSearchRunSummary, - SessionMeta, -} from "@/types"; +import type { DashboardActionResultSummary, DashboardSearchRunSummary, SessionMeta } from "@/types"; function statusBadge(status: "running" | "success" | "error") { if (status === "running") { @@ -435,7 +432,8 @@ export function LiveRun() { const [searchParams, setSearchParams] = useSearchParams(); const eventId = searchParams.get("event") || undefined; const skillName = searchParams.get("skill") || undefined; - const action = (searchParams.get("action") || undefined) as DashboardActionName | undefined; + const requestedAction = searchParams.get("action"); + const action = Schema.is(DashboardActionName)(requestedAction) ? requestedAction : undefined; const entries = useLiveActionFeed(); const selectedEntry = useSelectedLiveActionEntry({ diff --git a/apps/local-dashboard/src/pages/Overview.test.tsx b/apps/local-dashboard/src/pages/Overview.test.tsx deleted file mode 100644 index 65ceb966..00000000 --- a/apps/local-dashboard/src/pages/Overview.test.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -// Mock heavy external dependencies to avoid import timeouts -vi.mock("lucide-react", () => ({ - Activity: () => null, - AlertCircleIcon: () => null, - AlertTriangleIcon: () => null, - ArrowLeft: () => null, - BoltIcon: () => null, - Bot: () => null, - Boxes: () => null, - CheckCircleIcon: () => null, - ChevronDownIcon: () => null, - CircleDotIcon: () => null, - ClockIcon: () => null, - Cpu: () => null, - EyeIcon: () => null, - HelpCircleIcon: () => null, - LayersIcon: () => null, - Loader2: () => null, - RefreshCwIcon: () => null, - RocketIcon: () => null, - SparklesIcon: () => null, - TerminalSquare: () => null, - XCircleIcon: () => null, -})); - -vi.mock("@selftune/ui/primitives", () => ({ - Badge: () => null, - Button: ({ children }: { children?: React.ReactNode }) => , - Card: ({ children }: { children: unknown }) => children, - CardAction: ({ children }: { children: unknown }) => children, - CardContent: ({ children }: { children: unknown }) => children, - CardDescription: ({ children }: { children: unknown }) => children, - CardHeader: ({ children }: { children: unknown }) => children, - CardTitle: ({ children }: { children: unknown }) => children, - Tabs: ({ children }: { children: unknown }) => children, - TabsContent: ({ children }: { children: unknown }) => children, - TabsList: ({ children }: { children: unknown }) => children, - TabsTrigger: ({ children }: { children: unknown }) => children, -})); - -vi.mock("@selftune/ui/components", () => ({ - AutonomyHeroCard: () =>
Autonomy Hero
, - SupervisionFeed: () =>
Supervision Feed
, - TrustWatchlistRail: () =>
Trust Watchlist
, -})); - -vi.mock("@/components/ui/skeleton", () => ({ - Skeleton: () => null, -})); - -vi.mock("@/api", () => ({ - runDashboardAction: vi.fn(), -})); - -vi.mock("@selftune/dashboard-core/screens/overview", () => ({ - OverviewCompositionSurface: ({ - cleanup, - sectionsBeforeFeed, - }: { - cleanup?: { - activeSkillCount: number; - candidates: Array; - consolidationCandidates?: Array; - }; - sectionsBeforeFeed?: React.ReactNode; - }) => ( -
- {cleanup ? `Cleanup ${cleanup.candidates.length} of ${cleanup.activeSkillCount}` : null} - {cleanup?.consolidationCandidates - ? `Duplicates ${cleanup.consolidationCandidates.length}` - : null} - {sectionsBeforeFeed} -
- ), -})); - -vi.mock("react-router-dom", () => ({ - Link: () => null, - useNavigate: () => () => {}, - useParams: () => ({ name: "test-skill" }), - useSearchParams: () => [new URLSearchParams(), () => {}], -})); - -vi.mock("sonner", () => ({ - toast: { - error: vi.fn(), - }, -})); - -vi.mock("../hooks/useOrchestrateRuns", () => ({ - useOrchestrateRuns: () => ({ - data: null, - isPending: true, - isError: false, - error: null, - }), -})); - -vi.mock("../hooks/usePortfolio", () => ({ - usePortfolio: () => ({ - data: { - audit: { - generated_at: "2026-07-22T00:00:00.000Z", - thresholds: { - min_sessions: 20, - inactive_days: 30, - min_checks: 10, - routing_miss_rate: 0.85, - }, - session_count: 40, - installed_count: 12, - counts: { - protected: 1, - unobserved: 2, - under_observed: 1, - routing_problem: 0, - active: 7, - inactive_candidate: 1, - consolidation_candidate: 0, - }, - skills: [ - { - skill_name: "stale-skill", - skill_path: "/skills/stale-skill/SKILL.md", - package_path: "/skills/stale-skill", - scope: "global", - classification: "inactive_candidate", - recommendation: "review_quarantine", - reason: "No trusted invocation for 45 days across 30 subsequent sessions.", - evidence: { - trusted_checks: 12, - triggered_count: 1, - miss_rate: 0, - last_seen_at: "2026-06-07T00:00:00.000Z", - last_invoked_at: "2026-06-07T00:00:00.000Z", - sessions_since_invocation: 30, - inactive_days: 45, - package_modified_at: "2026-01-01T00:00:00.000Z", - }, - }, - ], - }, - quarantined: [], - }, - }), -})); - -vi.mock("../hooks/useLibrary", () => ({ - useLibrary: () => ({ - data: { - generatedAt: "2026-07-22T00:00:00.000Z", - counts: { total: 1, active: 1, library: 0, draft: 0, archived: 0 }, - skills: [ - { - skillId: "agent-browser", - name: "agent-browser", - lifecycle: "active", - revisions: [ - { - contentHash: "current-hash", - locations: [ - { - sourceKind: "installed", - packagePath: "/home/test/.agents/skills/agent-browser", - skillPath: "/home/test/.agents/skills/agent-browser/SKILL.md", - harness: "codex", - scope: "global", - projectRoot: null, - linkedPackagePath: null, - active: true, - modifiedAt: "2026-07-20T10:00:00.000Z", - lastUsedAt: "2026-07-20T10:00:00.000Z", - origin: null, - updateStatus: "current", - }, - { - sourceKind: "installed", - packagePath: "/projects/app/.agents/skills/agent-browser", - skillPath: "/projects/app/.agents/skills/agent-browser/SKILL.md", - harness: "codex", - scope: "project", - projectRoot: "/projects/app", - linkedPackagePath: null, - active: true, - modifiedAt: "2026-07-18T10:00:00.000Z", - lastUsedAt: "2026-07-18T10:00:00.000Z", - origin: null, - updateStatus: "untracked", - }, - ], - }, - ], - locations: [], - lastUsedAt: "2026-07-20T10:00:00.000Z", - lastModifiedAt: "2026-07-20T10:00:00.000Z", - origins: [], - updateStatus: "current", - }, - ], - }, - }), -})); - -describe("Overview", () => { - it("module exports Overview component", async () => { - const { Overview } = await import("./Overview"); - expect(Overview).toBeDefined(); - expect(typeof Overview).toBe("function"); - }); - - it("surfaces evidence-backed cleanup candidates after history processing", async () => { - const { Overview } = await import("./Overview"); - const html = renderToStaticMarkup( - {}} - overviewQuery={ - { - data: { - overview: { - telemetry: [], - skills: [], - evolution: [], - counts: { - telemetry: 0, - skills: 0, - evolution: 0, - evidence: 0, - sessions: 0, - prompts: 0, - }, - unmatched_queries: [], - pending_proposals: [], - active_sessions: 0, - recent_activity: [], - }, - skills: [], - watched_skills: [], - autonomy_status: { - level: "watching", - summary: "watching", - last_run: null, - skills_observed: 0, - pending_reviews: 0, - attention_required: 0, - }, - attention_queue: [], - trust_watchlist: [], - recent_decisions: [], - creator_testing: null, - }, - isPending: false, - isError: false, - error: null, - refetch: () => Promise.resolve(), - } as never - } - />, - ); - - expect(html).toContain("Cleanup 1 of 12"); - expect(html).toContain("Duplicates 1"); - }); - - it("renders the creator test loop summary when overview data includes it", async () => { - const { Overview } = await import("./Overview"); - const html = renderToStaticMarkup( - {}} - overviewQuery={ - { - data: { - overview: { - telemetry: [], - skills: [], - evolution: [], - counts: { - telemetry: 0, - skills: 0, - evolution: 0, - evidence: 0, - sessions: 0, - prompts: 0, - }, - unmatched_queries: [], - pending_proposals: [], - active_sessions: 0, - recent_activity: [], - }, - skills: [], - watched_skills: [], - autonomy_status: { - level: "watching", - summary: "watching", - last_run: null, - skills_observed: 0, - pending_reviews: 0, - attention_required: 0, - }, - attention_queue: [], - trust_watchlist: [], - recent_decisions: [], - creator_testing: { - summary: "1 still needs evals.", - counts: { - run_create_check: 0, - finish_package: 0, - generate_evals: 1, - run_unit_tests: 0, - run_replay_dry_run: 0, - measure_baseline: 0, - deploy_candidate: 0, - watch_deployment: 0, - }, - priorities: [ - { - skill_name: "research", - step: "generate_evals", - summary: "Trusted telemetry exists, but no canonical eval set is stored yet.", - recommended_command: "selftune eval generate --skill research", - }, - ], - }, - }, - isPending: false, - isError: false, - error: null, - refetch: () => Promise.resolve(), - } as never - } - />, - ); - - expect(html).toContain("Draft skill lifecycle"); - expect(html).toContain("Generate evals"); - expect(html).toContain("Ship candidate"); - expect(html).toContain("selftune eval generate --skill research"); - expect(html).toContain("Run now"); - }); - - it("renders draft-package create-check priorities in the creator test loop panel", async () => { - const { Overview } = await import("./Overview"); - const html = renderToStaticMarkup( - {}} - overviewQuery={ - { - data: { - overview: { - telemetry: [], - skills: [], - evolution: [], - counts: { - telemetry: 0, - skills: 0, - evolution: 0, - evidence: 0, - sessions: 0, - prompts: 0, - }, - unmatched_queries: [], - pending_proposals: [], - active_sessions: 0, - recent_activity: [], - }, - skills: [], - watched_skills: [], - autonomy_status: { - level: "watching", - summary: "watching", - last_run: null, - skills_observed: 0, - pending_reviews: 0, - attention_required: 0, - }, - attention_queue: [], - trust_watchlist: [], - recent_decisions: [], - creator_testing: { - summary: "1 need create check.", - counts: { - run_create_check: 1, - finish_package: 0, - generate_evals: 0, - run_unit_tests: 0, - run_replay_dry_run: 0, - measure_baseline: 0, - deploy_candidate: 0, - watch_deployment: 0, - }, - priorities: [ - { - skill_name: "draft-writer", - step: "run_create_check", - summary: "Run create check before publishing.", - recommended_command: - "selftune create check --skill-path /workspace/draft-writer/SKILL.md", - }, - ], - }, - }, - isPending: false, - isError: false, - error: null, - refetch: () => Promise.resolve(), - } as never - } - />, - ); - - expect(html).toContain("Verify draft"); - expect(html).toContain("selftune verify --skill-path /workspace/draft-writer/SKILL.md"); - expect(html).toContain("Run now"); - }); -}); diff --git a/apps/local-dashboard/src/pages/Overview.tsx b/apps/local-dashboard/src/pages/Overview.tsx deleted file mode 100644 index 339fba03..00000000 --- a/apps/local-dashboard/src/pages/Overview.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import { - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@selftune/ui/primitives"; -import type { UseQueryResult } from "@tanstack/react-query"; -import { AlertCircleIcon, RefreshCwIcon } from "lucide-react"; -import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { - OverviewCompositionSurface, - type OverviewComparisonRow, -} from "@selftune/dashboard-core/screens/overview"; -import { toast } from "sonner"; -import { recommendLibraryConsolidation } from "@selftune/control-plane/library-consolidation"; - -import { runDashboardAction } from "@/api"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useOrchestrateRuns } from "@/hooks/useOrchestrateRuns"; -import { useLibrary } from "@/hooks/useLibrary"; -import { usePortfolio } from "@/hooks/usePortfolio"; -import { normalizeLifecycleCommand, normalizeLifecycleText } from "@/lib/lifecycle-surface"; -import type { - CreateCheckState, - DashboardActionName, - CreatorOverviewStep, - CreatorLoopNextStep, - OverviewResponse, - SkillHealthStatus, -} from "@/types"; - -function formatLoopStep(step: CreatorLoopNextStep): string { - switch (step) { - case "generate_evals": - return "Generate evals"; - case "run_unit_tests": - return "Run unit tests"; - case "run_replay_dry_run": - return "Replay dry-run"; - case "measure_baseline": - return "Measure baseline"; - case "deploy_candidate": - return "Ship candidate"; - case "watch_deployment": - return "Monitor live"; - } -} - -function formatCreatorOverviewStep(step: CreatorOverviewStep): string { - switch (step) { - case "run_create_check": - return "Verify draft"; - case "finish_package": - return "Finish package"; - case "generate_evals": - return "Generate evals"; - case "run_unit_tests": - return "Run unit tests"; - case "run_replay_dry_run": - return "Replay dry-run"; - case "measure_baseline": - return "Measure baseline"; - case "deploy_candidate": - return "Ship candidate"; - case "watch_deployment": - return "Monitor live"; - } -} - -function actionForCreatorOverviewStep(step: CreatorOverviewStep): DashboardActionName | null { - switch (step) { - case "run_create_check": - return "create-check"; - case "generate_evals": - return "generate-evals"; - case "run_unit_tests": - return "generate-unit-tests"; - case "run_replay_dry_run": - return "replay-dry-run"; - case "measure_baseline": - return "measure-baseline"; - case "deploy_candidate": - return "deploy-candidate"; - case "watch_deployment": - return "watch"; - case "finish_package": - return null; - } -} - -function formatCreateState(state: CreateCheckState): string { - switch (state) { - case "blocked_spec_validation": - return "Verification blocked"; - case "needs_spec_validation": - return "Verify draft"; - case "needs_package_resources": - return "Finish package"; - case "needs_evals": - return "Generate evals"; - case "needs_unit_tests": - return "Generate unit tests"; - case "needs_routing_replay": - return "Replay package"; - case "needs_baseline": - return "Measure baseline"; - case "ready_to_publish": - return "Publish draft"; - } -} - -function CreatorLoopPanel({ - data, - skills, -}: { - data: OverviewResponse["creator_testing"]; - skills: OverviewResponse["skills"]; -}) { - const [runningAction, setRunningAction] = useState(null); - const navigate = useNavigate(); - - if (!data) return null; - - function handleRunPriority( - priority: NonNullable["priorities"][number], - ) { - const action = actionForCreatorOverviewStep(priority.step); - const skill = skills.find((entry) => entry.skill_name === priority.skill_name); - const skillPath = - skill?.create_readiness?.skill_path ?? skill?.testing_readiness?.skill_path ?? null; - - if (!action || !skillPath) { - toast.error("Action unavailable", { - description: - priority.step === "finish_package" - ? "Finish package is still a manual step. Use the recommended command and file checklist from the skill report." - : "This priority needs a resolved SKILL.md path before the dashboard can run it.", - }); - return; - } - - const actionKey = `${priority.skill_name}:${action}`; - setRunningAction(actionKey); - navigate(`/live-run?${new URLSearchParams({ skill: priority.skill_name, action }).toString()}`); - void runDashboardAction(action, { - skill: priority.skill_name, - skillPath, - autoSynthetic: - action === "generate-evals" && - skill?.testing_readiness?.eval_readiness === "cold_start_ready", - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - toast.error("Action failed to start", { description: message }); - }) - .finally(() => { - setRunningAction(null); - }); - } - - return ( - - - Draft skill lifecycle - - Verify the draft, fill any missing evidence, publish safely, then monitor live behavior. - This surface tracks whether each skill is still blocked on verification, ready to ship, or - already under watch. - - - -

{normalizeLifecycleText(data.summary)}

- -
- {[ - ["Verify draft", data.counts.run_create_check], - ["Finish package", data.counts.finish_package], - ["Generate evals", data.counts.generate_evals], - ["Run unit tests", data.counts.run_unit_tests], - ["Replay dry-run", data.counts.run_replay_dry_run], - ["Measure baseline", data.counts.measure_baseline], - ["Ship candidate", data.counts.deploy_candidate], - ["Monitor live", data.counts.watch_deployment], - ].map(([label, count]) => ( -
-
- {label} -
-
{count}
-
- ))} -
- - {data.priorities.length > 0 ? ( -
-
- Next priorities -
-
- {data.priorities.map((priority) => { - const action = actionForCreatorOverviewStep(priority.step); - const actionKey = action ? `${priority.skill_name}:${action}` : null; - return ( -
-
- - {priority.skill_name} - - - {formatCreatorOverviewStep(priority.step)} - -
-

- {normalizeLifecycleText(priority.summary)} -

- - {normalizeLifecycleCommand(priority.recommended_command)} - - {action ? ( -
- -
- ) : null} -
- ); - })} -
-
- ) : null} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Overview (main export) -// --------------------------------------------------------------------------- - -export function Overview({ - search: _search, - statusFilter: _statusFilter, - onStatusFilterChange: _onStatusFilterChange, - overviewQuery, -}: { - search: string; - statusFilter: SkillHealthStatus | "ALL"; - onStatusFilterChange: (v: SkillHealthStatus | "ALL") => void; - overviewQuery: UseQueryResult; -}) { - const { data, isPending, isError, error, refetch } = overviewQuery; - const orchestrateQuery = useOrchestrateRuns(); - const libraryQuery = useLibrary(); - const portfolioQuery = usePortfolio(); - - if (isPending) { - return ( -
- -
- - -
-
- ); - } - - if (isError) { - return ( -
- -

- {error instanceof Error ? error.message : "Unknown error"} -

- -
- ); - } - - if (!data) { - return ( -
-

- No telemetry data found. Run some sessions first. -

-
- ); - } - - const { - skills, - autonomy_status, - attention_queue, - trust_watchlist, - recent_decisions, - overview, - creator_testing, - } = data; - - // Orchestrate summary - const orchRuns = orchestrateQuery.data?.runs ?? []; - const latestRun = orchRuns[0]; - const totalDeployed = orchRuns.reduce((s, r) => s + r.deployed, 0); - const totalEvolved = orchRuns.reduce((s, r) => s + r.evolved, 0); - const totalWatched = orchRuns.reduce((s, r) => s + r.watched, 0); - const latestEvolutionBySkill = new Map(); - for (const entry of overview.evolution) { - if (!entry.skill_name || latestEvolutionBySkill.has(entry.skill_name)) continue; - latestEvolutionBySkill.set(entry.skill_name, entry); - } - - const comparisonRows: OverviewComparisonRow[] = skills.map((skill) => { - const trust = trust_watchlist.find((entry) => entry.skill_name === skill.skill_name); - const loopStep = skill.testing_readiness?.next_step; - const createReadiness = skill.create_readiness; - const lifecycleText = - createReadiness && skill.total_checks === 0 - ? `Draft package · ${formatCreateState(createReadiness.state)}` - : `${skill.skill_scope ?? "Unscoped"} · ${skill.total_checks} checks${loopStep && loopStep !== "watch_deployment" ? ` · ${formatLoopStep(loopStep)}` : ""}`; - return { - skillName: skill.skill_name, - subtext: lifecycleText, - triggerRate: trust?.pass_rate ?? skill.pass_rate, - routingConfidence: skill.routing_confidence, - confidenceCoverage: skill.confidence_coverage, - sessions: skill.unique_sessions, - lastEvolution: latestEvolutionBySkill.get(skill.skill_name) ?? null, - bucket: trust?.bucket ?? "uncertain", - sortTimestamp: skill.last_seen ?? null, - }; - }); - const cleanupCandidates = (portfolioQuery.data?.audit.skills ?? []) - .filter( - (skill) => - skill.classification === "inactive_candidate" && - skill.recommendation === "review_quarantine", - ) - .map((skill) => ({ - skillName: skill.skill_name, - reason: skill.reason, - lastInvokedAt: skill.evidence.last_invoked_at, - inactiveDays: skill.evidence.inactive_days, - sessionsSinceInvocation: skill.evidence.sessions_since_invocation, - })); - const consolidationCandidates = (libraryQuery.data?.skills ?? []).flatMap((skill) => { - const recommendation = recommendLibraryConsolidation(skill); - return recommendation - ? [ - { - skillName: recommendation.skillName, - installedCount: recommendation.installedCount, - projectCount: recommendation.projectCount, - confidence: recommendation.canonical.confidence, - }, - ] - : []; - }); - const cleanup = - cleanupCandidates.length > 0 || consolidationCandidates.length > 0 - ? { - activeSkillCount: - portfolioQuery.data?.audit.installed_count ?? libraryQuery.data?.skills.length ?? 0, - candidates: cleanupCandidates, - evidencePendingCount: - (portfolioQuery.data?.audit.counts.unobserved ?? 0) + - (portfolioQuery.data?.audit.counts.under_observed ?? 0), - archivedCount: portfolioQuery.data?.quarantined.length ?? 0, - consolidationCandidates, - reviewAction: ( - - ), - consolidationAction: ( - - ), - restoreAction: ( - - Restore archived skills - - ), - } - : null; - - return ( - ( - - {skillName} - - )} - onboarding={{ - skillCount: skills.length, - }} - cleanup={cleanup} - heroActions={ -
- {autonomy_status.attention_required > 0 ? ( - - ) : ( - No action needed - )} - -
- } - trustRailFooter={ - - View All Skills - - } - comparison={{ - rows: comparisonRows, - libraryAction: ( - - View library - - ), - watchlist: { - initialSkills: data.watched_skills, - }, - }} - sectionsBeforeFeed={} - runSummary={{ - lastRun: latestRun?.timestamp ?? null, - deployed: totalDeployed, - evolved: totalEvolved, - watched: totalWatched, - runCount: orchRuns.length, - historyAction: ( - - View full history - - ), - }} - /> - ); -} diff --git a/apps/local-dashboard/src/pages/PerformanceAnalytics.tsx b/apps/local-dashboard/src/pages/PerformanceAnalytics.tsx deleted file mode 100644 index ccdf00a6..00000000 --- a/apps/local-dashboard/src/pages/PerformanceAnalytics.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { AnalyticsScreen } from "@selftune/dashboard-core/screens/analytics"; -import type { AnalyticsResponse } from "@selftune/ui/components"; -import { Button } from "@selftune/ui/primitives"; -import { useQuery } from "@tanstack/react-query"; -import { DownloadIcon } from "lucide-react"; - -/* ── Data fetching ──────────────────────────────────────── */ - -async function fetchAnalytics(): Promise { - const response = await fetch("/api/v2/analytics"); - if (!response.ok) { - throw new Error(`Failed to load analytics (${response.status})`); - } - return (await response.json()) as AnalyticsResponse; -} - -/* ── Main Page ──────────────────────────────────────────── */ - -export function PerformanceAnalytics() { - const { data, isPending, isError, error, refetch } = useQuery({ - queryKey: ["analytics"], - queryFn: fetchAnalytics, - refetchInterval: 30_000, - }); - - return ( - { - void refetch(); - }} - onRetry={() => { - void refetch(); - }} - headerActions={ - - } - insightActions={ - <> - - - - } - /> - ); -} diff --git a/apps/local-dashboard/src/pages/Settings.test.tsx b/apps/local-dashboard/src/pages/Settings.test.tsx index c8ab574f..0e6798ac 100644 --- a/apps/local-dashboard/src/pages/Settings.test.tsx +++ b/apps/local-dashboard/src/pages/Settings.test.tsx @@ -1,126 +1,80 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DesktopSettingsResponse } from "@/types"; - -const useSettingsMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/hooks/useSettings", () => { - const mutation = () => ({ - data: undefined, - isPending: false, - mutate: vi.fn(), - }); - - return { - useSettings: useSettingsMock, - useCreateRemoteLibraryShare: mutation, - useExportRemoteLibrary: mutation, - useInviteWorkspaceMember: mutation, - useLinkCloudAccount: mutation, - usePreviewRemoteLibrary: mutation, - useRemoteLibraryShareAction: mutation, - useRemoveWorkspaceMember: mutation, - useResetWorkspaceSkillSetPolicy: mutation, - useRestoreRemoteLibrary: mutation, - useSyncRemoteLibrary: mutation, - useUpdateRemoteLibrarySettings: mutation, - useUpdateScheduleSettings: mutation, - useUpdateWorkspaceMemberRole: mutation, - useUpdateWorkspaceSkillSetPolicy: mutation, - useRemoteLibraryStatus: () => ({ data: undefined, isError: false }), - useRemoteLibraryShares: () => ({ data: undefined }), - useWorkspaceSkillSetPolicies: () => ({ data: undefined, isLoading: false }), - useWorkspaceMembers: () => ({ data: undefined, isLoading: false }), - }; -}); - +import { settingsFor } from "../test-fixtures/settings"; import { Settings } from "./Settings"; -function settingsFor(url: string): DesktopSettingsResponse { - return { - harnesses: [], - agent_skill: { - installed: true, - locations: [], - install_command: "npx skills add selftune-dev/selftune", - }, - onboarding: { - version: 1, - completed: true, - import_sources: { - claude_code: false, - cline: false, - codex: false, - opencode: false, - openclaw: false, - pi: false, - }, - hook_harnesses: { - claude_code: false, - cline: false, - codex: false, - opencode: false, - pi: false, - }, - features: { - observability: true, - health_recommendations: true, - autonomous_improvement: false, - }, - }, - cloud_account: { - linked: true, - cloud_user_id: "user-1", - cloud_org_id: "workspace-1", - }, - remote_library: { - configured: true, - credential_provider: null, - url, - preferences: { - releasedSkills: false, - drafts: false, - skillSets: false, - metadata: false, - decisionHistory: false, - }, - }, - schedule: { - supported: true, - format: "launchd", - settings_path: "/tmp/jobs.json", - jobs: [], - }, - }; -} +const clients: QueryClient[] = []; -function renderSettings(url: string) { - useSettingsMock.mockReturnValue({ - data: settingsFor(url), - isError: false, - isFetching: false, - isLoading: false, - refetch: vi.fn(), +function renderSettings(url: string, route = "/settings?section=remote-library") { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (input === "/api/v2/settings") return Response.json(settingsFor(url)); + if ( + [ + "/api/v2/settings/remote-library/status", + "/api/v2/settings/remote-library/shares", + "/api/v2/settings/workspace/members", + "/api/v2/settings/workspace/policies", + ].includes(String(input)) + ) { + return new Response("Service unavailable", { status: 503 }); + } + throw new Error(`Unexpected request: ${input}`); }); - + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } }, + }); + clients.push(client); return render( - - - , + + + + + , ); } afterEach(() => { cleanup(); - useSettingsMock.mockReset(); + for (const client of clients.splice(0)) client.clear(); + vi.restoreAllMocks(); }); describe("Settings remote-library capabilities", () => { - it("shows complete backup actions only for a configured self-hosted server", async () => { + it("renders a newly available automation job before the draft synchronization effect runs", async () => { + const url = "https://cloud.selftune.dev"; + renderSettings(url, "/settings?section=automation"); + await screen.findByText("Run local collection and improvement jobs in the background."); + const settings = settingsFor(url); + act(() => + clients.at(-1)?.setQueryData(["settings"], { + ...settings, + schedule: { + ...settings.schedule, + jobs: [ + { + id: "selftune-sync", + label: "Collect", + description: "Import local sessions", + command: "selftune sync", + default_schedule: "*/30 * * * *", + schedule: "*/15 * * * *", + enabled: false, + active: false, + }, + ], + }, + }), + ); + expect(await screen.findByRole("switch", { name: "Enable Collect" })).not.toBeNull(); + expect(screen.getByRole("combobox", { name: "Collect frequency" })).not.toBeNull(); + }); + + it("shows backup controls only for self-hosting, even when remote status is unavailable", async () => { const cloud = renderSettings("https://cloud.selftune.dev"); await screen.findByRole("heading", { diff --git a/apps/local-dashboard/src/pages/Settings.tsx b/apps/local-dashboard/src/pages/Settings.tsx index 04aff5ba..2d8d24a5 100644 --- a/apps/local-dashboard/src/pages/Settings.tsx +++ b/apps/local-dashboard/src/pages/Settings.tsx @@ -78,7 +78,7 @@ import type { WorkspaceMemberRole, } from "@/types"; -type ScheduleDraft = Record; +type ScheduleDraft = Partial>; type RemoteDraft = { destination: SyncDestination; url: string; @@ -185,7 +185,7 @@ const SCHEDULE_PRESETS: Record = { function draftFromJobs(jobs: DesktopScheduleJob[]): ScheduleDraft { return Object.fromEntries( jobs.map((job) => [job.id, { enabled: job.enabled, schedule: job.schedule }]), - ) as ScheduleDraft; + ); } function humanizeSchedule(schedule: string): string { @@ -405,7 +405,7 @@ export function Settings() { const hasChanges = useMemo(() => { if (!settingsQuery.data || !draft) return false; return settingsQuery.data.schedule.jobs.some((job) => { - const next = draft[job.id]; + const next = draft[job.id] ?? job; return next.enabled !== job.enabled || next.schedule.trim() !== job.schedule; }); }, [draft, settingsQuery.data]); @@ -429,7 +429,7 @@ export function Settings() { const scheduleDraft = draft; const connectedCount = harnesses.filter((harness) => harness.connected).length; const canResetTimes = schedule.jobs.some( - (job) => scheduleDraft[job.id].schedule !== job.default_schedule, + (job) => (scheduleDraft[job.id]?.schedule ?? job.schedule) !== job.default_schedule, ); const formatLabel = schedule.format === "launchd" @@ -450,7 +450,8 @@ export function Settings() { { jobs: schedule.jobs.map((job) => ({ id: job.id, - ...scheduleDraft[job.id], + enabled: scheduleDraft[job.id]?.enabled ?? job.enabled, + schedule: scheduleDraft[job.id]?.schedule ?? job.schedule, })), }, { @@ -1488,9 +1489,9 @@ export function Settings() { void window.selftuneDesktop ?.setBackgroundService(enabled) .then(setBackgroundService) - .catch((error: unknown) => + .catch((cause: unknown) => toast.error("Background service update failed", { - description: error instanceof Error ? error.message : String(error), + description: cause instanceof Error ? cause.message : String(cause), }), ) .finally(() => setBackgroundServicePending(false)); @@ -1538,11 +1539,11 @@ export function Settings() { schedule.jobs.map((job) => [ job.id, { - enabled: scheduleDraft[job.id].enabled, + enabled: scheduleDraft[job.id]?.enabled ?? job.enabled, schedule: job.default_schedule, }, ]), - ) as ScheduleDraft, + ), ) } > @@ -1560,7 +1561,7 @@ export function Settings() {
{schedule.jobs.map((job) => { - const jobDraft = draft[job.id]; + const jobDraft = draft[job.id] ?? job; const scheduleOptions = optionsForJob(job, jobDraft.schedule); return (
| null = null; +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { SkillReportResponse, SkillTestingReadiness } from "@/types"; +import { SkillReport } from "./SkillReport"; + +interface ReportFixture extends SkillReportResponse { + testing_readiness: SkillTestingReadiness; +} +let mockSkillReportData: ReportFixture; let mockSearchParams = new URLSearchParams(); -const mockSetSearchParams = vi.fn(); - -vi.mock("@selftune/ui/primitives", () => ({ - Badge: ({ children }: { children?: ReactNode }) => {children}, - Button: ({ children, render }: { children?: ReactNode; render?: ReactNode }) => - render ? render : , - Card: ({ children }: { children?: ReactNode }) =>
{children}
, - CardAction: ({ children }: { children?: ReactNode }) =>
{children}
, - CardContent: ({ children }: { children?: ReactNode }) =>
{children}
, - CardDescription: ({ children }: { children?: ReactNode }) =>

{children}

, - CardHeader: ({ children }: { children?: ReactNode }) =>
{children}
, - CardTitle: ({ children }: { children?: ReactNode }) =>

{children}

, - Table: ({ children }: { children?: ReactNode }) => {children}
, - TableBody: ({ children }: { children?: ReactNode }) => {children}, - TableCell: ({ children, title }: { children?: ReactNode; title?: string }) => ( - {children} - ), - TableHead: ({ children }: { children?: ReactNode }) => {children}, - TableHeader: ({ children }: { children?: ReactNode }) => {children}, - TableRow: ({ children }: { children?: ReactNode }) => {children}, - Tabs: ({ children }: { children: ReactNode }) => <>{children}, - TabsList: ({ children }: { children?: ReactNode }) =>
{children}
, - TabsTrigger: ({ children }: { children?: ReactNode }) => , - TabsContent: ({ children }: { children?: ReactNode }) =>
{children}
, - Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children?: ReactNode }) => {children}, - TooltipTrigger: ({ children, render }: { children?: ReactNode; render?: ReactNode }) => - render ? ( - <> - {render} - {children} - - ) : ( - <>{children} - ), -})); - -vi.mock("@selftune/ui/components", () => ({ - DataQualityPanel: () => ( -
-
Evidence Quality Rates
-
Data Hygiene
-
- ), - EvolutionTimeline: () =>
Evolution Timeline
, - EvidenceViewer: () =>
Evidence Viewer
, - InfoTip: () => i, - InvocationsPanel: () =>
Invoker codex
, - PassRateTrendChart: () =>
Pass Rate Trend
, - PromptEvidencePanel: () =>
Prompt Evidence
, - SkillReportGuideSheet: () =>
How this works
, - SkillReportOnboardingBanner: () =>
Onboarding Banner
, - SkillReportTopRow: ({ nextAction }: { nextAction: { actionLabel: string; text: string } }) => ( -
-
Latest Decision
-
{nextAction.actionLabel}
-
{nextAction.text}
-
- ), - SkillTrustNarrativePanel: () => ( -
-
How selftune is improving this skill
-
What selftune saw
-
Why it acted
-
What happened next
-
- ), - TrustSignalsGrid: () =>
Trust Signals
, -})); - -vi.mock("@selftune/ui/lib", () => ({ - cn: (...classes: Array) => classes.filter(Boolean).join(" "), - formatRate: (v: number) => `${Math.round(v * 100)}%`, - timeAgo: () => "just now", -})); - -vi.mock("@/components/ui/skeleton", () => ({ - Skeleton: () => null, -})); - -vi.mock("@/components/ui/sheet", () => ({ - Sheet: ({ children }: { children?: ReactNode }) => <>{children}, - SheetContent: ({ children }: { children?: ReactNode }) =>
{children}
, - SheetDescription: ({ children }: { children?: ReactNode }) =>

{children}

, - SheetHeader: ({ children }: { children?: ReactNode }) =>
{children}
, - SheetTitle: ({ children }: { children?: ReactNode }) =>

{children}

, -})); - -vi.mock("react-router-dom", () => ({ - Link: ({ children, to }: { children?: ReactNode; to?: string }) => {children}, - useNavigate: () => () => {}, - useParams: () => ({ name: "test-skill" }), - useSearchParams: () => [mockSearchParams, mockSetSearchParams], -})); - -vi.mock("lucide-react", () => ({ - Activity: () => null, - AlertCircleIcon: () => null, - ActivityIcon: () => null, - ArrowDown: () => null, - ArrowLeft: () => null, - ArrowLeftIcon: () => null, - ArrowRightIcon: () => null, - BarChart3Icon: () => null, - Bot: () => null, - Boxes: () => null, - CheckCircleIcon: () => null, - ChevronDownIcon: () => null, - ChevronRightIcon: () => null, - ClockIcon: () => null, - CoinsIcon: () => null, - Cpu: () => null, - DatabaseIcon: () => null, - EyeIcon: () => null, - FilterIcon: () => null, - FlaskConicalIcon: () => null, - FolderIcon: () => null, - GaugeIcon: () => null, - GitBranchIcon: () => null, - LayersIcon: () => null, - ListChecksIcon: () => null, - Loader2: () => null, - MessageSquareTextIcon: () => null, - RefreshCwIcon: () => null, - RocketIcon: () => null, - SearchIcon: () => null, - ServerIcon: () => null, - ShieldAlertIcon: () => null, - ShieldCheckIcon: () => null, - ShieldIcon: () => null, - ShieldQuestionIcon: () => null, - SparklesIcon: () => null, - TargetIcon: () => null, - TerminalSquare: () => null, - AlertTriangleIcon: () => null, - TrendingDownIcon: () => null, - TrendingUpIcon: () => null, - AlertOctagonIcon: () => null, - XIcon: () => null, -})); - -vi.mock("../hooks/useSkillReport", () => ({ - useSkillReport: () => ({ - data: mockSkillReportData, - isPending: false, - isError: false, - error: null, - refetch: () => {}, - }), -})); - +let client: QueryClient; + +function renderReport() { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["skill-report", "test-skill"], mockSkillReportData); + return render( + + + + } /> + + + , + ); +} + +afterEach(() => { + cleanup(); + client?.clear(); +}); beforeEach(() => { mockSearchParams = new URLSearchParams(); - mockSetSearchParams.mockReset(); mockSkillReportData = { skill_name: "selftune", - usage: { total_checks: 125, pass_rate: 0.84, missed_triggers: 6 }, + usage: { total_checks: 125, triggered_count: 105, pass_rate: 0.84 }, + recent_invocations: [], + watch_trust_score: null, + token_usage: { total_input_tokens: 0, total_output_tokens: 0 }, + duration_stats: { + avg_duration_ms: 0, + total_duration_ms: 0, + execution_count: 0, + missed_triggers: 6, + }, + selftune_stats: { total_llm_calls: 0, total_elapsed_ms: 0, avg_elapsed_ms: 0, run_count: 0 }, + prompt_samples: [ + { + prompt_text: "test query", + prompt_kind: "meta", + is_actionable: true, + occurred_at: "2026-03-31T00:00:00Z", + session_id: "sess-1", + }, + ], sessions_with_skill: 98, - evidence: [{ proposal_id: "p1" }], - evolution: [{ proposal_id: "p1", action: "validated", timestamp: "2026-03-31T00:00:00Z" }], + evidence: [], + evolution: [ + { + proposal_id: "p1", + action: "validated", + timestamp: "2026-03-31T00:00:00Z", + details: "Replay passed", + }, + ], pending_proposals: [], canonical_invocations: [ { session_id: "sess-1", + skill_name: "selftune", timestamp: "2026-03-31T00:00:00Z", query: "test query", triggered: true, invocation_mode: "implicit", confidence: 0.7, tool_name: null, - agent_type: "agent-a", + agent_type: null, observation_kind: "canonical", }, ], @@ -185,6 +87,10 @@ beforeEach(() => { model: "claude", agent_cli: "codex", platform: "codex", + branch: null, + workspace_path: "/workspace", + ended_at: null, + completion_status: null, }, ], trust: { @@ -308,59 +214,51 @@ beforeEach(() => { }); describe("SkillReport", () => { - it("module exports SkillReport component", async () => { - const { SkillReport } = await import("./SkillReport"); - expect(SkillReport).toBeDefined(); - expect(typeof SkillReport).toBe("function"); - }); - it("renders tabs directly before tab-controlled content", async () => { - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); - - expect(html.indexOf("Trust Signals")).toBeLessThan(html.indexOf("Evidence")); - expect(html).toContain("Evidence"); + renderReport(); + const tabs = screen.getByRole("tab", { name: "Evidence" }); + const panel = screen.getByRole("tabpanel", { name: "Evidence" }); + expect(tabs.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); }); it("does not duplicate the latest decision block", async () => { - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); + const { container } = renderReport(); + const html = container.innerHTML; expect(html.match(/Latest Decision/g)?.length).toBe(1); }); it("renders evidence and data quality panel content in their sections", async () => { - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); - - expect(html).toContain("Prompt Evidence"); - expect(html).toContain("Evidence Quality Rates"); - expect(html).toContain("Data Hygiene"); - expect(html).toContain("Missed Queries"); + renderReport(); + fireEvent.click(screen.getByRole("tab", { name: "Evidence" })); + expect(screen.getByRole("tabpanel", { name: "Evidence" }).textContent).toContain( + "Prompt Evidence", + ); + fireEvent.click(screen.getByRole("tab", { name: "Data Quality" })); + expect(screen.getByRole("tabpanel", { name: "Data Quality" }).textContent).toContain( + "Evidence Quality Rates", + ); + expect(screen.getByRole("tabpanel", { name: "Data Quality" }).textContent).toContain( + "Data Hygiene", + ); + fireEvent.click(screen.getByRole("tab", { name: /Missed Queries/ })); + expect(screen.getByRole("tabpanel", { name: /Missed Queries/ }).textContent).toContain( + "missed query", + ); }); it("shows invoker fallback data from session metadata", async () => { - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); - - expect(html).toContain("Invoker"); - expect(html).toContain("codex"); - }); - - it("renders the plain-language education layer", async () => { - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); - - expect(html).toContain("How selftune is improving this skill"); - expect(html).toContain("What selftune saw"); - expect(html).toContain("Why it acted"); - expect(html).toContain("What happened next"); - expect(html).toContain("How this works"); + renderReport(); + fireEvent.click(screen.getByRole("tab", { name: /Invocations/ })); + expect(screen.getByRole("tabpanel").textContent).toContain("Invoker"); + expect(screen.getByRole("tabpanel").textContent).toContain("codex"); }); it("renders the creator test loop section with the recommended command", async () => { - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); + const { container } = renderReport(); + const html = container.innerHTML; expect(html).toContain("Measured trust loop"); expect(html).toContain("Replay dry-run"); @@ -371,7 +269,7 @@ describe("SkillReport", () => { it("renders the draft package loop for create-readiness-only skills", async () => { mockSkillReportData = { ...mockSkillReportData, - usage: { total_checks: 0, pass_rate: 0, missed_triggers: 0 }, + usage: { total_checks: 0, triggered_count: 0, pass_rate: 0 }, sessions_with_skill: 0, evidence: [], evolution: [], @@ -397,7 +295,7 @@ describe("SkillReport", () => { evolution_rows: 0, }, testing_readiness: { - ...((mockSkillReportData as { testing_readiness: object }).testing_readiness as object), + ...mockSkillReportData.testing_readiness, skill_name: "draft-writer", skill_path: "/workspace/.agents/skills/draft-writer/SKILL.md", }, @@ -445,12 +343,14 @@ describe("SkillReport", () => { }, }; - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); + const { container } = renderReport(); + const html = container.innerHTML; expect(html).toContain("Draft skill lifecycle"); expect(html).toContain("Verify draft"); expect(html).toContain("Publish draft"); + expect(screen.getByRole("button", { name: "Package report" })).not.toBeNull(); + expect(screen.getByRole("button", { name: "Run search" })).not.toBeNull(); expect(html).toContain( "selftune verify --skill-path /workspace/.agents/skills/draft-writer/SKILL.md", ); @@ -459,7 +359,7 @@ describe("SkillReport", () => { it("keeps draft-package blockers visible even after runtime data exists", async () => { mockSkillReportData = { ...mockSkillReportData, - usage: { total_checks: 12, pass_rate: 0.8, missed_triggers: 1 }, + usage: { total_checks: 12, triggered_count: 10, pass_rate: 0.8 }, sessions_with_skill: 6, trust: { state: "observed", @@ -481,7 +381,7 @@ describe("SkillReport", () => { evolution_rows: 0, }, testing_readiness: { - ...((mockSkillReportData as { testing_readiness: object }).testing_readiness as object), + ...mockSkillReportData.testing_readiness, skill_name: "draft-writer", skill_path: "/workspace/.agents/skills/draft-writer/SKILL.md", next_step: "deploy_candidate", @@ -534,8 +434,8 @@ describe("SkillReport", () => { }, }; - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); + const { container } = renderReport(); + const html = container.innerHTML; expect(html).toContain("Draft skill lifecycle"); expect(html).toContain("Verify draft"); @@ -556,30 +456,6 @@ describe("SkillReport", () => { expect(generateEvals?.autoSynthetic).toBe(true); }); - it("includes create check as a runnable draft-package action", async () => { - const { getDraftPackageActions } = await import("./SkillReport"); - expect(getDraftPackageActions()[0]).toEqual({ - action: "create-check", - label: "Verify draft", - }); - }); - - it("includes package report as a runnable draft-package action", async () => { - const { getDraftPackageActions } = await import("./SkillReport"); - expect(getDraftPackageActions()).toContainEqual({ - action: "report-package", - label: "Package report", - }); - }); - - it("includes bounded package search as a runnable draft-package action", async () => { - const { getDraftPackageActions } = await import("./SkillReport"); - expect(getDraftPackageActions()).toContainEqual({ - action: "search-run", - label: "Run search", - }); - }); - it("renders the latest bounded-search surface budget in the frontier panel", async () => { mockSkillReportData = { ...mockSkillReportData, @@ -627,8 +503,8 @@ describe("SkillReport", () => { }, }; - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); + const { container } = renderReport(); + const html = container.innerHTML; expect(html).toContain("Package frontier"); expect(html).toContain("Budget:"); @@ -637,10 +513,10 @@ describe("SkillReport", () => { it("keeps proposal deep links focused without restoring the old proposal-first layout", async () => { mockSearchParams = new URLSearchParams("proposal=p1"); - const { SkillReport } = await import("./SkillReport"); - const html = renderToStaticMarkup(); + const { container } = renderReport(); + const html = container.innerHTML; - expect(html).not.toContain("Onboarding Banner"); + expect(html).not.toContain("New to selftune?"); expect(html).not.toContain("Measured trust loop"); expect(html).toContain("Ship candidate"); expect(html.indexOf("Trust Signals")).toBeLessThan(html.indexOf("Evidence")); diff --git a/apps/local-dashboard/src/pages/SkillReport.tsx b/apps/local-dashboard/src/pages/SkillReport.tsx index 1b3e77f4..b3137c79 100644 --- a/apps/local-dashboard/src/pages/SkillReport.tsx +++ b/apps/local-dashboard/src/pages/SkillReport.tsx @@ -35,6 +35,7 @@ import { SkillReportScaffold, SkillReportTabs, SkillReportTrustBadge, + type SkillReportNextAction, } from "@selftune/dashboard-core/screens/skill-report"; import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; @@ -88,12 +89,7 @@ function actionForLoopStep(step: CreatorLoopNextStep): DashboardActionName { } } -function deriveTestingAction(readiness: SkillTestingReadiness): { - icon: React.ReactNode; - text: string; - actionLabel: string; - variant: "default" | "secondary" | "destructive" | "outline"; -} { +function deriveTestingAction(readiness: SkillTestingReadiness): SkillReportNextAction { switch (readiness.next_step) { case "generate_evals": return { @@ -181,12 +177,7 @@ function actionForCreateState(state: CreateCheckState): DashboardActionName | nu } } -function deriveCreateAction(readiness: CreateCheckReadiness): { - icon: React.ReactNode; - text: string; - actionLabel: string; - variant: "default" | "secondary" | "destructive" | "outline"; -} { +function deriveCreateAction(readiness: CreateCheckReadiness): SkillReportNextAction { switch (readiness.state) { case "blocked_spec_validation": return { @@ -250,12 +241,7 @@ function deriveCreateAction(readiness: CreateCheckReadiness): { function deriveProposalAction( evolution: EvolutionEntry[], proposalId: string, -): { - icon: React.ReactNode; - text: string; - actionLabel: string; - variant: "default" | "secondary" | "destructive" | "outline"; -} { +): SkillReportNextAction { const proposalEntries = evolution .filter((entry) => entry.proposal_id === proposalId) .sort((a, b) => (a.timestamp ?? "").localeCompare(b.timestamp ?? "")); @@ -768,12 +754,7 @@ function deriveNextAction( systemLikeRate: number | null | undefined, hasPendingProposals: boolean, _hasEvolution: boolean, -): { - icon: React.ReactNode; - text: string; - actionLabel: string; - variant: "default" | "secondary" | "destructive" | "outline"; -} { +): SkillReportNextAction { if (trustState === "low_sample") { return { icon: , @@ -1381,7 +1362,16 @@ export function SkillReport() { setActiveTab(value as SkillReportTab)} + onValueChange={(value) => { + if ( + value === "evidence" || + value === "missed" || + value === "invocations" || + value === "data-quality" + ) { + setActiveTab(value); + } + }} tabs={[ { value: "evidence", diff --git a/apps/local-dashboard/src/pages/SkillReportV2.tsx b/apps/local-dashboard/src/pages/SkillReportV2.tsx deleted file mode 100644 index e1ecbb58..00000000 --- a/apps/local-dashboard/src/pages/SkillReportV2.tsx +++ /dev/null @@ -1,833 +0,0 @@ -import { STATUS_CONFIG } from "@selftune/ui/lib"; -import { deriveStatus, formatRate, timeAgo } from "@selftune/ui/lib"; -import { - Badge, - Button, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@selftune/ui/primitives"; -import { - AlertCircleIcon, - ArrowLeftIcon, - CheckCircle2Icon, - GitBranchIcon, - PlusIcon, - RefreshCwIcon, - RocketIcon, - TrendingUpIcon, - XCircleIcon, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { Link, useParams } from "react-router-dom"; -import { - Bar, - BarChart, - Cell, - ResponsiveContainer, - Tooltip as RechartsTooltip, - XAxis, - YAxis, -} from "recharts"; - -import { Skeleton } from "@/components/ui/skeleton"; -import { useSkillReport } from "@/hooks/useSkillReport"; -import type { CanonicalInvocation, EvolutionEntry, PendingProposal } from "@/types"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatDuration(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms`; - const secs = ms / 1000; - if (secs < 60) return `${secs.toFixed(1)}s`; - const mins = secs / 60; - if (mins < 60) return `${mins.toFixed(1)}m`; - return `${(mins / 60).toFixed(1)}h`; -} - -function formatCost(usd: number): string { - if (usd < 0.01) return "<$0.01"; - return `$${usd.toFixed(2)}`; -} - -function statusBadgeClasses(status: string): string { - switch (status) { - case "HEALTHY": - return "border-primary/30 bg-primary/5 text-primary"; - case "CRITICAL": - return "border-destructive/30 bg-destructive/5 text-destructive"; - case "WARNING": - return "border-warning/30 bg-warning/5 text-warning-foreground"; - default: - return "border-muted-foreground/30 bg-muted-foreground/5 text-muted-foreground"; - } -} - -function tabTriggerClasses(isActive: boolean): string { - return `px-4 py-2 text-xs tracking-widest uppercase font-headline transition-colors ${ - isActive - ? "text-primary border-b-2 border-primary" - : "text-muted-foreground hover:text-foreground" - }`; -} - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -function KPICard({ - label, - value, - trending, - tooltip, -}: { - label: string; - value: string | number; - trending?: boolean; - tooltip?: string; -}) { - const card = ( -
-

- {label} -

-
- {value} - {trending && } -
-
- ); - - if (!tooltip) return card; - - return ( - - {card} - {tooltip} - - ); -} - -function InvocationTimelineTooltip({ - active, - payload, -}: { - active?: boolean; - payload?: Array<{ - payload: { - query: string; - outcome: string; - confidence: number; - session_id: string; - timestamp: string; - }; - }>; -}) { - if (!active || !payload?.length) return null; - const d = payload[0].payload; - return ( -
-

- {d.query || "No query recorded"} -

-

- - {d.outcome === "pass" ? "Pass" : "Fail"} - - {" — confidence "} - {Math.round(d.confidence * 100)}% -

-

- {d.session_id.substring(0, 8)} · {timeAgo(d.timestamp)} -

-
- ); -} - -function InvocationTimeline({ invocations }: { invocations: CanonicalInvocation[] }) { - const recent = invocations.slice(0, 30).reduceRight((acc, invocation) => { - acc.push(invocation); - return acc; - }, []); - if (recent.length === 0) { - return ( -

No invocation data yet.

- ); - } - - const chartData = recent.map((inv, i) => ({ - index: i, - confidence: inv.confidence ?? 0.5, - outcome: inv.triggered ? "pass" : "fail", - query: inv.query ?? "", - session_id: inv.session_id, - timestamp: inv.timestamp, - })); - - return ( -
-
-

- Invocation Timeline -

-
- - - Pass - - - - Fail - -
-
- - - - - } - cursor={{ - fill: "color-mix(in srgb, var(--muted-foreground) 8%, transparent)", - }} - /> - - {chartData.map((entry) => ( - - ))} - - - -
- ); -} - -function EvolutionHistory({ evolution }: { evolution: EvolutionEntry[] }) { - const recent = evolution.slice(0, 8); - if (recent.length === 0) { - return ( -

No evolution history yet.

- ); - } - - function dotClasses(action: string): string { - switch (action) { - case "deployed": - return "bg-primary ring-4 ring-background"; - case "validated": - return "bg-input ring-4 ring-background"; - case "rejected": - case "rolled_back": - return "bg-input ring-4 ring-background opacity-60"; - default: - return "bg-input ring-4 ring-background"; - } - } - - function actionIcon(action: string) { - switch (action) { - case "deployed": - return ; - case "validated": - return ; - case "rejected": - case "rolled_back": - return ; - default: - return ; - } - } - - return ( -
-
- {recent.map((entry, i) => { - const isDeployed = entry.action === "deployed"; - return ( -
-
- {actionIcon(entry.action)} -
-
-

- {entry.action} -

-

{entry.details}

-

- {timeAgo(entry.timestamp)} -

-
-
- ); - })} -
- ); -} - -function RecentInvocationsTable({ - invocations, - rowLimit, -}: { - invocations: CanonicalInvocation[]; - rowLimit?: number; -}) { - const rows = typeof rowLimit === "number" ? invocations.slice(0, rowLimit) : invocations; - if (rows.length === 0) { - return ( -

No invocations recorded.

- ); - } - - return ( -
- - - - - - - - - - - {rows.map((inv, i) => ( - - - - - - - ))} - -
Session IDQueryOutcomeTimestamp
- {inv.session_id.substring(0, 8)} - - {inv.query || ( - No query recorded - )} - - - {inv.triggered ? "Pass" : "Fail"} - - - {timeAgo(inv.timestamp)} -
-
- ); -} - -function ExecutionMetricsPanel({ - durationStats, - executionMetrics, - tokenUsage, -}: { - durationStats: { avg_duration_ms: number; execution_count: number }; - executionMetrics?: { - avg_files_changed: number; - total_lines_added: number; - total_cost_usd: number; - } | null; - tokenUsage: { total_input_tokens: number; total_output_tokens: number }; -}) { - const metrics = [ - { - label: "Avg Duration", - value: formatDuration(durationStats.avg_duration_ms), - }, - { - label: "Total Cost", - value: executionMetrics ? formatCost(executionMetrics.total_cost_usd) : "--", - }, - { - label: "Files Changed", - value: executionMetrics ? executionMetrics.avg_files_changed.toFixed(1) : "--", - }, - { - label: "Lines Added", - value: executionMetrics ? executionMetrics.total_lines_added.toLocaleString() : "--", - }, - ]; - - const totalTokens = tokenUsage.total_input_tokens + tokenUsage.total_output_tokens; - const inputPct = - totalTokens > 0 ? Math.round((tokenUsage.total_input_tokens / totalTokens) * 100) : 0; - - return ( -
- {metrics.map((m) => ( -
- - {m.label} - - {m.value} -
- ))} -
-
- - Token Usage - - - {totalTokens.toLocaleString()} total - -
-
-
-
-
- - Input: {tokenUsage.total_input_tokens.toLocaleString()} - - - Output: {tokenUsage.total_output_tokens.toLocaleString()} - -
-
-
- ); -} - -function PendingProposalCards({ proposals }: { proposals: PendingProposal[] }) { - if (proposals.length === 0) { - return

No pending proposals.

; - } - - return ( -
- {proposals.map((p) => ( -
-
- - - {p.action.replace(/_/g, " ")} - - - #{p.proposal_id.slice(0, 8)} - -
-

{p.details}

-
- - - - {timeAgo(p.timestamp)} - -
-
- ))} -
- ); -} - -// --------------------------------------------------------------------------- -// Main page component -// --------------------------------------------------------------------------- - -export function SkillReportV2() { - const { name } = useParams<{ name: string }>(); - const { data, isPending, isError, error, refetch } = useSkillReport(name); - const [activeTab, setActiveTab] = useState("overview"); - - // Derive invocations sorted by recency - const invocations = useMemo(() => { - if (!data) return []; - const items = (data.canonical_invocations ?? []).map((ci) => ({ - ...ci, - timestamp: ci.timestamp || ci.occurred_at || "", - })); - items.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); - return items; - }, [data]); - - // --- Guard states --- - - if (!name) { - return ( -
-

No skill name provided

-
- ); - } - - if (isPending) { - return ( -
-
- - - -
-
-
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
-
- - -
-
- - -
-
-
- ); - } - - if (isError) { - return ( -
- -

- {error instanceof Error ? error.message : "Unknown error"} -

- -
- ); - } - - if (!data) { - return ( -
-

No data yet

-
- ); - } - - // --- Derived values --- - const { - usage, - evolution, - pending_proposals, - duration_stats, - token_usage, - execution_metrics, - description_quality, - } = data; - const status = deriveStatus(usage.pass_rate, usage.total_checks); - const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.UNKNOWN; - const passRate = usage.total_checks > 0 ? formatRate(usage.pass_rate) : "--"; - const triggerRate = - usage.total_checks > 0 - ? `${Math.round((usage.triggered_count / usage.total_checks) * 100)}%` - : "--"; - const uniqueSessions = data.sessions_with_skill; - const descQuality = description_quality - ? `${Math.round(description_quality.composite * 100)}%` - : "--"; - - return ( - - {/* Sticky Header */} -
-
-
- {/* Breadcrumb */} -
- - - Dashboard - - / - Skills -
- {/* Title + Status */} -
-

- {data.skill_name} -

- - {config.label} - -
-
- - {/* Tab bar */} -
-
- - - - } - > - Overview - - Skill health summary and key metrics - - - - } - > - Invocations - {invocations.length > 0 && ( - - {invocations.length} - - )} - - Recent skill triggers and their outcomes - - - - } - > - Evolution - {evolution.length > 0 && ( - - {evolution.length} - - )} - - Change history and validation results - - - - } - > - Proposals - {pending_proposals.length > 0 && ( - - {pending_proposals.length} - - )} - - Proposals awaiting review - - -
-
-
-
- - {/* Bento Grid Content */} -
- {/* ============ OVERVIEW TAB ============ */} - - {/* Row 1: 4 KPI Cards */} -
- 0.9} - tooltip="Percentage of checks where the skill executed correctly" - /> - - - -
- - {/* Row 2: Invocation Timeline + Evolution History */} -
-
- -
- -
-

- Evolution History -

- -
-
- - {/* Row 3: Recent Invocations + Execution Metrics */} -
-
-
-

- Recent Invocations -

-
- -
- -
-

- Execution Metrics -

- -
-
- - {/* Row 4: Pending Proposals */} - {pending_proposals.length > 0 && ( -
-
-

- Pending Proposals -

- -
- -
- )} -
- - {/* ============ INVOCATIONS TAB ============ */} - -
-
-

- All Invocations - - ({invocations.length}) - -

-
- -
-
- - {/* ============ EVOLUTION TAB ============ */} - -
-

- Full Evolution Trail -

- {evolution.length === 0 ? ( -

- No evolution history yet. -

- ) : ( -
- {evolution.map((entry, i) => ( -
-
- {entry.action === "deployed" ? ( - - ) : entry.action === "rolled_back" || entry.action === "rejected" ? ( - - ) : ( - - )} - - {entry.action} - - - #{entry.proposal_id.slice(0, 8)} - {timeAgo(entry.timestamp)} - -
-

{entry.details}

-
- ))} -
- )} -
-
- - {/* ============ PROPOSALS TAB ============ */} - -
-

- Pending Proposals - - ({pending_proposals.length}) - -

- -
-
-
-
- ); -} diff --git a/apps/local-dashboard/src/pages/Status.test.tsx b/apps/local-dashboard/src/pages/Status.test.tsx new file mode 100644 index 00000000..b86778bf --- /dev/null +++ b/apps/local-dashboard/src/pages/Status.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HealthResponse } from "@selftune/runtime/dashboard-contract/health"; +import type { DoctorResult } from "@/types"; +import { fetchRuntimeHealth } from "@/api"; +import { Status } from "./Status"; + +const health = { + ok: true, + service: "selftune-dashboard", + version: "0.4.20", + latest_version: "0.4.21", + update_available: false, + auto_update_supported: false, + update_hint: null, + pid: 123, + runtime_instance_id: null, + runtime_owner: null, + runtime_supervision: null, + service_installation_nonce: null, + owner_executable_path: null, + spa: true, + v2_data_available: true, + workspace_root: "/tmp/status-project", + git_sha: "test-build", + db_path: "/tmp/status-project/telemetry.db", + log_dir: "/tmp/status-project/logs", + config_dir: "/tmp/status-project/config", + watcher_mode: "wal", + process_mode: "test", + host: "localhost", + port: 7888, +} satisfies HealthResponse; + +const doctor = { + command: "doctor", + timestamp: "2026-09-06T10:00:00Z", + healthy: true, + checks: [{ name: "new_check", path: "", status: "pass", message: "New check succeeded" }], + summary: { pass: 1, warn: 0, fail: 0, total: 1 }, +} satisfies DoctorResult; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function renderStatus() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + render( + + + , + ); + return client; +} + +describe("runtime health boundary", () => { + it("retains a complete response and absent optional SPA fields", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(health)); + await expect(fetchRuntimeHealth()).resolves.toEqual(health); + expect(fetch).toHaveBeenCalledWith("/api/health", expect.objectContaining({ method: "GET" })); + }); + + it("retains Desktop identity and optional SPA metadata", async () => { + const desktop = { + ...health, + runtime_owner: "desktop", + runtime_supervision: "desktop-child", + runtime_instance_id: "instance", + service_installation_nonce: "nonce", + owner_executable_path: "/Applications/SelfTune.app", + spa_mode: "proxy", + spa_build_id: null, + spa_proxy_url: "http://localhost:5199", + } satisfies HealthResponse; + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(desktop)); + await expect(fetchRuntimeHealth()).resolves.toEqual(desktop); + }); + + it.each([ + null, + {}, + { ...health, update_available: "false" }, + { ...health, auto_update_supported: "true" }, + { ...health, process_mode: "other" }, + { ...health, latest_version: 42 }, + { ...health, version: null }, + { ...health, runtime_owner: "browser" }, + { ...health, spa_mode: "other" }, + { ...health, watcher_mode: "other" }, + { ...health, port: "7888" }, + ])("rejects a malformed successful health response: %j", async (payload) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload)); + await expect(fetchRuntimeHealth()).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it("rejects failed HTTP status even when the body matches the health contract", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(health, { status: 503 })); + await expect(fetchRuntimeHealth()).rejects.toMatchObject({ status: 503, code: "API_ERROR" }); + }); + + it("rejects malformed JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{broken")); + await expect(fetchRuntimeHealth()).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); +}); + +describe("System Status runtime details", () => { + it("shows validated update state and keeps unknown doctor checks visible", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => + Response.json(url === "/api/health" ? health : doctor), + ); + renderStatus(); + await screen.findByText("Up to date"); + expect(screen.queryByText("v0.4.21 available")).toBeNull(); + expect(screen.getByText("new_check")).toBeTruthy(); + expect(screen.getByText(health.workspace_root)).toBeTruthy(); + }); + + it("does not display unvalidated runtime data", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => + Response.json(url === "/api/health" ? { ...health, update_available: "false" } : doctor), + ); + const client = renderStatus(); + await screen.findByText("new_check"); + await waitFor(() => expect(client.getQueryState(["runtime-health", 0])?.status).toBe("error")); + expect(screen.queryByText("Active dashboard runtime")).toBeNull(); + expect(screen.queryByText("v0.4.21 available")).toBeNull(); + }); + + it("keeps the latest refresh when an earlier request finishes late", async () => { + const stale = Promise.withResolvers(); + let healthRequests = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (url !== "/api/health") return Response.json(doctor); + healthRequests += 1; + if (healthRequests === 1) return stale.promise; + return Response.json({ ...health, update_available: true, latest_version: "0.4.22" }); + }); + renderStatus(); + await waitFor(() => expect(healthRequests).toBe(1)); + fireEvent.click(screen.getByRole("button", { name: "Refresh status" })); + await screen.findByText("v0.4.22 available"); + await act(async () => { + stale.resolve(Response.json(health)); + await stale.promise; + }); + await waitFor(() => expect(screen.getByText("v0.4.22 available")).toBeTruthy()); + expect(screen.queryByText("Up to date")).toBeNull(); + }); +}); diff --git a/apps/local-dashboard/src/pages/Status.tsx b/apps/local-dashboard/src/pages/Status.tsx index 93efdf07..38a7298d 100644 --- a/apps/local-dashboard/src/pages/Status.tsx +++ b/apps/local-dashboard/src/pages/Status.tsx @@ -11,12 +11,13 @@ import { SettingsIcon, ShieldCheckIcon, } from "lucide-react"; -import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchRuntimeHealth } from "@/api"; import { Skeleton } from "@/components/ui/skeleton"; import { useDoctor } from "@/hooks/useDoctor"; -import type { HealthCheck, HealthResponse, HealthStatus } from "@/types"; +import type { HealthCheck, HealthStatus } from "@/types"; const STATUS_DISPLAY: Record< HealthStatus, @@ -51,79 +52,80 @@ const STATUS_DISPLAY: Record< }, }; -const CHECK_META: Record = { - config: { - label: "Configuration", - description: "selftune.json exists and contains valid agent_type and llm_mode", - icon: , - }, - log_session_telemetry: { - label: "Session Telemetry Log", - description: "session_telemetry_log.jsonl exists and records parse correctly", - icon: , - }, - log_skill_usage: { - label: "Skill Usage Log", - description: "skill_usage_log.jsonl exists and records parse correctly", - icon: , - }, - log_all_queries: { - label: "Query Log", - description: "all_queries_log.jsonl exists and records parse correctly", - icon: , - }, - log_evolution_audit: { - label: "Evolution Audit Log", - description: "evolution_audit_log.jsonl exists and records parse correctly", - icon: , - }, - hook_settings: { - label: "Hook Installation", - description: "Claude Code settings.json has all required selftune hooks configured", - icon: , - }, - evolution_audit: { - label: "Evolution Health", - description: "Evolution audit log is intact and records are well-formed", - icon: , - }, - dashboard_freshness_mode: { - label: "Dashboard Freshness", - description: - "The current dashboard still invalidates live updates from JSONL log watchers. SQLite WAL live invalidation has not been cut over yet.", - icon: , - }, -}; - -function isHealthResponse(value: unknown): value is HealthResponse { - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - return ( - typeof record.workspace_root === "string" && - typeof record.git_sha === "string" && - typeof record.db_path === "string" && - typeof record.process_mode === "string" && - (record.watcher_mode === "wal" || - record.watcher_mode === "jsonl" || - record.watcher_mode === "none") - ); -} +const CHECK_META = new Map([ + [ + "config", + { + label: "Configuration", + description: "selftune.json exists and contains valid agent_type and llm_mode", + icon: , + }, + ], + [ + "log_session_telemetry", + { + label: "Session Telemetry Log", + description: "session_telemetry_log.jsonl exists and records parse correctly", + icon: , + }, + ], + [ + "log_skill_usage", + { + label: "Skill Usage Log", + description: "skill_usage_log.jsonl exists and records parse correctly", + icon: , + }, + ], + [ + "log_all_queries", + { + label: "Query Log", + description: "all_queries_log.jsonl exists and records parse correctly", + icon: , + }, + ], + [ + "log_evolution_audit", + { + label: "Evolution Audit Log", + description: "evolution_audit_log.jsonl exists and records parse correctly", + icon: , + }, + ], + [ + "hook_settings", + { + label: "Hook Installation", + description: "Claude Code settings.json has all required selftune hooks configured", + icon: , + }, + ], + [ + "evolution_audit", + { + label: "Evolution Health", + description: "Evolution audit log is intact and records are well-formed", + icon: , + }, + ], + [ + "dashboard_freshness_mode", + { + label: "Dashboard Freshness", + description: + "The current dashboard still invalidates live updates from JSONL log watchers. SQLite WAL live invalidation has not been cut over yet.", + icon: , + }, + ], +]); function RuntimeDetailsPanel({ refreshKey }: { refreshKey: number }) { - const [health, setHealth] = useState(null); - - useEffect(() => { - fetch("/api/health") - .then((res) => res.json()) - .then((data: unknown) => { - if (isHealthResponse(data)) { - setHealth(data); - } - }) - .catch(() => { - /* non-critical */ - }); - }, [refreshKey]); + const { data: health } = useQuery({ + queryKey: ["runtime-health", refreshKey], + queryFn: fetchRuntimeHealth, + retry: false, + }); if (!health) return null; const watcherBadge = @@ -253,7 +255,7 @@ function RuntimeDetailsPanel({ refreshKey }: { refreshKey: number }) { } function CheckRow({ check }: { check: HealthCheck }) { - const meta = CHECK_META[check.name] ?? { + const meta = CHECK_META.get(check.name) ?? { label: check.name, description: "", icon: , diff --git a/apps/local-dashboard/src/pages/Team.tsx b/apps/local-dashboard/src/pages/Team.tsx index ee0b907b..360fd28d 100644 --- a/apps/local-dashboard/src/pages/Team.tsx +++ b/apps/local-dashboard/src/pages/Team.tsx @@ -79,7 +79,7 @@ function TeamSkeleton() { ); } -export function teamFailureContent(cloudLinked: boolean | undefined, error: unknown) { +export function teamFailureContent(cloudLinked: boolean | undefined, cause: unknown) { if (cloudLinked === false) { return { title: "Connect Cloud to see your team", @@ -92,7 +92,7 @@ export function teamFailureContent(cloudLinked: boolean | undefined, error: unkn return { title: "Team data is temporarily unavailable", description: "You’re connected to SelfTune Cloud, but we couldn’t load your workspace.", - detail: error instanceof Error ? error.message : null, + detail: cause instanceof Error ? cause.message : null, action: "retry" as const, }; } diff --git a/apps/local-dashboard/src/skill-set-publish-api.ts b/apps/local-dashboard/src/skill-set-publish-api.ts index 5b5322d6..4370abcc 100644 --- a/apps/local-dashboard/src/skill-set-publish-api.ts +++ b/apps/local-dashboard/src/skill-set-publish-api.ts @@ -6,7 +6,7 @@ import type { ProjectSkillSetReleaseReceiptModel, } from "@selftune/dashboard-core/models"; -import { portfolioRequest } from "./api"; +import { portfolioRequest } from "./dashboard-http"; interface LocalSkillSetPublishPreviewResponse { skillSetId: string; @@ -38,7 +38,7 @@ export async function previewProjectSkillSetPublish( ): Promise { const response = await portfolioRequest( "/api/v2/skill-sets/publish/preview", - { set_id: input.skillSetId, dependency_resolution: input.dependencyResolution }, + JSON.stringify({ set_id: input.skillSetId, dependency_resolution: input.dependencyResolution }), ); return { skillSetId: response.skillSetId, @@ -61,14 +61,14 @@ export async function publishProjectSkillSet( ): Promise { const response = await portfolioRequest( "/api/v2/skill-sets/publish", - { + JSON.stringify({ set_id: input.skillSetId, expected_skill_set_revision_sha256: input.expectedSkillSetRevisionSha256, expected_envelope_sha256: input.expectedEnvelopeSha256, dependency_resolution: input.dependencyResolution, expected_dependency_lock: input.expectedDependencyLock, confirm_publish: input.confirmPublish, - }, + }), ); return { releaseId: response.release_id, diff --git a/apps/local-dashboard/src/test-fixtures/settings.ts b/apps/local-dashboard/src/test-fixtures/settings.ts new file mode 100644 index 00000000..6678d978 --- /dev/null +++ b/apps/local-dashboard/src/test-fixtures/settings.ts @@ -0,0 +1,59 @@ +import type { DesktopSettingsResponse } from "../types"; + +export function settingsFor(url: string): DesktopSettingsResponse { + return { + harnesses: [], + agent_skill: { + installed: true, + locations: [], + install_command: "npx skills add selftune-dev/selftune", + }, + onboarding: { + version: 1, + completed: true, + import_sources: { + claude_code: false, + cline: false, + codex: false, + opencode: false, + openclaw: false, + pi: false, + }, + hook_harnesses: { + claude_code: false, + cline: false, + codex: false, + opencode: false, + pi: false, + }, + features: { + observability: true, + health_recommendations: true, + autonomous_improvement: false, + }, + }, + cloud_account: { + linked: true, + cloud_user_id: "user-1", + cloud_org_id: "workspace-1", + }, + remote_library: { + configured: true, + credential_provider: null, + url, + preferences: { + releasedSkills: false, + drafts: false, + skillSets: false, + metadata: false, + decisionHistory: false, + }, + }, + schedule: { + supported: true, + format: "launchd", + settings_path: "/tmp/jobs.json", + jobs: [], + }, + }; +} diff --git a/apps/local-dashboard/vite.config.ts b/apps/local-dashboard/vite.config.ts index f95ebff7..abd60d38 100644 --- a/apps/local-dashboard/vite.config.ts +++ b/apps/local-dashboard/vite.config.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,6 +6,7 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { searchForWorkspaceRoot } from "vite"; import { defineConfig } from "vitest/config"; +import packageManifest from "../../package.json"; function resolvePort(rawValue: string | undefined, fallback: number): number { const parsed = Number.parseInt(rawValue ?? "", 10); @@ -15,9 +15,7 @@ function resolvePort(rawValue: string | undefined, fallback: number): number { const vitePort = resolvePort(process.env.VITE_PORT, 5199); const dashboardPort = resolvePort(process.env.DASHBOARD_PORT, 7888); -const packageVersion = JSON.parse( - readFileSync(new URL("../../package.json", import.meta.url), "utf-8"), -).version as string; +const packageVersion = packageManifest.version; const spaBuildId = process.env.SELFTUNE_SPA_BUILD_ID ?? packageVersion; const geistPackageRoot = dirname( createRequire(import.meta.url).resolve("@fontsource-variable/geist/package.json"), diff --git a/apps/local/package.json b/apps/local/package.json index a2d84a77..868c2c49 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -10,7 +10,7 @@ }, "scripts": { "start": "bun run src/dashboard-server.ts", - "test": "bun test tests/dashboard-operations.test.ts tests/report-process-boundary.test.ts tests/correction-studies-route.test.ts tests/correction-study-service.test.ts tests/correction-signal-discovery.test.ts tests/cloud-team-collaboration.test.ts tests/team-collaboration-routes.test.ts tests/hosted-state.test.ts tests/skill-set-publish-routes.test.ts ../../tests/dashboard ../../tests/runtime", + "test": "bun test tests/dashboard-operations.test.ts tests/report-process-boundary.test.ts tests/correction-studies-route.test.ts tests/correction-study-service.test.ts tests/correction-signal-discovery.test.ts tests/cloud-team-collaboration.test.ts tests/team-collaboration-routes.test.ts tests/hosted-state.test.ts tests/skill-set-publish-routes.test.ts ../../tests/dashboard ../../tests/runtime --path-ignore-patterns='**/action-route-inputs.test.ts' && bun test ../../tests/dashboard/action-route-inputs.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/local/src/cloud-account-link.ts b/apps/local/src/cloud-account-link.ts index d43f3f2b..58ddd9c4 100644 --- a/apps/local/src/cloud-account-link.ts +++ b/apps/local/src/cloud-account-link.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HostedStateService } from "./hosted-state.js"; import { loadConfigSync } from "@selftune/config"; import { @@ -34,7 +38,7 @@ export interface CloudAccountLinkManagerOptions { readonly startOverride?: () => | StartCloudAccountLinkResponse | Promise; - readonly sync: () => unknown | Promise; + readonly sync: HostedStateService["Service"]["sync"]; } function transport(clientId: string) { @@ -45,6 +49,20 @@ function transport(clientId: string) { }; } +export class CloudAccountLinkService extends Context.Service< + CloudAccountLinkService, + ReturnType +>()("SelfTune/CloudAccountLink") {} + +export function makeCloudAccountLinkLayer(options: Omit) { + return Layer.effect(CloudAccountLinkService)( + Effect.gen(function* () { + const hosted = yield* HostedStateService; + return makeCloudAccountLinkManager({ ...options, sync: hosted.sync }); + }), + ); +} + export function makeCloudAccountLinkManager(options: CloudAccountLinkManagerOptions) { const pendingLinks = new Map(); @@ -122,11 +140,12 @@ export function makeCloudAccountLinkManager(options: CloudAccountLinkManagerOpti let firstBackup: CompleteCloudAccountLinkResponse["first_backup"]; try { - const result = (await options.sync()) as { uploaded?: unknown; unchanged?: unknown }; + await options.sync(); + // Hosted sync publishes metadata, not the skill bytes counted by this legacy response. firstBackup = { status: "completed", - uploaded: typeof result?.uploaded === "number" ? result.uploaded : 0, - unchanged: typeof result?.unchanged === "number" ? result.unchanged : 0, + uploaded: 0, + unchanged: 0, }; } catch (cause) { firstBackup = { diff --git a/apps/local/src/cloud-billing.ts b/apps/local/src/cloud-billing.ts index 9e7b9fdd..48da37b0 100644 --- a/apps/local/src/cloud-billing.ts +++ b/apps/local/src/cloud-billing.ts @@ -1,4 +1,7 @@ import * as Schema from "effect/Schema"; +import * as Predicate from "effect/Predicate"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import { loadRemoteLibraryConfig } from "@selftune/runtime/remote-library-config"; import type { DesktopBillingCheckoutFinalizeRequest, @@ -9,53 +12,6 @@ import type { } from "@selftune/runtime/dashboard-contract"; import { CLIError } from "@selftune/runtime/utils/cli-error"; -const PlanId = Schema.Literals(["free", "pro", "team", "enterprise"]); -const PositiveInt = Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))); -const SubscriptionStatus = Schema.Literals([ - "none", - "active", - "canceled", - "incomplete", - "incomplete_expired", - "past_due", - "paused", - "trialing", - "unpaid", -]); -const BillingPlan = Schema.Struct({ - id: PlanId, - name: Schema.String, - price: Schema.NullOr(Schema.String), - period: Schema.NullOr(Schema.String), - description: Schema.String, - features: Schema.Array(Schema.String), - highlighted: Schema.Boolean, - seats: Schema.optional( - Schema.NullOr( - Schema.Struct({ - minimum: PositiveInt, - label: Schema.NullOr(Schema.String), - }), - ), - ), -}); -const BillingStatus = Schema.Struct({ - plan: PlanId, - subscriptionStatus: SubscriptionStatus, - currentPeriodEnd: Schema.NullOr(Schema.String), - trialEnd: Schema.NullOr(Schema.String), - seatCount: PositiveInt, - hasStripeCustomer: Schema.Boolean, - canManageBilling: Schema.Boolean, - availablePlans: Schema.Array(BillingPlan), -}); -const BillingSession = Schema.Struct({ url: Schema.NonEmptyString }); -const BillingCheckoutFinalizeResult = Schema.Struct({ - finalized: Schema.Boolean, - billing: Schema.NullOr(BillingStatus), - sessionStatus: Schema.NullOr(Schema.String), - paymentStatus: Schema.NullOr(Schema.String), -}); const HostedState = Schema.Struct({ workspaceId: Schema.String, plan: Schema.Literals(["free", "pro", "team"]), @@ -105,19 +61,13 @@ async function billingRequest(input: { readonly fetch: typeof fetch; readonly loadRemoteLibraryConfig: typeof loadRemoteLibraryConfig; readonly path: string; - readonly method: "GET" | "POST"; - readonly body?: unknown; -}): Promise { +}): Promise { const remote = cloudConnection(input.configRoot, input.loadRemoteLibraryConfig); let response: Response; try { response = await input.fetch(new URL(input.path, remote.url), { - method: input.method, - headers: { - Authorization: `Bearer ${remote.apiKey}`, - ...(input.body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + method: "GET", + headers: { Authorization: `Bearer ${remote.apiKey}` }, }); } catch (cause) { throw new CLIError( @@ -138,8 +88,10 @@ async function billingRequest(input: { ); } try { - const decoded = Schema.decodeUnknownSync(CloudBillingErrorResponse)(JSON.parse(responseText)); - const error = typeof decoded.error === "string" ? { message: decoded.error } : decoded.error; + const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(CloudBillingErrorResponse))( + responseText, + ); + const error = Predicate.isString(decoded.error) ? { message: decoded.error } : decoded.error; throw new CLIError( error.message ?? `SelfTune Cloud billing request failed (${response.status}).`, "API_ERROR", @@ -158,22 +110,12 @@ async function billingRequest(input: { response.status >= 500, ); } - try { - return JSON.parse(responseText); - } catch { - throw new CLIError( - "SelfTune Cloud returned an invalid billing response.", - "API_ERROR", - "Retry in a moment.", - 1, - true, - ); - } + return responseText; } -function decodeBillingStatus(body: unknown): DesktopBillingStatus { +function decodeBillingStatus(body: string): DesktopBillingStatus { try { - const state = Schema.decodeUnknownSync(HostedState)(body); + const state = Schema.decodeUnknownSync(Schema.fromJsonString(HostedState))(body); return { plan: state.plan, subscriptionStatus: state.status, @@ -237,35 +179,16 @@ function decodeBillingStatus(body: unknown): DesktopBillingStatus { } } -function decodeBillingSession(body: unknown): DesktopBillingSession { - try { - return Schema.decodeUnknownSync(BillingSession)(body); - } catch { - throw new CLIError( - "SelfTune Cloud returned an invalid billing response.", - "API_ERROR", - "Retry in a moment.", - 1, - true, - ); - } -} +/** Keeps the linked device credential in the sidecar process. */ +export class CloudBillingService extends Context.Service< + CloudBillingService, + ReturnType +>()("SelfTune/CloudBilling") {} -function decodeBillingCheckoutFinalizeResult(body: unknown): DesktopBillingCheckoutFinalizeResult { - try { - return Schema.decodeUnknownSync(BillingCheckoutFinalizeResult)(body); - } catch { - throw new CLIError( - "SelfTune Cloud returned an invalid billing response.", - "API_ERROR", - "Retry in a moment.", - 1, - true, - ); - } +export function makeCloudBillingLayer(configRoot: string) { + return Layer.sync(CloudBillingService)(() => makeCloudBillingOperations(configRoot)); } -/** Keeps the linked device credential in the sidecar process. */ export function makeCloudBillingOperations( configRoot: string, options: CloudBillingTransportOptions = {}, @@ -288,33 +211,27 @@ export function makeCloudBillingOperations( status: async (): Promise => { return request({ path: "/api/v1/desktop/state", - method: "GET", }).then(decodeBillingStatus); }, checkout: async (input: DesktopBillingCheckoutRequest): Promise => { - return decodeBillingSession({ - url: `https://cloud.selftune.dev/?billing=${input.plan}`, - }); + return { url: `https://cloud.selftune.dev/?billing=${input.plan}` }; }, portal: async (): Promise => { - return decodeBillingSession({ - url: "https://cloud.selftune.dev/?billing=portal", - }); + return { url: "https://cloud.selftune.dev/?billing=portal" }; }, finalize: async ( input: DesktopBillingCheckoutFinalizeRequest, ): Promise => { const billing = await request({ path: "/api/v1/desktop/state", - method: "GET", }).then(decodeBillingStatus); - return decodeBillingCheckoutFinalizeResult({ + return { finalized: billing.subscriptionStatus === "active" || billing.subscriptionStatus === "trialing", billing, sessionStatus: input.sessionId ? "redirected" : null, paymentStatus: null, - }); + }; }, } as const; } diff --git a/apps/local/src/cloud-team-collaboration.ts b/apps/local/src/cloud-team-collaboration.ts index 0410d6a4..76ff1a68 100644 --- a/apps/local/src/cloud-team-collaboration.ts +++ b/apps/local/src/cloud-team-collaboration.ts @@ -1,4 +1,7 @@ import * as Schema from "effect/Schema"; +import * as Predicate from "effect/Predicate"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import type { TeamCollaborationSnapshotModel, @@ -191,18 +194,17 @@ async function collaborationRequest(input: { readonly loadConfig: typeof loadRemoteLibraryConfig; readonly path: string; readonly method: "GET" | "PATCH" | "POST"; - readonly body?: unknown; -}): Promise { + readonly body?: { readonly policy: TeamRolloutPolicyModel }; +}): Promise { const remote = input.loadConfig(input.configRoot); + const headers = new Headers({ Authorization: `Bearer ${remote.apiKey}` }); + if (input.body !== undefined) headers.set("Content-Type", "application/json"); let response: Response; try { response = await input.fetch(new URL(input.path, remote.url), { method: input.method, - headers: { - Authorization: `Bearer ${remote.apiKey}`, - ...(input.body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + headers, + body: input.body === undefined ? undefined : JSON.stringify(input.body), }); } catch (cause) { throw CloudTeamCollaborationError.make({ @@ -226,37 +228,39 @@ async function collaborationRequest(input: { }); } try { - const decoded = Schema.decodeUnknownSync(CloudErrorResponse)(JSON.parse(responseText)); - const error = typeof decoded.error === "string" ? { message: decoded.error } : decoded.error; - throw CloudTeamCollaborationError.make({ + const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(CloudErrorResponse))( + responseText, + ); + const error = Predicate.isString(decoded.error) ? { message: decoded.error } : decoded.error; + const failure = { code: error.code ?? "API_ERROR", message: error.message ?? `Team collaboration request failed (${response.status}).`, status: response.status, - ...(error.suggestion ? { suggestion: error.suggestion } : {}), retryable: error.retryable ?? response.status >= 500, - }); + }; + if (error.suggestion) + throw CloudTeamCollaborationError.make({ ...failure, suggestion: error.suggestion }); + throw CloudTeamCollaborationError.make(failure); } catch (cause) { if (cause instanceof CloudTeamCollaborationError) throw cause; } - throw CloudTeamCollaborationError.make({ + const failure = { code: "API_ERROR", message: `Team collaboration request failed (${response.status}).`, status: response.status, - ...(response.status >= 500 ? { suggestion: "Retry in a moment." } : {}), retryable: response.status >= 500, - }); + }; + if (response.status >= 500) + throw CloudTeamCollaborationError.make({ ...failure, suggestion: "Retry in a moment." }); + throw CloudTeamCollaborationError.make(failure); } - try { - return JSON.parse(responseText); - } catch { - throw invalidResponse(); - } + return responseText; } -function decodeSnapshot(body: unknown): TeamCollaborationSnapshotModel { +function decodeSnapshot(body: string): TeamCollaborationSnapshotModel { try { - const decoded = Schema.decodeUnknownSync(CollaborationSnapshot)(body); + const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(CollaborationSnapshot))(body); return { entries: decoded.entries.map((entry) => ({ ...entry })), contributions: decoded.contributions.map((contribution) => ({ @@ -271,32 +275,43 @@ function decodeSnapshot(body: unknown): TeamCollaborationSnapshotModel { } } -function decodeAccess(body: unknown): TeamCollaborationAccessModel { +function decodeAccess(body: string): TeamCollaborationAccessModel { try { - const status = Schema.decodeUnknownSync(TeamStatus)(body); + const status = Schema.decodeUnknownSync(Schema.fromJsonString(TeamStatus))(body); return { currentRole: status.currentRole, readOnly: status.readOnly }; } catch { throw invalidResponse(); } } -function decodeRolloutPolicyResult(body: unknown): TeamRolloutPolicyResultModel { +function decodeRolloutPolicyResult(body: string): TeamRolloutPolicyResultModel { try { - return Schema.decodeUnknownSync(RolloutPolicyResult)(body); + return Schema.decodeUnknownSync(Schema.fromJsonString(RolloutPolicyResult))(body); } catch { throw invalidResponse(); } } -function decodeDecisionResult(body: unknown): TeamContributionDecisionResultModel { +function decodeDecisionResult(body: string): TeamContributionDecisionResultModel { try { - return Schema.decodeUnknownSync(ContributionDecisionResult)(body); + return Schema.decodeUnknownSync(Schema.fromJsonString(ContributionDecisionResult))(body); } catch { throw invalidResponse(); } } /** Retains the device credential in the sidecar and forwards only validated collaboration data. */ +export class CloudTeamCollaborationService extends Context.Service< + CloudTeamCollaborationService, + ReturnType +>()("SelfTune/CloudTeamCollaboration") {} + +export function makeCloudTeamCollaborationLayer(configRoot: string) { + return Layer.sync(CloudTeamCollaborationService)(() => + makeCloudTeamCollaborationOperations(configRoot), + ); +} + export function makeCloudTeamCollaborationOperations( configRoot: string, options: CloudTeamCollaborationTransportOptions = {}, diff --git a/apps/local/src/correction-review-projection.ts b/apps/local/src/correction-review-projection.ts index 57c827e4..943cc751 100644 --- a/apps/local/src/correction-review-projection.ts +++ b/apps/local/src/correction-review-projection.ts @@ -1,27 +1,81 @@ import type { Database } from "bun:sqlite"; +import { Effect, Option, Schema } from "effect"; +import { optionalEvidence } from "@selftune/runtime/utils/transcript-contract"; const MAX_JSON_BYTES = 65_536; const MAX_DISPLAY_TEXT = 8_000; -type JsonRecord = Record; +const TextEvidence = optionalEvidence(Schema.String); +const Signal = Schema.Struct({ reason: TextEvidence, correction_intent: TextEvidence }); +const Study = Schema.Struct({ + task_capsule: optionalEvidence( + Schema.Struct({ + observed_failure: TextEvidence, + correction_intent: TextEvidence, + }), + ), + revisions: optionalEvidence( + Schema.Struct({ + pre_edit_revision: TextEvidence, + post_edit_revision: TextEvidence, + }), + ), +}); +const RegressionCase = Schema.Struct({ case_id: TextEvidence }).pipe( + Schema.catchDecoding(() => Effect.succeed(Option.some({}))), +); +const Manifest = Schema.Struct({ + candidate: optionalEvidence( + Schema.Struct({ + installed_body: TextEvidence, + proposed_body: TextEvidence, + changed_lines: optionalEvidence(Schema.Number.check(Schema.isInt())), + }), + ), + active_regression_cases: optionalEvidence(Schema.Array(RegressionCase)), +}); +const Trial = Schema.Struct({ + case_id: TextEvidence, + arm: TextEvidence, + skipped: optionalEvidence(Schema.Boolean), + passed_repetitions: optionalEvidence(Schema.Number), + scored_repetitions: optionalEvidence(Schema.Number), +}).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some({})))); +const EvaluationResult = Schema.Struct({ + reason: TextEvidence, + trials: optionalEvidence(Schema.Array(Trial)), +}); +const Verifier = Schema.Struct({ + instrument: optionalEvidence(Schema.Struct({ verifier_id: TextEvidence, version: TextEvidence })), +}); -function record(value: unknown): JsonRecord | null { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as JsonRecord) - : null; +interface CorrectionReviewRow { + candidate_id?: string | null; + evidence_level?: string | null; + reason?: string | null; + manifest_digest?: string | null; + signal_payload_json?: string | null; + study_payload_json?: string | null; + evaluation_evidence_level?: string | null; + evaluation_status?: string | null; + evaluation_reason?: string | null; + evaluation_manifest_json?: string | null; + evaluation_result_json?: string | null; + verifier_provenance?: string | null; + last_action?: string | null; } -function parseRecord(value: unknown): JsonRecord { - if (typeof value !== "string" || value.length > MAX_JSON_BYTES) return {}; +function parseSaved(schema: Schema.Codec, value: string | null | undefined): A | null { + if (value == null || value.length > MAX_JSON_BYTES) return null; try { - return record(JSON.parse(value)) ?? {}; + return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(value); } catch { - return {}; + return null; } } -function text(value: unknown, fallback = ""): string { - return (typeof value === "string" ? value : fallback).slice(0, MAX_DISPLAY_TEXT); +function text(value: string | null | undefined, fallback = ""): string { + return (value ?? fallback).slice(0, MAX_DISPLAY_TEXT); } function bodyDiff(before: string, after: string): string { @@ -37,22 +91,20 @@ function bodyDiff(before: string, after: string): string { return lines.join("\n").slice(0, MAX_DISPLAY_TEXT); } -function proposedChange(study: JsonRecord, manifest: JsonRecord) { - const candidate = record(manifest.candidate); +function proposedChange(study: typeof Study.Type | null, manifest: typeof Manifest.Type | null) { + const candidate = manifest?.candidate; const installed = text(candidate?.installed_body); const proposed = text(candidate?.proposed_body); if (installed && proposed && installed !== proposed) { const changedLines = - typeof candidate?.changed_lines === "number" && Number.isInteger(candidate.changed_lines) - ? String(candidate.changed_lines) - : "Bounded"; + candidate?.changed_lines !== undefined ? String(candidate.changed_lines) : "Bounded"; return { diff: bodyDiff(installed, proposed), summary: `${changedLines} changed line(s)`, }; } - const revisions = record(study.revisions); + const revisions = study?.revisions; const before = text(revisions?.pre_edit_revision); const after = text(revisions?.post_edit_revision); return before && after && before !== after @@ -62,43 +114,39 @@ function proposedChange(study: JsonRecord, manifest: JsonRecord) { : null; } -function regressionFailures(manifest: JsonRecord, result: JsonRecord): string[] { +function regressionFailures( + manifest: typeof Manifest.Type | null, + result: typeof EvaluationResult.Type | null, +): string[] { const activeIds = new Set( - (Array.isArray(manifest.active_regression_cases) ? manifest.active_regression_cases : []) - .map(record) - .filter((entry): entry is JsonRecord => entry !== null) - .map((entry) => text(entry.case_id)) - .filter(Boolean), + (manifest?.active_regression_cases ?? []).map((entry) => text(entry.case_id)).filter(Boolean), ); - return (Array.isArray(result.trials) ? result.trials : []) - .map(record) - .filter((entry): entry is JsonRecord => entry !== null) + return (result?.trials ?? []) .filter( (entry) => entry.arm === "candidate_skill" && activeIds.has(text(entry.case_id)) && (entry.skipped === true || - (typeof entry.passed_repetitions === "number" && - typeof entry.scored_repetitions === "number" && + (entry.passed_repetitions !== undefined && + entry.scored_repetitions !== undefined && entry.passed_repetitions < entry.scored_repetitions)), ) .map((entry) => text(entry.case_id)); } -function verifierProvenance(value: unknown): string { - const verifier = parseRecord(value); - const instrument = record(verifier.instrument); +function verifierProvenance(value: string | null | undefined): string { + const instrument = parseSaved(Verifier, value)?.instrument; const id = text(instrument?.verifier_id); const version = text(instrument?.version); return id ? `Verifier ${id}${version ? `@${version}` : ""}` : "Candidate manifest"; } -export function projectCorrectionReview(row: Record) { - const signal = parseRecord(row.signal_payload_json); - const study = parseRecord(row.study_payload_json); - const capsule = record(study.task_capsule) ?? {}; - const manifest = parseRecord(row.evaluation_manifest_json); - const result = parseRecord(row.evaluation_result_json); +export function projectCorrectionReview(row: CorrectionReviewRow) { + const signal = parseSaved(Signal, row.signal_payload_json); + const study = parseSaved(Study, row.study_payload_json); + const capsule = study?.task_capsule; + const manifest = parseSaved(Manifest, row.evaluation_manifest_json); + const result = parseSaved(EvaluationResult, row.evaluation_result_json); const evaluationStatus = text(row.evaluation_status); const evaluationEvidence = text(row.evaluation_evidence_level); const candidateEvidence = text(row.evidence_level); @@ -113,15 +161,15 @@ export function projectCorrectionReview(row: Record) { candidate_id: text(row.candidate_id), evidence_level: evidenceLevel, observed_failure: text( - capsule.observed_failure, - text(signal.reason, text(row.reason, "Observed correction")), + capsule?.observed_failure, + text(signal?.reason, text(row.reason, "Observed correction")), ), - correction_intent: text(capsule.correction_intent, text(signal.correction_intent)), + correction_intent: text(capsule?.correction_intent, text(signal?.correction_intent)), proposed_change: proposedChange(study, manifest), evaluation: evaluationStatus ? { summary: `${evaluationStatus}: ${text( - result.reason, + result?.reason, text(row.evaluation_reason, "No reason recorded"), )}`, regressions: regressionFailures(manifest, result), @@ -142,7 +190,7 @@ export function listCorrectionReviews(database: Database, limit: number) { if (!Number.isInteger(limit) || limit < 1 || limit > 128) throw new RangeError("Correction review limit must be between 1 and 128."); const rows = database - .query( + .query( `SELECT c.candidate_id, c.evidence_level, @@ -209,6 +257,6 @@ export function listCorrectionReviews(database: Database, limit: number) { ORDER BY c.updated_at DESC, c.candidate_id ASC LIMIT ?`, ) - .all(limit) as Record[]; + .all(limit); return rows.map(projectCorrectionReview); } diff --git a/apps/local/src/correction-review-request.ts b/apps/local/src/correction-review-request.ts new file mode 100644 index 00000000..32f1e2f6 --- /dev/null +++ b/apps/local/src/correction-review-request.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect"; + +export const CorrectionReviewRequest = Schema.Struct({ + candidate_id: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/)), + action: Schema.Literals(["accept", "reject", "defer"]), + reason: Schema.String.check(Schema.isMaxLength(512), Schema.isPattern(/\S/)), + manifest_digest: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)), +}); + +export type CorrectionReviewRequest = typeof CorrectionReviewRequest.Type; diff --git a/apps/local/src/correction-review-service.ts b/apps/local/src/correction-review-service.ts index 9fbf6596..cc57b823 100644 --- a/apps/local/src/correction-review-service.ts +++ b/apps/local/src/correction-review-service.ts @@ -3,35 +3,14 @@ import { createHash } from "node:crypto"; import type { Database } from "bun:sqlite"; import { recordCorrectionReviewDecision } from "@selftune/local-store"; -import { CorrectionStudyServiceError } from "./routes/correction-studies.js"; +import type { CorrectionReviewRequest } from "./correction-review-request.js"; export function recordLocalCorrectionReviewDecision( database: Database, - input: unknown, + input: CorrectionReviewRequest, decidedAt = new Date().toISOString(), ) { - if ( - typeof input !== "object" || - input === null || - !("candidate_id" in input) || - !("action" in input) || - !("reason" in input) || - !("manifest_digest" in input) || - typeof input.candidate_id !== "string" || - typeof input.action !== "string" || - typeof input.reason !== "string" || - typeof input.manifest_digest !== "string" || - input.candidate_id.length > 128 || - input.reason.length > 512 || - !["accept", "reject", "defer"].includes(input.action) - ) { - throw new CorrectionStudyServiceError( - "INVALID_CORRECTION_REVIEW", - "A review must name a candidate, action, reason, and immutable manifest.", - 400, - ); - } - const action = input.action as "accept" | "reject" | "defer"; + const { action } = input; const decisionId = `review:${createHash("sha256") .update( JSON.stringify({ diff --git a/apps/local/src/correction-study-service.ts b/apps/local/src/correction-study-service.ts index c34e196d..3b475d0a 100644 --- a/apps/local/src/correction-study-service.ts +++ b/apps/local/src/correction-study-service.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { CorrectionEvidenceLedgerEntry, + type CreateOrGetCorrectionStudy, CorrectionEpisode as PersistedCorrectionEpisode, type CorrectionStudy as PersistedCorrectionStudy, CorrectionStudyPersistenceConflict, @@ -12,6 +13,7 @@ import { } from "@selftune/local-store"; import { evaluateCorrectionStudy, + type EvaluateCorrectionStudyInput, type CorrectionEpisode, type PairedReplayTrial, type VerifierInstrument, @@ -25,6 +27,7 @@ import { import { VerifierQualificationResult } from "@selftune/skill-intelligence/verifier-instruments"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import type { Mutable } from "effect/Types"; const Identifier = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/)); const BoundedText = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(8_000)); @@ -309,10 +312,8 @@ function parsePersistedJson( export const captureExplicitCorrectionStudy = Effect.fn( "CorrectionStudy.captureExplicitCorrection", -)(function* (database: Database, unknownInput: unknown) { - const input = yield* Schema.decodeUnknownEffect(ExplicitCorrectionStudyRequest)( - unknownInput, - ).pipe( +)(function* (database: Database, request: typeof ExplicitCorrectionStudyRequest.Encoded) { + const input = yield* Schema.decodeUnknownEffect(ExplicitCorrectionStudyRequest)(request).pipe( Effect.mapError( (error) => new CorrectionStudyServiceFailure({ @@ -327,14 +328,14 @@ export const captureExplicitCorrectionStudy = Effect.fn( const episode = domainEpisode(episodeId, input.episode); const verifier = domainVerifier(input.verifier); const trials = domainTrials(input.trials); - const evaluation = evaluateCorrectionStudy({ + const evaluationInput: EvaluateCorrectionStudyInput = { episode, verifier, trials, - ...(input.minimum_scored_trials === undefined - ? {} - : { minimum_scored_trials: input.minimum_scored_trials }), - }); + }; + if (input.minimum_scored_trials !== undefined) + evaluationInput.minimum_scored_trials = input.minimum_scored_trials; + const evaluation = evaluateCorrectionStudy(evaluationInput); const manifestJson = JSON.stringify({ manifest_id: evaluation.manifest_id, execution_source: "externally_supplied", @@ -356,7 +357,7 @@ export const captureExplicitCorrectionStudy = Effect.fn( ); const reason = evaluation.reason; const ledgerStatus = evidenceStatus(evaluation.status); - const persisted = yield* createOrGetCorrectionStudy(database, { + const persistence: Mutable = { episode: PersistedCorrectionEpisode.make({ episode_id: episodeId, capture_key: episodeId, @@ -397,28 +398,27 @@ export const captureExplicitCorrectionStudy = Effect.fn( trial_payload_json: trialsJson, recorded_at: input.episode.captured_at, }), - ...(evaluation.case_id === null - ? {} - : { - promoted_case: PromotedStudyCase.make({ - case_id: evaluation.case_id, - episode_id: episodeId, - evidence_id: evidenceId, - skill_id: input.episode.skill_id, - skill_name: input.episode.skill_name, - pre_revision: input.episode.pre_edit_revision, - post_revision: input.episode.post_edit_revision, - manifest_json: manifestJson, - verifier_payload_json: verifierJson, - trial_payload_json: trialsJson, - evidence_level: "E1", - status: "active", - reason: null, - promoted_at: input.episode.captured_at, - created_at: input.episode.captured_at, - }), - }), - }).pipe( + }; + if (evaluation.case_id !== null) { + persistence.promoted_case = PromotedStudyCase.make({ + case_id: evaluation.case_id, + episode_id: episodeId, + evidence_id: evidenceId, + skill_id: input.episode.skill_id, + skill_name: input.episode.skill_name, + pre_revision: input.episode.pre_edit_revision, + post_revision: input.episode.post_edit_revision, + manifest_json: manifestJson, + verifier_payload_json: verifierJson, + trial_payload_json: trialsJson, + evidence_level: "E1", + status: "active", + reason: null, + promoted_at: input.episode.captured_at, + created_at: input.episode.captured_at, + }); + } + const persisted = yield* createOrGetCorrectionStudy(database, persistence).pipe( Effect.mapError((error) => error instanceof CorrectionStudyPersistenceConflict ? new CorrectionStudyServiceFailure({ @@ -453,10 +453,10 @@ export const captureExplicitCorrectionStudy = Effect.fn( */ export const captureManagedCorrectionStudy = Effect.fn("CorrectionStudy.captureManaged")(function* ( database: Database, - unknownInput: unknown, + request: typeof ManagedCorrectionStudyRequest.Encoded, executor: PairedReplayArmExecutor, ) { - const input = yield* Schema.decodeUnknownEffect(ManagedCorrectionStudyRequest)(unknownInput).pipe( + const input = yield* Schema.decodeUnknownEffect(ManagedCorrectionStudyRequest)(request).pipe( Effect.mapError( (error) => new CorrectionStudyServiceFailure({ @@ -520,7 +520,7 @@ export const captureManagedCorrectionStudy = Effect.fn("CorrectionStudy.captureM reason: replay.reason, }), ); - const persisted = yield* createOrGetCorrectionStudy(database, { + const persistence: Mutable = { episode: PersistedCorrectionEpisode.make({ episode_id: episodeId, capture_key: episodeId, @@ -562,28 +562,27 @@ export const captureManagedCorrectionStudy = Effect.fn("CorrectionStudy.captureM trial_payload_json: trialsJson, recorded_at: input.episode.captured_at, }), - ...(replay.status !== "promoted" - ? {} - : { - promoted_case: PromotedStudyCase.make({ - case_id: contentId("correction-case", replay.manifest_id), - episode_id: episodeId, - evidence_id: evidenceId, - skill_id: input.episode.skill_id, - skill_name: input.episode.skill_name, - pre_revision: input.episode.pre_edit_revision, - post_revision: input.episode.post_edit_revision, - manifest_json: manifestJson, - verifier_payload_json: verifierJson, - trial_payload_json: trialsJson, - evidence_level: "E1", - status: "active", - reason: null, - promoted_at: input.episode.captured_at, - created_at: input.episode.captured_at, - }), - }), - }).pipe( + }; + if (replay.status === "promoted") { + persistence.promoted_case = PromotedStudyCase.make({ + case_id: contentId("correction-case", replay.manifest_id), + episode_id: episodeId, + evidence_id: evidenceId, + skill_id: input.episode.skill_id, + skill_name: input.episode.skill_name, + pre_revision: input.episode.pre_edit_revision, + post_revision: input.episode.post_edit_revision, + manifest_json: manifestJson, + verifier_payload_json: verifierJson, + trial_payload_json: trialsJson, + evidence_level: "E1", + status: "active", + reason: null, + promoted_at: input.episode.captured_at, + created_at: input.episode.captured_at, + }); + } + const persisted = yield* createOrGetCorrectionStudy(database, persistence).pipe( Effect.mapError((error) => error instanceof CorrectionStudyPersistenceConflict ? new CorrectionStudyServiceFailure({ diff --git a/apps/local/src/daemon.ts b/apps/local/src/daemon.ts index e3d935af..625edfc9 100644 --- a/apps/local/src/daemon.ts +++ b/apps/local/src/daemon.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { BunRuntime } from "@effect/platform-bun"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; @@ -121,19 +122,15 @@ function defaultSpaDir(): string | undefined { return candidates.find((candidate) => candidate !== null && existsSync(candidate)) ?? undefined; } +const PackageVersion = Schema.fromJsonString(Schema.Struct({ version: Schema.String })); + function installedVersion(): string { const environmentVersion = process.env.SELFTUNE_VERSION ?? process.env.SELFTUNE_SERVICE_VERSION; if (environmentVersion) return environmentVersion; try { - const value: unknown = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")); - if ( - typeof value === "object" && - value !== null && - "version" in value && - typeof value.version === "string" - ) { - return value.version; - } + return Schema.decodeUnknownSync(PackageVersion)( + readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"), + ).version; } catch { // Compiled binaries receive their version through SELFTUNE_VERSION. } @@ -195,17 +192,17 @@ export function resolveDaemonRunOptions( ? "desktop-child" : "none"; const spaDir = input.spaDir ?? dependencies.defaultSpaDir(); - return { + const options = { configDir, hostname, owner, port, readySentinel: input.readySentinel, runtimeMode, - ...(serviceInstallationNonce ? { serviceInstallationNonce } : {}), spaDir, supervision, }; + return serviceInstallationNonce ? { ...options, serviceInstallationNonce } : options; } export function parseDaemonRunInput(args: ReadonlyArray): DaemonRunInput { @@ -274,16 +271,16 @@ const acquireDaemon = Effect.fn("SelfTuneDaemon.acquire")(function* ( dependencies: DaemonStartDependencies = LIVE_START_DEPENDENCIES, ) { const instanceId = dependencies.createInstanceId(); - const runtimeIdentity: DaemonRuntimeIdentity = { + const identity = { configDir: options.configDir, instanceId, owner: options.owner, supervision: options.supervision, ownerExecutablePath: dependencies.executablePath, - ...(options.serviceInstallationNonce - ? { serviceInstallationNonce: options.serviceInstallationNonce } - : {}), }; + const runtimeIdentity: DaemonRuntimeIdentity = options.serviceInstallationNonce + ? { ...identity, serviceInstallationNonce: options.serviceInstallationNonce } + : identity; let requestShutdown: (() => void) | undefined; const shutdownRequested = new Promise((resolveShutdown) => { requestShutdown = resolveShutdown; @@ -321,16 +318,7 @@ const acquireDaemon = Effect.fn("SelfTuneDaemon.acquire")(function* ( spaDir: options.spaDir, openBrowser: false, runtimeMode: options.runtimeMode, - runtimeIdentity: { - configDir: runtimeIdentity.configDir, - instanceId: runtimeIdentity.instanceId, - owner: runtimeIdentity.owner, - ownerExecutablePath: runtimeIdentity.ownerExecutablePath, - ...(runtimeIdentity.serviceInstallationNonce - ? { serviceInstallationNonce: runtimeIdentity.serviceInstallationNonce } - : {}), - supervision: runtimeIdentity.supervision, - }, + runtimeIdentity, runtimeShutdown: () => requestShutdown?.(), spaProxyUrl: process.env.SPA_PROXY_URL, manageProcessSignals: false, @@ -458,9 +446,12 @@ export const runDaemonProgram = Effect.fn("SelfTuneDaemon.program")(function* ( ); }); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const RuntimeOwnership = Schema.Struct({ + pid: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), + runtime_instance_id: Schema.String, + config_dir: Schema.String, + process_mode: Schema.Literal("standalone"), +}); async function manifestOwnsProcess( manifest: NonNullable>, @@ -472,12 +463,13 @@ async function manifestOwnsProcess( signal: AbortSignal.timeout(2_000), }); if (!response.ok) return false; - const payload: unknown = await response.json(); + const payload = Schema.decodeUnknownOption(RuntimeOwnership)(await response.json()).pipe( + Option.getOrNull, + ); return ( - isRecord(payload) && + payload !== null && payload.pid === manifest.pid && payload.runtime_instance_id === manifest.instance_id && - typeof payload.config_dir === "string" && resolve(payload.config_dir) === resolve(configDir) && payload.process_mode === "standalone" ); diff --git a/apps/local/src/dashboard-auth.ts b/apps/local/src/dashboard-auth.ts index 81d5ff59..119dc2a8 100644 --- a/apps/local/src/dashboard-auth.ts +++ b/apps/local/src/dashboard-auth.ts @@ -1,4 +1,5 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import * as Schema from "effect/Schema"; import { dashboardCorsHeaders } from "./dashboard-http.js"; @@ -258,15 +259,9 @@ export function createDashboardAuth(options: DashboardAuthOptions): DashboardAut } let token: string | null = null; try { - const body: unknown = await request.json(); - if ( - typeof body === "object" && - body !== null && - "token" in body && - typeof body.token === "string" - ) { - token = body.token; - } + token = Schema.decodeUnknownSync(Schema.Struct({ token: Schema.String }))( + await request.json(), + ).token; } catch { token = null; } diff --git a/apps/local/src/dashboard-events.ts b/apps/local/src/dashboard-events.ts index b19f4889..520f95e9 100644 --- a/apps/local/src/dashboard-events.ts +++ b/apps/local/src/dashboard-events.ts @@ -1,3 +1,4 @@ +import { decodeDashboardActionLine } from "@selftune/runtime/dashboard-contract/action-events"; import { existsSync, statSync, unwatchFile, watchFile } from "node:fs"; import type { DashboardActionEvent, HealthResponse } from "@selftune/runtime/dashboard-contract"; @@ -80,7 +81,10 @@ export function createDashboardEventHub(options: DashboardEventHubOptions): Dash trimActionHistory(); }; - const broadcast = (eventType: string, payload: unknown): void => { + const broadcast = ( + eventType: "action" | "update", + payload: DashboardActionEvent | DashboardUpdateEvent, + ): void => { const bytes = new TextEncoder().encode( `event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`, ); @@ -128,9 +132,10 @@ export function createDashboardEventHub(options: DashboardEventHubOptions): Dash if (actionStreamDebounce) return; actionStreamDebounce = setTimeout(() => { actionStreamDebounce = null; - const { records, newOffset } = readJsonlFrom( + const { records, newOffset } = readJsonlFrom( options.actionStreamPath, actionStreamOffset, + decodeDashboardActionLine, ); actionStreamOffset = newOffset; for (const record of records) broadcastAction(record); diff --git a/apps/local/src/dashboard-http.ts b/apps/local/src/dashboard-http.ts index 97f76262..d64defea 100644 --- a/apps/local/src/dashboard-http.ts +++ b/apps/local/src/dashboard-http.ts @@ -1,6 +1,6 @@ import type { DashboardOperationError } from "./dashboard-operations.js"; -export function dashboardCorsHeaders(): Record { +export function dashboardCorsHeaders() { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PATCH, POST, OPTIONS", @@ -21,17 +21,12 @@ export function withDashboardCors(response: Response): Response { } export function dashboardOperationErrorResponse(error: DashboardOperationError): Response { + const detail = { code: error.code, message: error.message, retryable: error.retryable }; + const guidance = error.suggestion ? { ...detail, suggestion: error.suggestion } : detail; + const failures = error.failures ? { ...guidance, failures: error.failures } : guidance; + const progress = error.progress ? { ...failures, progress: error.progress } : failures; return Response.json( - { - error: { - code: error.code, - message: error.message, - ...(error.suggestion ? { suggestion: error.suggestion } : {}), - retryable: error.retryable, - ...(error.failures ? { failures: error.failures } : {}), - ...(error.progress ? { progress: error.progress } : {}), - }, - }, + { error: progress }, { status: error.status, headers: dashboardCorsHeaders() }, ); } diff --git a/apps/local/src/dashboard-operation-errors.ts b/apps/local/src/dashboard-operation-errors.ts index 1788c822..b3d838f0 100644 --- a/apps/local/src/dashboard-operation-errors.ts +++ b/apps/local/src/dashboard-operation-errors.ts @@ -63,14 +63,16 @@ export function operationError(operation: string, cause: unknown): DashboardOper }); } if (cause instanceof CloudTeamCollaborationError) { - return DashboardOperationError.make({ + const failure = { operation, code: cause.code, message: cause.message, status: cause.status, - ...(cause.suggestion ? { suggestion: cause.suggestion } : {}), retryable: cause.retryable, - }); + }; + if (cause.suggestion) + return DashboardOperationError.make({ ...failure, suggestion: cause.suggestion }); + return DashboardOperationError.make(failure); } if (cause instanceof CatalogSkillSetResolutionError) { return DashboardOperationError.make({ @@ -118,14 +120,16 @@ export function operationError(operation: string, cause: unknown): DashboardOper }); } if (cause instanceof CLIError || cause instanceof LibraryError) { - return DashboardOperationError.make({ + const failure = { operation, code: cause.code, message: cause.message, status: cause.code === "FILE_NOT_FOUND" ? 404 : cause.code === "GUARD_BLOCKED" ? 409 : 400, - ...(cause.suggestion ? { suggestion: cause.suggestion } : {}), retryable: cause.retryable, - }); + }; + if (cause.suggestion) + return DashboardOperationError.make({ ...failure, suggestion: cause.suggestion }); + return DashboardOperationError.make(failure); } return DashboardOperationError.make({ operation, diff --git a/apps/local/src/dashboard-operations.ts b/apps/local/src/dashboard-operations.ts index ad266901..6c162bf3 100644 --- a/apps/local/src/dashboard-operations.ts +++ b/apps/local/src/dashboard-operations.ts @@ -2,8 +2,7 @@ import { previewSkillSetLicenseDraft, applySkillSetLicenseDraft, } from "@selftune/runtime/skill-set-license-draft"; -import { join, resolve } from "node:path"; -import type { Database } from "bun:sqlite"; +import { resolve } from "node:path"; import * as Context from "effect/Context"; /* eslint-disable max-lines -- local dashboard operations are a legacy composition boundary */ @@ -23,7 +22,7 @@ import type { SkillSetDependencyResolutionInput, SkillSetPackPreview, } from "@selftune/control-plane"; -import { getDb, LocalDatabaseService } from "@selftune/local-store"; +import { LocalDatabaseService } from "@selftune/local-store"; import { SELFTUNE_CONFIG_DIR } from "@selftune/runtime/constants"; import { applyDesktopOnboarding, @@ -69,7 +68,6 @@ import type { UpdateSkillClassificationRequest, UpdateSkillSetRequest, } from "@selftune/runtime/dashboard-contract"; -import { createControlPlaneRuntime } from "@selftune/runtime/control-plane-runtime"; import { makeNodeInstallerMaterializationFileSystem, makeSqliteInstallerExclusiveCommitLock, @@ -156,8 +154,8 @@ import { listQuarantinedSkills, quarantineSkill, restoreQuarantinedSkill, - type PortfolioAuditResult, } from "@selftune/runtime/skill-portfolio"; +import { quarantinePortfolioBatch } from "@selftune/runtime/skill-portfolio/on-demand-batch"; import { createSkillSet, captureSkillSetFromProject, @@ -180,33 +178,39 @@ import { localHarnessSettingsEnvironment, resolveSourceMergeInvocation, } from "./harness-registry.js"; -import { makeCloudAccountLinkManager } from "./cloud-account-link.js"; -import { makeCloudBillingOperations } from "./cloud-billing.js"; +import { CloudAccountLinkService, makeCloudAccountLinkLayer } from "./cloud-account-link.js"; +import { CloudBillingService, makeCloudBillingLayer } from "./cloud-billing.js"; import { - makeHostedStateOperations, + HostedStateService, + makeHostedStateLayer, type HostedSkillSetPublishPreview, type PublishHostedSkillSetInput, } from "./hosted-state.js"; import { - makeCloudTeamCollaborationOperations, + CloudTeamCollaborationService, + makeCloudTeamCollaborationLayer, type TeamCollaborationAccessModel, type TeamContributionDecisionResultModel, type TeamRolloutPolicyResultModel, } from "./cloud-team-collaboration.js"; -import { makeLibraryReportLoader } from "./library-report.js"; -import { makeMaterializedCache } from "./operation-cache.js"; +import { makeDashboardLibraryLayer } from "./library-report.js"; + import { - makeRemoteLibraryOperations, + RemoteLibraryService, + makeRemoteLibraryLayer, type RemoteLibraryAction, type RemoteLibraryShareAction, type RemoteWorkspaceAction, type RemoteWorkspaceInput, } from "./remote-library-operations.js"; import { - computeReportInWorker, - resolveReportComputeOptions, - type DashboardReportName, -} from "./report-compute.js"; + PortfolioAuditCache, + SkillIntelligenceCache, + InsightsCache, + LibraryCache, + makeDashboardReportCachesLayer, + type DashboardReportCacheOptions, +} from "./report-caches.js"; import { attempt, DashboardOperationError, operationError } from "./dashboard-operation-errors.js"; import { importSkillSetPack, previewSkillSetPack } from "./skill-set-pack-import.js"; import { @@ -226,10 +230,27 @@ export type { RemoteWorkspaceAction, } from "./remote-library-operations.js"; export type CloudBillingAction = "status" | "checkout" | "portal" | "finalize"; -export interface DashboardOperationOverrides { - portfolioLoader?: () => PortfolioAuditResult; - libraryLoader?: () => LibrarySnapshot | Promise; - skillIntelligenceLoader?: () => SkillIntelligenceReport | Promise; +type OverrideResult = A | Promise; +type TeamContributionOperations = ReturnType; +type TeamContributionSubmitResult = Awaited>; +type TeamContributionSyncResult = Awaited>; +type InsightReviewResult = Awaited>; +type InsightDraftResult = Awaited>; +type InsightEvaluationResult = Awaited>; +type InsightReleaseResult = Awaited>; +type HostedSyncResult = Awaited>; +type RemoteLibraryResult = + | Awaited> + | HostedSyncResult + | { readonly url: string; readonly mode: "privacy_safe_manifest" }; +type LibraryBackupResult = + | Awaited> + | HostedSyncResult; +type LibraryInstallResult = Awaited>; +type LibraryShareResult = + | Awaited> + | Awaited>; +export interface DashboardOperationOverrides extends DashboardReportCacheOptions { skillClassificationUpdater?: ( input: UpdateSkillClassificationRequest, ) => SkillClassificationOverrideReceipt | Promise; @@ -274,8 +295,8 @@ export interface DashboardOperationOverrides { teamContributionSubmitter?: (input: { readonly previewToken: string; readonly confirmSubmit: boolean; - }) => unknown | Promise; - teamContributionSyncer?: () => unknown | Promise; + }) => OverrideResult; + teamContributionSyncer?: () => OverrideResult; sourceUpdatePreviewer?: ( skillName: string, ) => SkillSourceUpdatePreview | Promise; @@ -301,17 +322,16 @@ export interface DashboardOperationOverrides { approvalId: string, action: "approve" | "decline", ) => DurableDashboardDecision | Promise; - insightsLoader?: () => InsightsResponse | Promise; - insightReviewer?: (input: ReviewInsightRequest) => unknown | Promise; - insightDrafter?: (input: DraftInsightRequest) => unknown | Promise; - insightEvaluator?: (candidateId: string) => unknown | Promise; - insightReleaser?: (candidateId: string) => unknown | Promise; - remoteLibraryAction?: (action: RemoteLibraryAction) => unknown | Promise; - remoteLibrarySkillBackup?: (skillId: string) => unknown | Promise; + insightReviewer?: (input: ReviewInsightRequest) => OverrideResult; + insightDrafter?: (input: DraftInsightRequest) => OverrideResult; + insightEvaluator?: (candidateId: string) => OverrideResult; + insightReleaser?: (candidateId: string) => OverrideResult; + remoteLibraryAction?: (action: RemoteLibraryAction) => OverrideResult; + remoteLibrarySkillBackup?: (skillId: string) => OverrideResult; remoteLibrarySkillInstall?: ( skillId: string, targetAgent: "codex" | "claude_code" | "opencode" | "openclaw" | "pi", - ) => unknown | Promise; + ) => OverrideResult; cloudAccountLinkStarter?: () => | StartCloudAccountLinkResponse | Promise; @@ -343,90 +363,18 @@ export interface DashboardOperationOverrides { remoteLibraryShareAction?: ( action: RemoteLibraryShareAction, input?: CreateRemoteLibraryShareRequest | CreateSkillShareGrantRequest | { share_id: string }, - ) => unknown | Promise; + ) => OverrideResult; settingsLoader?: () => DesktopSettingsResponse; settingsUpdater?: (input: UpdateDesktopScheduleRequest) => DesktopSettingsResponse; remoteSettingsUpdater?: (input: UpdateRemoteLibraryRequest) => DesktopSettingsResponse; onboardingUpdater?: ( input: ApplyOnboardingRequest, ) => ApplyOnboardingResponse | Promise; - skillSetConfigRoot?: string; - portfolioSearchDirs?: string[]; - quarantineRoot?: string; catalogSkillPackageResolver?: CatalogSkillPackageResolver; catalogSkillResolutionProgress?: (progress: CatalogSkillResolutionProgress) => void; - /** Test seam and host override for report-specific dependency watermarks. */ - reportVersionReaders?: Partial string>>; -} - -interface ReportDependency { - readonly table: string; - /** An indexed append/update timestamp, when the report needs one. */ - readonly cursorColumn?: string; } -const REPORT_DEPENDENCIES: Record = { - "portfolio-audit": [ - { table: "session_telemetry", cursorColumn: "timestamp" }, - { table: "skill_invocations", cursorColumn: "occurred_at" }, - { table: "prompts", cursorColumn: "occurred_at" }, - { table: "queries", cursorColumn: "timestamp" }, - { table: "skill_usage", cursorColumn: "timestamp" }, - ], - "skill-intelligence": [ - { table: "sessions" }, - { table: "prompts", cursorColumn: "occurred_at" }, - { table: "skill_invocations", cursorColumn: "occurred_at" }, - { table: "session_telemetry", cursorColumn: "timestamp" }, - { table: "queries", cursorColumn: "timestamp" }, - { table: "skill_usage", cursorColumn: "timestamp" }, - { table: "skill_classification_overrides", cursorColumn: "updated_at" }, - { table: "skill_set_suggestion_reviews", cursorColumn: "reviewed_at" }, - { table: "skill_set_outcomes", cursorColumn: "measured_at" }, - // DuckDB facts are rebuildable, but this SQLite checkpoint records the - // accepted source revision that changes their dashboard projection. - { table: "analytical_import_checkpoints", cursorColumn: "imported_at" }, - ], - insights: [ - { table: "session_telemetry", cursorColumn: "timestamp" }, - { table: "skill_invocations", cursorColumn: "occurred_at" }, - { table: "prompts", cursorColumn: "occurred_at" }, - { table: "queries", cursorColumn: "timestamp" }, - { table: "skill_usage", cursorColumn: "timestamp" }, - ], - library: [ - { table: "skill_install_receipts" }, - { table: "skill_install_receipt_files" }, - { table: "skill_install_operations" }, - ], -}; - -function dependencyCursor(db: Database, dependency: ReportDependency): string { - const rowid = db - .query(`SELECT COALESCE(MAX(rowid), 0) AS max_rowid FROM ${dependency.table}`) - .get() as { max_rowid?: number } | null; - if (!dependency.cursorColumn) return `${dependency.table}:${rowid?.max_rowid ?? 0}`; - const timestamp = db - .query( - `SELECT COALESCE(MAX(${dependency.cursorColumn}), '') AS max_cursor FROM ${dependency.table}`, - ) - .get() as { max_cursor?: string } | null; - return `${dependency.table}:${rowid?.max_rowid ?? 0}:${timestamp?.max_cursor ?? ""}`; -} - -export function dashboardReportDependencyVersion( - report: DashboardReportName, - database?: Database, -): string { - const dependencies = REPORT_DEPENDENCIES[report]; - try { - const db = database ?? getDb(); - return dependencies.map((dependency) => dependencyCursor(db, dependency)).join("|"); - } catch { - // The cache TTL remains a safe fallback while a host is still bringing up SQLite. - return `unavailable:${report}`; - } -} +export { dashboardReportDependencyVersion } from "./report-version.js"; type HeavyReport = "portfolio" | "skillIntelligence" | "insights" | "library"; type ReportInvalidationScope = "all" | "skillIntelligence" | "insights"; @@ -693,85 +641,52 @@ export class DashboardOperations extends Context.Service< confirm: boolean; }) => Effect.Effect; readonly quarantineMany: ( - inputs: readonly { skillName: string; skillPath: string }[], + inputs: readonly { + skillName: string; + skillPath: string; + keepSearchable?: boolean; + expectedContentHash?: string; + }[], ) => Effect.Effect; readonly restore: (quarantineId: string) => Effect.Effect; } >()("@selftune/local/DashboardOperations") {} export function makeDashboardOperationsLayer(options: DashboardOperationOverrides = {}) { + const configRoot = resolve(options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR); + const harnessSettings = localHarnessSettingsEnvironment(); + const getSettings = options.settingsLoader ?? (() => loadDesktopSettings(harnessSettings)); + const getMigratedSettings = + options.settingsLoader ?? + (() => + loadDesktopSettingsWithMigration({ + ...harnessSettings, + configDir: options.skillSetConfigRoot, + })); + const libraryLayer = makeDashboardLibraryLayer(options.skillSetConfigRoot, options.libraryLoader); + const hostedLayer = makeHostedStateLayer(configRoot).pipe(Layer.provideMerge(libraryLayer)); + const accountLinkLayer = makeCloudAccountLinkLayer({ + configRoot, + loadSettings: getMigratedSettings, + startOverride: options.cloudAccountLinkStarter, + completeOverride: options.cloudAccountLinkCompleter, + }).pipe(Layer.provideMerge(hostedLayer)); + const dependencies = Layer.mergeAll( + accountLinkLayer, + makeCloudBillingLayer(configRoot), + makeCloudTeamCollaborationLayer(configRoot), + makeRemoteLibraryLayer(configRoot), + makeDashboardReportCachesLayer(options), + ); return Layer.effect( DashboardOperations, Effect.gen(function* () { const localDatabase = yield* Effect.serviceOption(LocalDatabaseService); const reportDatabase = Option.getOrUndefined(localDatabase)?.sqlite; - const controlPlane = yield* Effect.acquireRelease( - Effect.sync(createControlPlaneRuntime), - (runtime) => Effect.promise(() => runtime.dispose()), - ); - const reportOptions = resolveReportComputeOptions({ - configRoot: options.skillSetConfigRoot, - searchDirs: options.portfolioSearchDirs, - quarantineRoot: options.quarantineRoot, - }); - const reportsDir = join(reportOptions.storagePaths.configRoot, "cache", "reports"); - const readReportVersion = (report: DashboardReportName) => - options.reportVersionReaders?.[report] ?? - (() => dashboardReportDependencyVersion(report, reportDatabase)); - // Compute reports in a subprocess so a failed or memory-heavy report never occupies - // the daemon. The cache retains a previous artifact on worker failure. - const reportCompute = ( - report: DashboardReportName, - operation: string, - ): Effect.Effect => - computeReportInWorker(report, reportOptions, reportsDir).pipe( - Effect.mapError((cause) => operationError(operation, cause)), - ); - const portfolioAudit = options.portfolioLoader - ? { - read: attempt("portfolio.load", options.portfolioLoader), - invalidate: Effect.void, - } - : yield* makeMaterializedCache( - reportCompute("portfolio-audit", "portfolio.load"), - { - artifactPath: join(reportsDir, "portfolio-audit.json"), - readVersion: readReportVersion("portfolio-audit"), - }, - ); - const skillIntelligenceReport = options.skillIntelligenceLoader - ? { - read: attempt("skill_intelligence.load", options.skillIntelligenceLoader), - invalidate: Effect.void, - } - : yield* makeMaterializedCache( - reportCompute("skill-intelligence", "skill_intelligence.load"), - { - artifactPath: join(reportsDir, "skill-intelligence.json"), - readVersion: readReportVersion("skill-intelligence"), - }, - ); - const insightsReport = options.insightsLoader - ? { - read: attempt("insights.load", options.insightsLoader), - invalidate: Effect.void, - } - : yield* makeMaterializedCache( - reportCompute("insights", "insights.load"), - { - artifactPath: join(reportsDir, "insights.json"), - readVersion: readReportVersion("insights"), - }, - ); - const harnessSettings = localHarnessSettingsEnvironment(); - const getSettings = options.settingsLoader ?? (() => loadDesktopSettings(harnessSettings)); - const getMigratedSettings = options.settingsLoader - ? getSettings - : () => - loadDesktopSettingsWithMigration({ - ...harnessSettings, - configDir: options.skillSetConfigRoot, - }); + const portfolioAudit = yield* PortfolioAuditCache; + const skillIntelligenceReport = yield* SkillIntelligenceCache; + const insightsReport = yield* InsightsCache; + const libraryReport = yield* LibraryCache; const skillSetOptions = options.skillSetConfigRoot ? { configRoot: options.skillSetConfigRoot } : {}; @@ -824,14 +739,6 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride }; }; - const getLibrary = - options.libraryLoader ?? makeLibraryReportLoader(options.skillSetConfigRoot, controlPlane); - const libraryReport = options.libraryLoader - ? { read: attempt("library.load", getLibrary), invalidate: Effect.void } - : yield* makeMaterializedCache(reportCompute("library", "library.load"), { - artifactPath: join(reportsDir, "library.json"), - readVersion: readReportVersion("library"), - }); const reportCaches = { portfolio: portfolioAudit, skillIntelligence: skillIntelligenceReport, @@ -856,16 +763,9 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride scope: ReportInvalidationScope = "all", ) => invalidating(attempt(operation, run), scope); - const configuredRemoteLibrary = makeRemoteLibraryOperations( - options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, - ); - const configuredCloudBilling = makeCloudBillingOperations( - options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, - ); - const configuredHostedState = makeHostedStateOperations( - options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, - getLibrary, - ); + const configuredRemoteLibrary = yield* RemoteLibraryService; + const configuredCloudBilling = yield* CloudBillingService; + const configuredHostedState = yield* HostedStateService; const configuredAssignedSkillSets = reportDatabase ? (() => { const sqlite = makeSqliteInstallerReceiptAuthority(reportDatabase); @@ -910,10 +810,8 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride "Publishing a Skill Set release requires a linked SelfTune Cloud workspace.", ); }; - const configuredTeamCollaboration = makeCloudTeamCollaborationOperations( - options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, - ); - const runRemoteLibrary = + const configuredTeamCollaboration = yield* CloudTeamCollaborationService; + const runRemoteLibrary: NonNullable = options.remoteLibraryAction ?? (async (action) => { if (!(await configuredHostedState.isCloudConnection())) @@ -929,13 +827,7 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride ); }); - const cloudAccountLink = makeCloudAccountLinkManager({ - configRoot: resolve(options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR), - loadSettings: getMigratedSettings, - sync: configuredHostedState.sync, - startOverride: options.cloudAccountLinkStarter, - completeOverride: options.cloudAccountLinkCompleter, - }); + const cloudAccountLink = yield* CloudAccountLinkService; const runRemoteLibraryShare = options.remoteLibraryShareAction ?? @@ -1558,31 +1450,11 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride const installedSkills = findInstalledSkillPackages( options.portfolioSearchDirs ?? getDefaultSkillSearchDirs(), ); - const result: PortfolioQuarantineBatchResult = { - receipts: [], - failures: [], - }; - - for (const input of inputs) { - try { - result.receipts.push( - quarantineSkill({ - installedSkills, - skillName: input.skillName, - skillPath: input.skillPath, - quarantineRoot: options.quarantineRoot, - }), - ); - } catch (error) { - result.failures.push({ - skill_name: input.skillName, - skill_path: input.skillPath, - message: error instanceof Error ? error.message : String(error), - }); - } - } - - return result; + return quarantinePortfolioBatch(inputs, { + installedSkills, + quarantineRoot: options.quarantineRoot, + configRoot: options.skillSetConfigRoot, + }); }), restore: (quarantineId) => invalidatingAttempt("portfolio.restore", () => @@ -1593,5 +1465,5 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride ), }); }), - ); + ).pipe(Layer.provide(dependencies)); } diff --git a/apps/local/src/dashboard-server.ts b/apps/local/src/dashboard-server.ts index 1387c233..44b5897c 100644 --- a/apps/local/src/dashboard-server.ts +++ b/apps/local/src/dashboard-server.ts @@ -66,12 +66,12 @@ /* eslint-disable max-lines -- Legacy server composition is being extracted route by route. */ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; -import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { homedir } from "node:os"; -import type { Database } from "bun:sqlite"; import type { BlindBenchmarkExecutor } from "@selftune/skill-intelligence/blind-benchmark"; import { getCachedUpdateStatus } from "@selftune/runtime/auto-update"; @@ -79,29 +79,9 @@ import { DASHBOARD_ACTION_STREAM_LOG, LOG_DIR } from "@selftune/runtime/constant import type { DashboardHostKind } from "@selftune/dashboard-core/host"; import type { HealthResponse } from "@selftune/runtime/dashboard-contract"; import { resolveSelftunePaths } from "@selftune/config"; -import { - getEvaluationSubmissionDraft, - makeLocalDatabaseLive, - LocalDatabaseService, - markEvaluationSubmissionDraftStale, - markEvaluationSubmissionDraftSubmitted, -} from "@selftune/local-store"; -import { EvidenceCohort, EvidenceCohortEntry } from "@selftune/observability/evidence-cohort"; +import { makeLocalDatabaseLive, LocalDatabaseService } from "@selftune/local-store"; import { LocalTraceImporter } from "@selftune/observability/local-trace-importer"; import { makeLocalTraceImporterLive } from "@selftune/orchestration/sync/local-trace-importer"; -import { maintainUploadArtifacts } from "@selftune/runtime/alpha-upload/prune"; -import { - createCompatibilityExportWorker, - type CompatibilityExportWorker, -} from "@selftune/runtime/alpha-upload/worker"; -import { - CloudEvaluationSubmissionClient, - makeCloudEvaluationSubmissionClientLayer, -} from "@selftune/runtime/evolution/cloud-evaluation-submission-client"; -import { - CloudEvaluationTargetClient, - makeCloudEvaluationTargetClientLayer, -} from "@selftune/runtime/evolution/cloud-evaluation-target-client"; import { createDashboardAuth } from "./dashboard-auth.js"; import { createDashboardEventHub } from "./dashboard-events.js"; @@ -115,12 +95,15 @@ import { dashboardCorsHeaders as corsHeaders } from "./dashboard-http.js"; import { createDashboardSpa } from "./dashboard-spa.js"; import { handleDashboardApplicationRoute } from "./routes/application.js"; import { createDashboardCoreRoutes, type DashboardCoreRouteOverrides } from "./routes/core.js"; -import { createEvaluationDraftSubmissionRoutes } from "./routes/evaluation-draft-submissions.js"; import { CorrectionStudyServiceError, createCorrectionStudyRoutes, + type CorrectionStudyRouteOptions, } from "./routes/correction-studies.js"; -import { createTraceCandidateRoutes } from "./routes/trace-candidates.js"; +import { + createTraceCandidateRoutes, + type TraceCandidateRouteOptions, +} from "./routes/trace-candidates.js"; import { CorrectionStudyServiceFailure, captureExplicitCorrectionStudy, @@ -135,19 +118,21 @@ import { recordLocalCorrectionReviewDecision } from "./correction-review-service import { TraceCandidatePreparation, makeTraceCandidatePreparationLayer, - decodePreparedTraceCandidateDraft, } from "./trace-candidate-service.js"; +import { + TraceCandidateRequest, + TraceCandidatePreparationError, +} from "./trace-candidate-contract.js"; import { HistoricalSkillImprovement, + HistoricalSkillImprovementRequest, + HistoricalSkillImprovementFailure, makeHistoricalSkillImprovementLayer, } from "./historical-skill-improvement-service.js"; -import { makeHostHistoricalSkillReplayExecutorFactory } from "./historical-skill-replay-executor.js"; -import { projectImproveEvaluationSubmission } from "@selftune/runtime/evolution/improve-evaluation-projector"; import { - computeSkillVersionHash, - findInstalledSkillPackages, - getDefaultSkillSearchDirs, -} from "@selftune/runtime/utils/skill-discovery"; + HostHistoricalSkillReplay, + HostHistoricalSkillReplayLive, +} from "./historical-skill-replay-executor.js"; import { createHookRoutes, type HookRunners } from "./routes/hooks.js"; import { createOtlpRoutes, OtlpInvalidPayloadError } from "./routes/otlp.js"; @@ -179,11 +164,6 @@ export interface DashboardServerOptions dashboardHost?: Extract; dashboardOrigin?: string; manageProcessSignals?: boolean; - /** Test seam for the daemon-owned legacy V2 compatibility export worker. */ - compatibilityExportWorkerFactory?: ( - sqlite: Database, - configPath: string, - ) => CompatibilityExportWorker; /** * Managed replay capability supplied by a concrete harness adapter. The * HTTP surface remains fail-closed when no harness owns execution. @@ -195,31 +175,6 @@ interface DashboardSocketData { upstreamUrl?: string; } -const CloudTargetId = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(128), - Schema.isPattern(/^[^\p{Cc}]+$/u), -); -const ExactTargetSelection = Schema.Struct({ - source_id: CloudTargetId, - snapshot_id: CloudTargetId, - skill_id: CloudTargetId, - suite_id: CloudTargetId, - manifest_digest: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)), -}); -const exactTargetSelection = (value: unknown) => { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return Effect.fail(new Error("Select one exact Cloud evaluation target.")); - } - const exactKeys = ["source_id", "snapshot_id", "skill_id", "suite_id", "manifest_digest"]; - if (Object.keys(value).length !== exactKeys.length || !exactKeys.every((key) => key in value)) { - return Effect.fail(new Error("Select one exact Cloud evaluation target.")); - } - return Schema.decodeUnknownEffect(ExactTargetSelection)(value).pipe( - Effect.mapError(() => new Error("Select one exact Cloud evaluation target.")), - ); -}; - function allowedDashboardOrigins( hostname: string, port: number, @@ -246,36 +201,6 @@ function otlpEnabled( ); } -function createLiveCompatibilityExportWorker( - sqlite: Database, - configPath: string, -): CompatibilityExportWorker { - return createCompatibilityExportWorker({ - flush: async ({ signal, batchSize }) => { - const [configModule, credentialModule, exportModule] = await Promise.all([ - import("@selftune/config"), - import("@selftune/runtime/auth/cloud-credential"), - import("@selftune/runtime/alpha-upload/index"), - ]); - const config = configModule.loadConfigSync(configPath); - if (!config?.alpha?.enrolled) { - return { sent: 0, failed: 0, skipped: 0, skipped_unchanged: 0 }; - } - const apiKey = credentialModule.resolveCloudCredential(config, { - configPath, - }); - if (!apiKey) return { sent: 0, failed: 0, skipped: 0, skipped_unchanged: 0 }; - const summary = await exportModule.flushCompatibilityExport(sqlite, { - enrolled: true, - apiKey, - batchSize, - signal, - }); - return { ...summary, skipped_unchanged: 0 }; - }, - }); -} - export async function startDashboardServer(options?: DashboardServerOptions): Promise<{ close: () => Promise; server: ReturnType; @@ -308,16 +233,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr cookieSecure: options?.authCookieSecure, }); const localDatabaseLayer = makeLocalDatabaseLive(storagePaths.localDatabasePath); - const evaluationSubmissionRuntime = ManagedRuntime.make( - makeCloudEvaluationSubmissionClientLayer({ - configPath: storagePaths.configPath, - }), - ); - const evaluationTargetRuntime = ManagedRuntime.make( - makeCloudEvaluationTargetClientLayer({ - configPath: storagePaths.configPath, - }), - ); const operationsRuntime = ManagedRuntime.make( makeDashboardOperationsLayer({ ...options, @@ -326,79 +241,103 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr ); const localDatabase = await operationsRuntime .runPromise(Effect.map(LocalDatabaseService, ({ sqlite }) => sqlite)) - .catch(async (error: unknown) => { - await evaluationSubmissionRuntime.dispose(); - await evaluationTargetRuntime.dispose(); + .catch(async (cause: unknown) => { await operationsRuntime.dispose(); - throw error; + throw cause; }); // Candidate preparation opens an analytical store only for the request. The // OTLP importer may own its own long-lived store; Desktop must not hold a // second independent DuckDB instance for its entire lifetime. - const prepareTraceCandidate = async (input: unknown) => { - const { makeDuckDbNodeApiAnalyticalStoreLive } = - await import("@selftune/observability/duckdb-node-api"); - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const preparation = yield* TraceCandidatePreparation; - return yield* preparation.prepare(input); - }).pipe( - Effect.provide( - Layer.provide( - makeTraceCandidatePreparationLayer({ sqlite: localDatabase }), - makeDuckDbNodeApiAnalyticalStoreLive(storagePaths.localAnalyticsPath), + const traceCandidateOperations = { + prepare: async (input) => { + const request = await Effect.runPromise( + Schema.decodeUnknownEffect(TraceCandidateRequest)(input).pipe( + Effect.mapError( + (error) => new TraceCandidatePreparationError({ message: error.message }), + ), + ), + ); + const { makeDuckDbNodeApiAnalyticalStoreLive } = + await import("@selftune/observability/duckdb-node-api"); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const preparation = yield* TraceCandidatePreparation; + return yield* preparation.prepare(request); + }).pipe( + Effect.provide( + Layer.provide( + makeTraceCandidatePreparationLayer({ sqlite: localDatabase }), + makeDuckDbNodeApiAnalyticalStoreLive(storagePaths.localAnalyticsPath), + ), ), ), ), - ), - ); - }; - const evaluateHistoricalSkill = async (input: unknown) => { - const { makeDuckDbNodeApiAnalyticalStoreLive } = - await import("@selftune/observability/duckdb-node-api"); - const preparationLayer = Layer.provide( - makeTraceCandidatePreparationLayer({ sqlite: localDatabase }), - makeDuckDbNodeApiAnalyticalStoreLive(storagePaths.localAnalyticsPath), - ); - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const improvement = yield* HistoricalSkillImprovement; - return yield* improvement.evaluate(input); - }).pipe( - Effect.provide( - Layer.provide( - makeHistoricalSkillImprovementLayer({ - sqlite: localDatabase, - ...(options?.historicalReplayExecutor - ? { executor: options.historicalReplayExecutor } - : { executorFactory: makeHostHistoricalSkillReplayExecutorFactory() }), + ); + }, + evaluate: async (input) => { + const request = await Effect.runPromise( + Schema.decodeUnknownEffect(HistoricalSkillImprovementRequest)(input).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "INVALID_REQUEST", + message: error.message, }), - preparationLayer, + ), + ), + ); + const { makeDuckDbNodeApiAnalyticalStoreLive } = + await import("@selftune/observability/duckdb-node-api"); + const replayFactory = await Effect.runPromise( + Effect.map(HostHistoricalSkillReplay, (service) => service).pipe( + Effect.provide(HostHistoricalSkillReplayLive), + ), + ); + const preparationLayer = Layer.provide( + makeTraceCandidatePreparationLayer({ sqlite: localDatabase }), + makeDuckDbNodeApiAnalyticalStoreLive(storagePaths.localAnalyticsPath), + ); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const improvement = yield* HistoricalSkillImprovement; + return yield* improvement.evaluate(request); + }).pipe( + Effect.provide( + Layer.provide( + makeHistoricalSkillImprovementLayer({ + sqlite: localDatabase, + ...(options?.historicalReplayExecutor + ? { executor: options.historicalReplayExecutor } + : { executorFactory: replayFactory }), + }), + preparationLayer, + ), ), ), ), - ), - ); - }; - const correctionStudyRouteError = (error: unknown): CorrectionStudyServiceError => - error instanceof CorrectionStudyServiceFailure - ? new CorrectionStudyServiceError(error.code, error.message, error.status) + ); + }, + } satisfies TraceCandidateRouteOptions; + const correctionStudyRouteError = (cause: unknown): CorrectionStudyServiceError => + cause instanceof CorrectionStudyServiceFailure + ? new CorrectionStudyServiceError(cause.code, cause.message, cause.status) : new CorrectionStudyServiceError( "CORRECTION_STUDY_PERSISTENCE_FAILED", - error instanceof Error ? error.message : "Correction study operation failed.", + cause instanceof Error ? cause.message : "Correction study operation failed.", 503, ); - const captureExplicitCorrection = async (input: unknown) => - Effect.runPromise(captureExplicitCorrectionStudy(localDatabase, input)).catch( - (error: unknown) => { - throw correctionStudyRouteError(error); - }, - ); + const captureExplicitCorrection: CorrectionStudyRouteOptions["captureExplicitCorrection"] = + async (input) => + Effect.runPromise(captureExplicitCorrectionStudy(localDatabase, input)).catch( + (cause: unknown) => { + throw correctionStudyRouteError(cause); + }, + ); const lookupCorrection = async (episodeId: string) => - Effect.runPromise(lookupCorrectionStudy(localDatabase, episodeId)).catch((error: unknown) => { - throw correctionStudyRouteError(error); + Effect.runPromise(lookupCorrectionStudy(localDatabase, episodeId)).catch((cause: unknown) => { + throw correctionStudyRouteError(cause); }); const discoverCorrectionSignals = async (input: { readonly limit: number; @@ -417,168 +356,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr throw error; } }; - const loadCurrentDraft = async (draftId: string) => { - const draft = await Effect.runPromise(getEvaluationSubmissionDraft(localDatabase, draftId)); - if (!draft) throw new Error("The prepared trace candidate no longer exists."); - if (draft.lifecycle === "submitted" && draft.cloud_run_id) return { draft, payload: null }; - if (draft.lifecycle === "stale") - throw new Error("This candidate is stale because the local skill changed."); - const installed = findInstalledSkillPackages(getDefaultSkillSearchDirs()).find( - (skill) => skill.name === draft.skill_name, - ); - const revision = installed ? computeSkillVersionHash(installed.skill_path) : undefined; - if (revision !== draft.skill_revision) { - await Effect.runPromise( - markEvaluationSubmissionDraftStale(localDatabase, { draft_id: draftId }), - ); - throw new Error("This candidate is stale because the local skill revision changed."); - } - const payload = await Effect.runPromise( - decodePreparedTraceCandidateDraft(JSON.parse(draft.payload_json)), - ); - if (payload.candidate === null) { - throw new Error("This search receipt has no selected candidate to submit."); - } - return { draft, payload }; - }; - const discoverDraftTargets = async (draftId: string) => { - const loaded = await loadCurrentDraft(draftId); - if (loaded.draft.lifecycle === "submitted") { - return { - draft_id: draftId, - lifecycle: "submitted" as const, - run_id: loaded.draft.cloud_run_id, - targets: [], - blockers: [], - }; - } - const payload = loaded.payload; - if (!payload) throw new Error("The prepared trace candidate is unavailable."); - const candidate = payload.candidate; - if (candidate === null) throw new Error("This search receipt has no selected candidate."); - const discovery = await evaluationTargetRuntime.runPromise( - Effect.gen(function* () { - const client = yield* CloudEvaluationTargetClient; - return yield* client.discover({ - skill_name: loaded.draft.skill_name, - skill_revision: loaded.draft.skill_revision, - }); - }), - ); - const targets = discovery.targets.filter( - (target) => - target.lane === "outcome_task" && - !target.verification_only && - target.min_repetitions <= target.max_repetitions && - target.max_repetitions >= 3 && - target.skill_revision === candidate.target_revision, - ); - return { - draft_id: draftId, - lifecycle: "prepared" as const, - run_id: null, - targets, - blockers: discovery.blockers, - }; - }; - const submitDraftTarget = async (draftId: string, unknownTarget: unknown) => { - const loaded = await loadCurrentDraft(draftId); - if (loaded.draft.lifecycle === "submitted" && loaded.draft.cloud_run_id) { - return { - run_id: loaded.draft.cloud_run_id, - status: "scheduled", - dispatch: "scheduled" as const, - }; - } - if (!loaded.payload) throw new Error("The prepared trace candidate is unavailable."); - if (loaded.payload.schema_version !== 1) { - throw new Error( - "Historical task-quality drafts are local replay artifacts and cannot be submitted as correlated-error Cloud evidence.", - ); - } - const selection = await Effect.runPromise(exactTargetSelection(unknownTarget)); - const discovered = await discoverDraftTargets(draftId); - const target = discovered.targets.find( - (candidate) => - candidate.source_id === selection.source_id && - candidate.snapshot_id === selection.snapshot_id && - candidate.skill_id === selection.skill_id && - candidate.suite_id === selection.suite_id && - candidate.manifest_digest === selection.manifest_digest, - ); - if (!target) throw new Error("The selected Cloud target is no longer eligible."); - const entries = await Effect.runPromise( - Schema.decodeUnknownEffect(Schema.Array(EvidenceCohortEntry))(loaded.payload.cohort.entries), - ); - const cohort = EvidenceCohort.make({ - ...loaded.payload.cohort, - target_skill: { ...loaded.payload.cohort.target_skill, skill_path: "[local-path-redacted]" }, - entries, - }); - const submission = await Effect.runPromise( - projectImproveEvaluationSubmission({ - cohort, - candidate: { - candidate_kind: "existing_skill_body_mutation", - proposal_id: loaded.payload.candidate.proposal_id, - skill_name: cohort.target_skill.skill_name, - skill_path: "[local-path-redacted]", - target_revision: loaded.payload.candidate.target_revision, - cohort_id: cohort.fingerprint, - cohort_fingerprint: cohort.fingerprint, - proposed_body: loaded.payload.candidate.proposed_body, - rationale: loaded.payload.candidate.rationale, - confidence: 0.5, - generator_contract_version: "evidence-body-proposal/v1", - target_section: "local-review", - scope: "section_local", - mutation_operation: "refine", - principle: "Trace-backed review candidate.", - applicability: "The exact installed skill revision.", - failure_mode: "Cloud evaluation required.", - preserved_constraints: [], - superseded_guidance: [], - uncertainty: [], - changed_lines: 0, - }, - resolved_evidence: loaded.payload.resolved_evidence, - cloud_source_id: target.source_id, - cloud_snapshot_id: target.snapshot_id, - cloud_skill_id: target.skill_id, - cloud_eval_suite_id: target.suite_id, - manifest_digest: target.manifest_digest, - lane: target.lane, - max_repetitions: Math.max(3, target.min_repetitions), - }), - ); - const receipt = await evaluationSubmissionRuntime.runPromise( - Effect.gen(function* () { - const client = yield* CloudEvaluationSubmissionClient; - return yield* client.submit(submission); - }), - ); - const persisted = await Effect.runPromise( - markEvaluationSubmissionDraftSubmitted(localDatabase, { - draft_id: draftId, - cloud_run_id: receipt.run_id, - }), - ); - return { - run_id: persisted.cloud_run_id ?? receipt.run_id, - status: receipt.status, - dispatch: "scheduled" as const, - }; - }; - // The V2 compatibility export is intentionally daemon-owned. Local sync only - // stages queue entries; this worker performs credential lookup and HTTP later. - // Self-host and test/dev process modes never start a cloud-export worker. - const compatibilityExportWorker = - runtimeMode === "standalone" && dashboardHost === "local" - ? (options?.compatibilityExportWorkerFactory ?? createLiveCompatibilityExportWorker)( - localDatabase, - storagePaths.configPath, - ) - : undefined; let otlpComposition: | { otlp: typeof import("@selftune/observability/otlp"); @@ -612,13 +389,9 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr }; } } catch (error) { - await evaluationSubmissionRuntime.dispose(); - await evaluationTargetRuntime.dispose(); - await compatibilityExportWorker?.stop(); await operationsRuntime.dispose(); throw error; } - compatibilityExportWorker?.start(); const otlpRoutes = otlpComposition ? createOtlpRoutes(async (signal, encoding, body, abortSignal) => { try { @@ -642,10 +415,7 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr } catch (error) { if ( error instanceof otlpComposition.otlp.OtlpDecodeFailure || - (typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "SchemaError") + Schema.isSchemaError(error) ) { throw new OtlpInvalidPayloadError(); } @@ -682,34 +452,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr : null; backgroundRemoteSyncStartup?.unref(); backgroundRemoteSyncInterval?.unref(); - let backgroundUploadPruneRunning = false; - const runBackgroundUploadPrune = async (): Promise => { - if (backgroundUploadPruneRunning) return; - backgroundUploadPruneRunning = true; - try { - await operationsRuntime.runPromise( - Effect.gen(function* () { - const database = yield* LocalDatabaseService; - return maintainUploadArtifacts(database.sqlite, new Date()); - }), - ); - } catch (error) { - process.stderr.write( - `SelfTune upload artifact pruning failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); - } finally { - backgroundUploadPruneRunning = false; - } - }; - const backgroundUploadPruneStartup = - runtimeMode === "standalone" ? setTimeout(() => void runBackgroundUploadPrune(), 5_000) : null; - const backgroundUploadPruneInterval = - runtimeMode === "standalone" - ? setInterval(() => void runBackgroundUploadPrune(), 24 * 60 * 60 * 1_000) - : null; - backgroundUploadPruneStartup?.unref(); - backgroundUploadPruneInterval?.unref(); - // -- SPA serving ------------------------------------------------------------- if (spa.proxyUrl) { console.log(`SPA proxy enabled at ${spa.proxyUrl.toString()}`); @@ -736,14 +478,7 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr version: spa.version, }); const hookRoutes = createHookRoutes({ runners: options?.hookRunners }); - const traceCandidateRoutes = createTraceCandidateRoutes({ - prepare: prepareTraceCandidate, - evaluate: evaluateHistoricalSkill, - }); - const evaluationDraftSubmissionRoutes = createEvaluationDraftSubmissionRoutes({ - discover: discoverDraftTargets, - submit: submitDraftTarget, - }); + const traceCandidateRoutes = createTraceCandidateRoutes(traceCandidateOperations); const correctionStudyRoutes = createCorrectionStudyRoutes({ captureExplicitCorrection, lookup: lookupCorrection, @@ -758,9 +493,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr disposePromise ??= (async () => { if (backgroundRemoteSyncStartup) clearTimeout(backgroundRemoteSyncStartup); if (backgroundRemoteSyncInterval) clearInterval(backgroundRemoteSyncInterval); - if (backgroundUploadPruneStartup) clearTimeout(backgroundUploadPruneStartup); - if (backgroundUploadPruneInterval) clearInterval(backgroundUploadPruneInterval); - await compatibilityExportWorker?.stop(); eventHub.stop(); for (const upstreamSocket of proxiedSpaSockets.values()) { try { @@ -771,8 +503,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr } proxiedSpaSockets.clear(); await hookRoutes.waitForIdle(); - await evaluationSubmissionRuntime.dispose(); - await evaluationTargetRuntime.dispose(); await operationsRuntime.dispose(); })(); return disposePromise; @@ -864,12 +594,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr const traceCandidateResponse = await traceCandidateRoutes.handle(req, url, allowedOrigins); if (traceCandidateResponse) return traceCandidateResponse; - const evaluationDraftResponse = await evaluationDraftSubmissionRoutes.handle( - req, - url, - allowedOrigins, - ); - if (evaluationDraftResponse) return evaluationDraftResponse; // ---- GET /api/health ---- if (url.pathname === "/api/health" && req.method === "GET") { @@ -914,12 +638,12 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr { status: 409, headers: corsHeaders() }, ); } - const payload: unknown = await req.json().catch(() => null); + const payload = Schema.decodeUnknownOption( + Schema.Struct({ runtime_instance_id: Schema.String }), + )(await req.json().catch(() => null)); if ( - typeof payload !== "object" || - payload === null || - !("runtime_instance_id" in payload) || - payload.runtime_instance_id !== runtimeIdentity.instanceId + Option.isNone(payload) || + payload.value.runtime_instance_id !== runtimeIdentity.instanceId ) { return Response.json( { error: { code: "RUNTIME_INSTANCE_MISMATCH" } }, diff --git a/apps/local/src/dashboard-spa.ts b/apps/local/src/dashboard-spa.ts index 90581edc..6eda6821 100644 --- a/apps/local/src/dashboard-spa.ts +++ b/apps/local/src/dashboard-spa.ts @@ -25,20 +25,22 @@ export interface DashboardSpaOptions { readonly proxyUrl?: string; } -const MIME_TYPES: Readonly> = { - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".ico": "image/x-icon", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".js": "application/javascript; charset=utf-8", - ".json": "application/json", - ".png": "image/png", - ".svg": "image/svg+xml", - ".ttf": "font/ttf", - ".woff": "font/woff", - ".woff2": "font/woff2", -}; +const MIME_TYPES = new Map( + Object.entries({ + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + }), +); function normalizeProxyUrl(rawValue: string | undefined): URL | null { if (!rawValue) return null; @@ -153,7 +155,7 @@ async function serveAsset(directory: string, pathname: string): Promise +type DashboardServerHandle = Pick< + Awaited>, + "port" | "close" >; type DashboardStartOptions = Parameters< typeof import("./dashboard-server.js").startDashboardServer >[0]; type DashboardKillFn = (pid: number, signal?: string | number) => boolean; -type DashboardRuntimeHealth = Partial & { - ok: boolean; - service: string; - pid?: number; -}; +const DashboardRuntimeHealth = Schema.Struct({ + ok: Schema.Literal(true), + service: Schema.Literal("selftune-dashboard"), + pid: optionalEvidence(Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))), + version: optionalEvidence(Schema.String), + process_mode: optionalEvidence(Schema.Literals(["standalone", "dev-server", "test"])), +}); +type DashboardRuntimeHealth = typeof DashboardRuntimeHealth.Type; +const decodeHealth = Schema.decodeUnknownOption(DashboardRuntimeHealth); +const decodePackage = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), +); +type DashboardFetch = (...args: Parameters) => ReturnType; export interface DashboardLaunchOptions { openBrowser: boolean; @@ -53,7 +63,7 @@ export interface DashboardLaunchResult { } export interface DashboardLaunchDependencies { - fetch?: typeof fetch; + fetch?: DashboardFetch; findListeningPids?: (port: number) => number[]; kill?: DashboardKillFn; log?: Pick; @@ -64,7 +74,7 @@ export interface DashboardLaunchDependencies { function getInstalledSelftuneVersion(): string { try { - return JSON.parse(readFileSync(VERSION_PKG_PATH, "utf-8")).version; + return decodePackage(readFileSync(VERSION_PKG_PATH, "utf-8")).version; } catch { return "unknown"; } @@ -91,8 +101,7 @@ function openDashboardUrl(url: string): void { } } -function isAddressInUseError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); +function isAddressInUseMessage(message: string): boolean { return /EADDRINUSE|address already in use|port .* in use|already in use/i.test(message); } @@ -154,7 +163,7 @@ function findListeningPids(port: number): number[] { async function probeDashboardHealth( port: number, - fetchImpl: typeof fetch = globalThis.fetch, + fetchImpl: DashboardFetch = globalThis.fetch, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), HEALTHCHECK_TIMEOUT_MS); @@ -165,11 +174,7 @@ async function probeDashboardHealth( if (!response.ok) { return null; } - const payload = (await response.json()) as Partial; - if (payload.service !== "selftune-dashboard" || payload.ok !== true) { - return null; - } - return payload as DashboardRuntimeHealth; + return Option.getOrNull(decodeHealth(await response.json())); } catch { return null; } finally { @@ -209,7 +214,7 @@ async function stopExistingDashboard( const listeningPids = deps.findListeningPids?.(port) ?? findListeningPids(port); const pids = new Set(); - if (typeof health.pid === "number" && health.pid > 0) { + if (health.pid !== undefined) { pids.add(health.pid); } @@ -379,7 +384,7 @@ async function launchDashboardWithOptions( return { action: "reused", installedVersion, url }; } - if (isAddressInUseError(error)) { + if (isAddressInUseMessage(error instanceof Error ? error.message : String(error))) { throw new CLIError( `Port ${options.port} is already in use.`, "OPERATION_FAILED", diff --git a/apps/local/src/harness-registry.ts b/apps/local/src/harness-registry.ts index 7eebd507..b74886ab 100644 --- a/apps/local/src/harness-registry.ts +++ b/apps/local/src/harness-registry.ts @@ -68,9 +68,7 @@ export function detectLocalHarnessConnections( }); } -export function localHarnessSettingsEnvironment(): { - loadHarnessConnections: () => HarnessConnection[]; -} { +export function localHarnessSettingsEnvironment() { return { loadHarnessConnections: () => detectLocalHarnessConnections() }; } diff --git a/apps/local/src/historical-skill-improve-cli.ts b/apps/local/src/historical-skill-improve-cli.ts index 6c2b12c0..eea56f27 100644 --- a/apps/local/src/historical-skill-improve-cli.ts +++ b/apps/local/src/historical-skill-improve-cli.ts @@ -17,12 +17,13 @@ import { import { historicalRoutingVerifierQualification, historicalTaskQualityVerifierQualification, - makeHostHistoricalTaskCalibrator, - makeHostHistoricalSkillReplayExecutorFactory, + HostHistoricalTaskCalibration, + makeHostHistoricalTaskCalibrationLayer, + HostHistoricalSkillReplay, + HostHistoricalSkillReplayLive, } from "./historical-skill-replay-executor.js"; import { executionPatternIdForSkill, - makeLiveCohortBodyTeacher, makeTraceCandidatePreparationLayer, } from "./trace-candidate-service.js"; @@ -118,26 +119,32 @@ export async function runHistoricalSkillImproveCli( const program = Effect.gen(function* () { const { sqlite } = yield* LocalDatabaseService; + const executorFactory = yield* HostHistoricalSkillReplay; + const historicalTaskCalibrator = taskQualityReplay + ? yield* HostHistoricalTaskCalibration.pipe( + Effect.provide( + makeHostHistoricalTaskCalibrationLayer({ + agent, + model: agent === "codex" ? CODEX_STUDENT_MODEL : "configured-default", + }), + ), + ) + : undefined; const preparationLayer = Layer.provide( makeTraceCandidatePreparationLayer({ sqlite, - teacher: makeLiveCohortBodyTeacher({ agent }), + teacherAgent: agent, searchDirs, studentAgent: agent, studentModel: agent === "codex" ? CODEX_STUDENT_MODEL : undefined, - historicalTaskCalibrator: taskQualityReplay - ? makeHostHistoricalTaskCalibrator({ - agent, - model: agent === "codex" ? CODEX_STUDENT_MODEL : "configured-default", - }) - : undefined, + historicalTaskCalibrator, }), makeDuckDbNodeApiAnalyticalStoreLive(SELFTUNE_LOCAL_ANALYTICS_PATH), ); const improvementLayer = Layer.provide( makeHistoricalSkillImprovementLayer({ sqlite, - executorFactory: makeHostHistoricalSkillReplayExecutorFactory(), + executorFactory, searchDirs, }), preparationLayer, @@ -187,7 +194,14 @@ export async function runHistoricalSkillImproveCli( }); }).pipe(Effect.provide(improvementLayer)), ); - }).pipe(Effect.provide(makeLocalDatabaseLive(SELFTUNE_LOCAL_DATABASE_PATH))); + }).pipe( + Effect.provide( + Layer.merge( + HostHistoricalSkillReplayLive, + makeLocalDatabaseLive(SELFTUNE_LOCAL_DATABASE_PATH), + ), + ), + ); const response = await Effect.runPromise(Effect.scoped(program)); const unsupportedContrast = response.reason.startsWith( diff --git a/apps/local/src/historical-skill-improvement-service.ts b/apps/local/src/historical-skill-improvement-service.ts index 049db3d6..4d6a1126 100644 --- a/apps/local/src/historical-skill-improvement-service.ts +++ b/apps/local/src/historical-skill-improvement-service.ts @@ -13,6 +13,7 @@ import { upsertCorrectionSignalCandidate, } from "@selftune/local-store"; import { replaceBody } from "@selftune/runtime/evolution/deploy-proposal"; +import { optionalEvidence } from "@selftune/runtime/utils/transcript-contract"; import { computeSkillVersionHash, computeSkillVersionHashWithContent, @@ -29,7 +30,8 @@ import { Context, Effect, Layer, Schema } from "effect"; import { ProactiveExecutionControls, - makeLocalStoreProactiveCandidateEvaluationPersistence, + ProactiveEvaluationPersistence, + makeLocalStoreProactiveEvaluationLayer, runProactiveCorrectionE2, } from "./proactive-correction-e2-service.js"; import { @@ -130,7 +132,7 @@ export class HistoricalSkillImprovementFailure extends Schema.TaggedErrorClass Effect.Effect; } @@ -139,11 +141,12 @@ export class HistoricalSkillImprovement extends Context.Service< HistoricalSkillImprovementService >()("@selftune/local/HistoricalSkillImprovement") {} -interface RegressionRow { - readonly case_id: string; - readonly manifest_json: string; - readonly verifier_payload_json: string; -} +const RegressionRow = Schema.Struct({ + case_id: Schema.String, + manifest_json: Schema.String, + verifier_payload_json: Schema.String, +}); +type RegressionRow = typeof RegressionRow.Type; interface RegressionProjection { readonly benchmarkCase: typeof BlindBenchmarkCase.Type; @@ -184,13 +187,27 @@ function caseId(role: string, source: { readonly skill_invocation_id: string }): return stableId("historical-case", `${role}\u0000${source.skill_invocation_id}`); } -function parseJson(value: string, label: string): Record { +const RegressionVerifier = Schema.Struct({ + instrument: optionalEvidence( + Schema.Struct({ + verifier_id: optionalEvidence(Schema.String), + version: optionalEvidence(Schema.String), + }), + ), +}); +const RegressionManifest = Schema.Struct({ + task_case: optionalEvidence( + Schema.Struct({ + task_payload: optionalEvidence(Schema.String), + task_fingerprint: optionalEvidence(Schema.String), + }), + ), + episode: optionalEvidence(Schema.Struct({ task: optionalEvidence(Schema.String) })), +}); + +function decodeRegressionArtifact(schema: Schema.Codec, value: string, label: string): A { try { - const parsed: unknown = JSON.parse(value); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new TypeError(`${label} must be a JSON object.`); - } - return parsed as Record; + return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(value); } catch (error) { throw new HistoricalSkillImprovementFailure({ code: "INVALID_EVIDENCE", @@ -199,19 +216,17 @@ function parseJson(value: string, label: string): Record { } } -function nestedRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : null; -} - function regressionProjection( row: RegressionRow, expectedVerifier: typeof VerifierQualificationResult.Type, owningSkillId: string, ): RegressionProjection { - const verifier = parseJson(row.verifier_payload_json, `Verifier for ${row.case_id}`); - const instrument = nestedRecord(verifier.instrument); + const verifier = decodeRegressionArtifact( + RegressionVerifier, + row.verifier_payload_json, + `Verifier for ${row.case_id}`, + ); + const instrument = verifier.instrument; if ( instrument?.verifier_id !== expectedVerifier.instrument.verifier_id || instrument.version !== expectedVerifier.instrument.version @@ -221,25 +236,20 @@ function regressionProjection( message: `Active regression ${row.case_id} requires a different verifier.`, }); } - const manifest = parseJson(row.manifest_json, `Manifest for ${row.case_id}`); - const taskCase = nestedRecord(manifest.task_case); - const episode = nestedRecord(manifest.episode); - const taskPayload = - typeof taskCase?.task_payload === "string" - ? taskCase.task_payload - : typeof episode?.task === "string" - ? episode.task - : null; + const manifest = decodeRegressionArtifact( + RegressionManifest, + row.manifest_json, + `Manifest for ${row.case_id}`, + ); + const taskCase = manifest.task_case; + const taskPayload = taskCase?.task_payload ?? manifest.episode?.task; if (!taskPayload || taskPayload.length > 8_000) { throw new HistoricalSkillImprovementFailure({ code: "INVALID_EVIDENCE", message: `Active regression ${row.case_id} has no bounded replay task.`, }); } - const taskFingerprint = - typeof taskCase?.task_fingerprint === "string" - ? taskCase.task_fingerprint - : digest(taskPayload); + const taskFingerprint = taskCase?.task_fingerprint ?? digest(taskPayload); const benchmarkCase = BlindBenchmarkCase.make({ case_id: row.case_id, task_payload: taskPayload, @@ -263,14 +273,14 @@ function emptyCounts() { } function historicalFailure( - error: unknown, + cause: unknown, code: HistoricalSkillImprovementFailure["code"] = "INVALID_EVIDENCE", ): HistoricalSkillImprovementFailure { - return error instanceof HistoricalSkillImprovementFailure - ? error + return cause instanceof HistoricalSkillImprovementFailure + ? cause : new HistoricalSkillImprovementFailure({ code, - message: error instanceof Error ? error.message : String(error), + message: cause instanceof Error ? cause.message : String(cause), }); } @@ -300,11 +310,12 @@ export function makeHistoricalSkillImprovementLayer(options: { HistoricalSkillImprovement, Effect.gen(function* () { const preparation = yield* TraceCandidatePreparation; + const persistence = yield* ProactiveEvaluationPersistence; const evaluate = Effect.fn("HistoricalSkillImprovement.evaluate")(function* ( - unknownInput: unknown, + request: HistoricalSkillImprovementRequest, ) { const input = yield* Schema.decodeUnknownEffect(HistoricalSkillImprovementRequest)( - unknownInput, + request, ).pipe( Effect.mapError( (error) => @@ -471,7 +482,9 @@ export function makeHistoricalSkillImprovementLayer(options: { const owningSkillId = skillId(draft.cohort.target_skill.skill_name); const regressionRows = yield* Effect.try({ try: () => - listActivePromotedStudyCases(options.sqlite, owningSkillId, 50) as RegressionRow[], + Schema.decodeUnknownSync(Schema.Array(RegressionRow))( + listActivePromotedStudyCases(options.sqlite, owningSkillId, 50), + ), catch: (error) => new HistoricalSkillImprovementFailure({ code: "PERSISTENCE_FAILED", @@ -621,7 +634,7 @@ export function makeHistoricalSkillImprovementLayer(options: { recorded_at: input.recorded_at, }, executor, - makeLocalStoreProactiveCandidateEvaluationPersistence(options.sqlite), + persistence, ).pipe( Effect.mapError( (error) => @@ -682,5 +695,5 @@ export function makeHistoricalSkillImprovementLayer(options: { }); return HistoricalSkillImprovement.of({ evaluate }); }), - ); + ).pipe(Layer.provide(makeLocalStoreProactiveEvaluationLayer(options.sqlite))); } diff --git a/apps/local/src/historical-skill-replay-executor.ts b/apps/local/src/historical-skill-replay-executor.ts index ad3cbdb2..1f67b2ab 100644 --- a/apps/local/src/historical-skill-replay-executor.ts +++ b/apps/local/src/historical-skill-replay-executor.ts @@ -14,7 +14,7 @@ import { runHostRuntimeReplayFixture, } from "@selftune/runtime/evolution/validate-host-replay"; import type { RoutingReplayEntryResult } from "@selftune/runtime/types"; -import { Effect, Schema } from "effect"; +import { Context, Effect, Layer, Schema } from "effect"; import type { HistoricalTaskCalibrator } from "./historical-task-candidate.js"; @@ -76,7 +76,7 @@ function runtimeAgent(harness: string): string | null { } function boundedCount(value: number | null | undefined): number { - return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; + return value != null && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; } function processMetrics(result: RoutingReplayEntryResult) { @@ -94,8 +94,8 @@ function processMetrics(result: RoutingReplayEntryResult) { }; } -function retryableReplayFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); +function retryableReplayFailure(cause: unknown): boolean { + const message = cause instanceof Error ? cause.message : String(cause); return /(?:timed? ?out|temporar|rate limit|connection|socket|exited with code)/i.test(message); } @@ -169,16 +169,18 @@ export function historicalRoutingVerifierQualification(): VerifierQualificationR check_description: "Stages one frozen skill arm and checks the harness routing events without an LLM judge.", }, - evidence: [ - { id: "known-failure", label: "known_failure", expected: "reject", observed: "reject" }, - { id: "known-good", label: "known_good", expected: "accept", observed: "accept" }, - { id: "boundary", label: "boundary", expected: "reject", observed: "reject" }, - { id: "adversarial", label: "adversarial", expected: "reject", observed: "reject" }, - ].map((control) => ({ + evidence: ( + [ + { id: "known-failure", label: "known_failure", expected: "reject", observed: "reject" }, + { id: "known-good", label: "known_good", expected: "accept", observed: "accept" }, + { id: "boundary", label: "boundary", expected: "reject", observed: "reject" }, + { id: "adversarial", label: "adversarial", expected: "reject", observed: "reject" }, + ] as const + ).map((control) => ({ evidence_id: `historical-routing-${control.id}`, - label: control.label as "known_failure" | "known_good" | "boundary" | "adversarial", - expected_decision: control.expected as "accept" | "reject", - observed_decision: control.observed as "accept" | "reject", + label: control.label, + expected_decision: control.expected, + observed_decision: control.observed, partition: "verifier_calibration" as const, candidate_strategy_reference: null, })), @@ -236,7 +238,7 @@ export function historicalTaskQualityVerifierQualification(): VerifierQualificat }); } -export function makeHostHistoricalTaskCalibrator(options: { +function makeHostHistoricalTaskCalibrator(options: { readonly agent: string; readonly model: string; }): HistoricalTaskCalibrator { @@ -424,3 +426,24 @@ export function makeHostHistoricalSkillReplayExecutorFactory(options?: { return { create }; } + +export class HostHistoricalSkillReplay extends Context.Service< + HostHistoricalSkillReplay, + HistoricalSkillReplayExecutorFactory +>()("@selftune/local/HostHistoricalSkillReplay") {} + +export const HostHistoricalSkillReplayLive = Layer.sync(HostHistoricalSkillReplay, () => + makeHostHistoricalSkillReplayExecutorFactory(), +); + +export class HostHistoricalTaskCalibration extends Context.Service< + HostHistoricalTaskCalibration, + HistoricalTaskCalibrator +>()("@selftune/local/HostHistoricalTaskCalibration") {} + +export function makeHostHistoricalTaskCalibrationLayer(options: { + readonly agent: string; + readonly model: string; +}) { + return Layer.sync(HostHistoricalTaskCalibration, () => makeHostHistoricalTaskCalibrator(options)); +} diff --git a/apps/local/src/historical-task-candidate.ts b/apps/local/src/historical-task-candidate.ts index 8692005b..77c7385a 100644 --- a/apps/local/src/historical-task-candidate.ts +++ b/apps/local/src/historical-task-candidate.ts @@ -172,14 +172,14 @@ function historicalTaskWithContext( if (!current) return null; if (row.matched_prompt_index === null) return current; const previous = sqlite - .query( + .query<{ prompt_text: string | null }, [string, number]>( `SELECT prompt_text FROM prompts WHERE session_id = ? AND prompt_kind = 'user' AND prompt_index < ? ORDER BY prompt_index DESC LIMIT 8`, ) - .all(row.session_id, row.matched_prompt_index) as Array<{ prompt_text: string | null }>; + .all(row.session_id, row.matched_prompt_index); const seen = new Set([current]); const context = previous .flatMap((entry) => { @@ -210,8 +210,8 @@ export const prepareHistoricalTaskCandidate = Effect.fn( const rows = ids.length === 0 ? [] - : (options.sqlite - .query( + : options.sqlite + .query( `SELECT invocation.skill_invocation_id, invocation.session_id, @@ -229,7 +229,7 @@ export const prepareHistoricalTaskCandidate = Effect.fn( LEFT JOIN prompts prompt ON prompt.prompt_id = invocation.matched_prompt_id WHERE invocation.skill_invocation_id IN (${placeholders})`, ) - .all(...ids) as HistoricalInvocationRow[]); + .all(...ids); const packageMtimeMs = yield* Effect.try({ try: () => latestPackageMtimeMs(options.installed.package_path), catch: (error) => diff --git a/apps/local/src/hosted-state.ts b/apps/local/src/hosted-state.ts index d9733f0f..83693fd8 100644 --- a/apps/local/src/hosted-state.ts +++ b/apps/local/src/hosted-state.ts @@ -2,6 +2,9 @@ import { createHash } from "node:crypto"; import { hostname, platform } from "node:os"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import { DashboardLibraryService } from "./library-report.js"; import type { LibrarySnapshot } from "@selftune/runtime/dashboard-contract"; import { exportPortableSkillSetPackBytes } from "@selftune/library"; @@ -130,7 +133,7 @@ function manifestRevision(skills: ReturnType) { } export interface HostedStateOptions { - readonly fetch?: typeof fetch; + readonly fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; readonly deviceName?: () => string; readonly platform?: () => string; readonly loadConfig?: typeof loadRemoteLibraryConfig; @@ -149,6 +152,20 @@ export function isSelfTuneCloudUrl(input: string): boolean { } /** The only Desktop boundary allowed to report local state to SelfTune Cloud. */ +export class HostedStateService extends Context.Service< + HostedStateService, + ReturnType +>()("SelfTune/HostedState") {} + +export function makeHostedStateLayer(configRoot: string) { + return Layer.effect(HostedStateService)( + Effect.gen(function* () { + const library = yield* DashboardLibraryService; + return makeHostedStateOperations(configRoot, library.load); + }), + ); +} + export function makeHostedStateOperations( configRoot: string, loadLibrary: () => LibrarySnapshot | Promise, diff --git a/apps/local/src/library-report.ts b/apps/local/src/library-report.ts index 7be8c37e..fa15b11d 100644 --- a/apps/local/src/library-report.ts +++ b/apps/local/src/library-report.ts @@ -1,18 +1,51 @@ import type { LibrarySnapshot } from "@selftune/runtime/dashboard-contract"; -import { type ControlPlaneRuntime } from "@selftune/runtime/control-plane-runtime"; -import { loadLibraryCatalog } from "@selftune/runtime/library-catalog"; +import { + createControlPlaneRuntime, + type ControlPlaneRuntime, +} from "@selftune/runtime/control-plane-runtime"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { loadLibraryCatalog, type LibraryCatalogOptions } from "@selftune/runtime/library-catalog"; import { resolveInstalledSkillMetadata } from "@selftune/runtime/skill-source-metadata"; import { findInstalledSkillPackages, getDefaultSkillSearchDirs, } from "@selftune/runtime/utils/skill-discovery"; +export class DashboardLibraryService extends Context.Service< + DashboardLibraryService, + { + readonly load: () => LibrarySnapshot | Promise; + } +>()("SelfTune/DashboardLibrary") {} + +export function makeDashboardLibraryLayer( + configRoot: string | undefined, + loader?: () => LibrarySnapshot | Promise, +) { + return Layer.effect(DashboardLibraryService)( + Effect.gen(function* () { + const controlPlane = yield* Effect.acquireRelease( + Effect.sync(createControlPlaneRuntime), + (runtime) => Effect.promise(() => runtime.dispose()), + ); + return { load: loader ?? makeLibraryReportLoader(configRoot, controlPlane) }; + }), + ); +} + export function loadLibraryReport( configRoot: string | undefined, controlPlane: ControlPlaneRuntime, + options: Pick< + LibraryCatalogOptions, + "searchDirs" | "quarantineRoot" | "usageRows" | "workspacePaths" + > = {}, ): Promise { return loadLibraryCatalog( { + ...options, skillSetConfigRoot: configRoot, sourceMetadata: { updateMode: "cache-first" }, }, diff --git a/apps/local/src/local-runtime.ts b/apps/local/src/local-runtime.ts index 01a47ee4..2ddadec2 100644 --- a/apps/local/src/local-runtime.ts +++ b/apps/local/src/local-runtime.ts @@ -78,10 +78,11 @@ export interface RuntimeLock { readonly stop: () => Promise; } -interface AuthRecord { - readonly version: 1; - readonly token: string; -} +const AuthRecord = Schema.Struct({ + version: Schema.Literal(1), + token: Schema.String.check(Schema.isMinLength(32)), +}); +type AuthRecord = typeof AuthRecord.Type; export interface LocalAuthTokenDependencies { readonly beforeCommit?: () => void; @@ -116,21 +117,13 @@ function readAuthRecord(configDir: string): AuthRecord | null { const path = localAuthPath(configDir); if (!existsSync(path)) return null; try { - const value: unknown = JSON.parse(readFileSync(path, "utf8")); - if ( - typeof value === "object" && - value !== null && - "version" in value && - value.version === 1 && - "token" in value && - typeof value.token === "string" && - value.token.length >= 32 - ) { - chmodSync(path, 0o600); - return { version: 1, token: value.token }; - } + const record = Schema.decodeUnknownSync(Schema.fromJsonString(AuthRecord))( + readFileSync(path, "utf8"), + ); + chmodSync(path, 0o600); + return record; } catch { - // A malformed owner-only token is replaced below. + // The installation path refuses to overwrite an existing invalid token. } return null; } @@ -150,7 +143,7 @@ function writeOwnerOnlyFile(path: string, contents: string): void { } function isAlreadyExistsError(cause: unknown): boolean { - return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EEXIST"; + return Schema.is(Schema.Struct({ code: Schema.Literal("EEXIST") }))(cause); } function installAuthRecordCandidate( @@ -343,7 +336,7 @@ export function removeDaemonManifestIfOwned( } function isMissingFileError(cause: unknown): boolean { - return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"; + return Schema.is(Schema.Struct({ code: Schema.Literal("ENOENT") }))(cause); } export function isProcessAlive(pid: number): boolean { diff --git a/apps/local/src/operation-cache.ts b/apps/local/src/operation-cache.ts index 0ca22013..d073950f 100644 --- a/apps/local/src/operation-cache.ts +++ b/apps/local/src/operation-cache.ts @@ -4,6 +4,9 @@ import { dirname } from "node:path"; import * as Effect from "effect/Effect"; import * as Latch from "effect/Latch"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import type * as Scope from "effect/Scope"; import { getDb } from "@selftune/local-store"; @@ -14,13 +17,12 @@ import { getDb } from "@selftune/local-store"; function localDatabaseVersion(): string { try { const db = getDb(); - const dataVersion = (db.query("PRAGMA data_version").get() as { data_version?: number } | null) - ?.data_version; - const totalChanges = ( - db.query("SELECT total_changes() AS total_changes").get() as { - total_changes?: number; - } | null - )?.total_changes; + const dataVersion = db + .query<{ data_version: number }, []>("PRAGMA data_version") + .get()?.data_version; + const totalChanges = db + .query<{ total_changes: number }, []>("SELECT total_changes() AS total_changes") + .get()?.total_changes; return `${dataVersion ?? 0}:${totalChanges ?? 0}`; } catch { return `unversioned:${Date.now()}`; @@ -32,9 +34,15 @@ export interface CachedOperation { readonly invalidate: Effect.Effect; } -export interface MaterializedCacheOptions { - /** JSON envelope persisted across daemon restarts so boot serves instantly. */ - readonly artifactPath?: string; +export function makeMaterializedCacheLayer( + key: Context.Service>, + compute: Effect.Effect, + options: MaterializedCacheOptions = {}, +) { + return Layer.effect(key)(makeMaterializedCache(compute, options)); +} + +interface CacheRefreshOptions { readonly readVersion?: () => string; /** Cadence of the background refresh loop (also the version-check cadence). */ readonly refreshIntervalMs?: number; @@ -42,19 +50,32 @@ export interface MaterializedCacheOptions { readonly refreshTtlMs?: number; } +export type MaterializedCacheOptions = CacheRefreshOptions & + ( + | { readonly artifactPath?: undefined } + | { readonly artifactPath: string; readonly schema: Schema.Codec } + ); + interface Artifact { readonly schema_version: 1; readonly generated_at: string; readonly data: A; } -function readArtifact(path: string | undefined): { readonly value: A } | null { - if (!path) return null; +function readArtifact(options: MaterializedCacheOptions): { readonly value: A } | null { + if (options.artifactPath === undefined) return null; try { - const envelope = JSON.parse(readFileSync(path, "utf8")) as Partial> | null; - if (envelope?.schema_version === 1 && envelope.data !== undefined) { - return { value: envelope.data }; - } + const schema = Schema.fromJsonString( + Schema.Struct({ + schema_version: Schema.Literal(1), + generated_at: Schema.String, + data: options.schema, + }), + ); + const envelope = Schema.decodeUnknownSync(schema)(readFileSync(options.artifactPath, "utf8"), { + onExcessProperty: "preserve", + }); + return { value: envelope.data }; } catch { // Missing or corrupt artifact — recomputed by the refresh loop. } @@ -90,13 +111,13 @@ function writeArtifact(path: string | undefined, data: A): void { // background refresh keeps the previous value. export function makeMaterializedCache( compute: Effect.Effect, - options: MaterializedCacheOptions = {}, + options: MaterializedCacheOptions = {}, ): Effect.Effect, never, Scope.Scope | R> { const readVersion = options.readVersion ?? localDatabaseVersion; const refreshIntervalMs = options.refreshIntervalMs ?? 60_000; const refreshTtlMs = options.refreshTtlMs ?? 5 * 60 * 1_000; return Effect.gen(function* () { - let latest = readArtifact(options.artifactPath); + let latest = readArtifact(options); let version: string | null = null; let refreshedAt = 0; let pendingRefreshGeneration = 0; diff --git a/apps/local/src/plugin-host-contract.ts b/apps/local/src/plugin-host-contract.ts new file mode 100644 index 00000000..64c70ad1 --- /dev/null +++ b/apps/local/src/plugin-host-contract.ts @@ -0,0 +1,17 @@ +import * as Schema from "effect/Schema"; +import { optionalEvidence } from "@selftune/runtime/utils/transcript-contract"; + +const Text = optionalEvidence(Schema.String); +export const ClaudePlugin = Schema.Struct({ + id: Schema.NonEmptyString, + version: Text, + scope: Text, + enabled: optionalEvidence(Schema.Boolean), +}); +export const CodexPlugin = Schema.Struct({ + pluginId: Schema.NonEmptyString, + version: Text, + marketplaceName: Text, + enabled: optionalEvidence(Schema.Boolean), + source: optionalEvidence(Schema.Struct({ source: Text })), +}); diff --git a/apps/local/src/plugin-inventory.ts b/apps/local/src/plugin-inventory.ts index ea46ee48..1724dd56 100644 --- a/apps/local/src/plugin-inventory.ts +++ b/apps/local/src/plugin-inventory.ts @@ -1,10 +1,11 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import type { PluginHostInstallationModel, PluginHostModel, - PluginHostStatusModel, PluginInventoryItemModel, PluginInventoryModel, PluginManagementActionModel, @@ -12,6 +13,7 @@ import type { PluginManagementReceiptModel, } from "@selftune/dashboard-core/models"; import { SELFTUNE_CONFIG_DIR } from "@selftune/runtime/constants"; +import { ClaudePlugin, CodexPlugin } from "./plugin-host-contract.js"; export interface PluginInventoryCommandResult { readonly exitCode: number; @@ -38,27 +40,12 @@ const defaultRuntime: PluginInventoryRuntime = { now: () => new Date(), }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } -} - -function stringField(value: unknown, key: string): string | null { - return isRecord(value) && typeof value[key] === "string" ? value[key] : null; -} - -function booleanField(value: unknown, key: string, fallback: boolean): boolean { - return isRecord(value) && typeof value[key] === "boolean" ? value[key] : fallback; -} +const ClaudeInventory = Schema.Array(Schema.Json); +const CodexInventory = Schema.Struct({ installed: Schema.Array(Schema.Json) }); +const InstallReceipt = Schema.Struct({ hosts: Schema.Array(Schema.Json) }); +const ReceiptHost = Schema.Struct({ pluginId: Schema.String }); -function pluginIdentity(pluginId: string): { name: string; marketplaceName: string } { +function pluginIdentity(pluginId: string) { const separator = pluginId.lastIndexOf("@"); if (separator <= 0 || separator === pluginId.length - 1) { return { name: pluginId, marketplaceName: "unknown" }; @@ -76,10 +63,13 @@ function receiptPluginIds(configRoot: string): ReadonlySet { for (const entry of readdirSync(receiptsRoot, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - const decoded = parseJson(readFileSync(join(receiptsRoot, entry.name), "utf8")); - if (!isRecord(decoded) || !Array.isArray(decoded.hosts)) continue; - for (const host of decoded.hosts) { - const pluginId = stringField(host, "pluginId"); + const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(InstallReceipt))( + readFileSync(join(receiptsRoot, entry.name), "utf8"), + ); + if (Option.isNone(decoded)) continue; + for (const host of decoded.value.hosts) { + const value = Schema.decodeUnknownOption(ReceiptHost)(host); + const pluginId = Option.getOrUndefined(value)?.pluginId; if (pluginId) pluginIds.add(pluginId); } } @@ -95,22 +85,23 @@ function claudeActions(enabled: boolean, scope: string | null): PluginManagement } function claudeInstallations( - value: unknown, + value: typeof ClaudeInventory.Type, managedPluginIds: ReadonlySet, ): PluginHostInstallationModel[] { - if (!Array.isArray(value)) return []; return value.flatMap((entry) => { - const pluginId = stringField(entry, "id"); - if (!pluginId) return []; + const decoded = Schema.decodeUnknownOption(ClaudePlugin)(entry); + if (Option.isNone(decoded)) return []; + const plugin = decoded.value; + const pluginId = plugin.id; const identity = pluginIdentity(pluginId); - const scope = stringField(entry, "scope"); - const enabled = booleanField(entry, "enabled", true); + const scope = plugin.scope ?? null; + const enabled = plugin.enabled ?? true; return [ { host: "claude", hostLabel: "Claude", pluginId, - version: stringField(entry, "version"), + version: plugin.version ?? null, enabled, scope, sourceType: scope === "managed" ? "managed" : "marketplace", @@ -122,30 +113,33 @@ function claudeInstallations( }); } -function codexSourceType(value: unknown): PluginHostInstallationModel["sourceType"] { - if (!isRecord(value)) return "unknown"; - return stringField(value, "source") === "local" ? "local" : "marketplace"; +function codexSourceType( + value: (typeof CodexPlugin.Type)["source"], +): PluginHostInstallationModel["sourceType"] { + if (!value) return "unknown"; + return value.source === "local" ? "local" : "marketplace"; } function codexInstallations( - value: unknown, + value: typeof CodexInventory.Type, managedPluginIds: ReadonlySet, ): PluginHostInstallationModel[] { - if (!isRecord(value) || !Array.isArray(value.installed)) return []; return value.installed.flatMap((entry) => { - const pluginId = stringField(entry, "pluginId"); - if (!pluginId) return []; + const decoded = Schema.decodeUnknownOption(CodexPlugin)(entry); + if (Option.isNone(decoded)) return []; + const plugin = decoded.value; + const pluginId = plugin.pluginId; const identity = pluginIdentity(pluginId); return [ { host: "codex", hostLabel: "Codex", pluginId, - version: stringField(entry, "version"), - enabled: booleanField(entry, "enabled", true), + version: plugin.version ?? null, + enabled: plugin.enabled ?? true, scope: null, - sourceType: codexSourceType(isRecord(entry) ? entry.source : null), - sourceLabel: stringField(entry, "marketplaceName") ?? identity.marketplaceName, + sourceType: codexSourceType(plugin.source), + sourceLabel: plugin.marketplaceName ?? identity.marketplaceName, managedBySelfTune: managedPluginIds.has(pluginId), availableActions: ["remove"], }, @@ -157,7 +151,7 @@ function inspectHost( host: PluginHostModel, runtime: PluginInventoryRuntime, managedPluginIds: ReadonlySet, -): { status: PluginHostStatusModel; installations: PluginHostInstallationModel[] } { +) { const label = host === "claude" ? "Claude" : "Codex"; const executable = runtime.which(host); if (!executable) { @@ -165,7 +159,7 @@ function inspectHost( status: { host, label, - status: "unavailable", + status: "unavailable" as const, installedCount: 0, message: `${label} is not installed on this machine.`, }, @@ -180,7 +174,7 @@ function inspectHost( status: { host, label, - status: "error", + status: "error" as const, installedCount: 0, message: detail.slice(0, 500) || `${label} did not return its plugin inventory.`, }, @@ -188,28 +182,32 @@ function inspectHost( }; } - const decoded = parseJson(result.stdout); - if (decoded === null) { + const decoded = + host === "claude" + ? Schema.decodeUnknownOption(Schema.fromJsonString(ClaudeInventory))(result.stdout).pipe( + Option.map((value) => claudeInstallations(value, managedPluginIds)), + ) + : Schema.decodeUnknownOption(Schema.fromJsonString(CodexInventory))(result.stdout).pipe( + Option.map((value) => codexInstallations(value, managedPluginIds)), + ); + if (Option.isNone(decoded)) { return { status: { host, label, - status: "error", + status: "error" as const, installedCount: 0, message: `${label} returned an invalid plugin inventory.`, }, installations: [], }; } - const installations = - host === "claude" - ? claudeInstallations(decoded, managedPluginIds) - : codexInstallations(decoded, managedPluginIds); + const installations = decoded.value; return { status: { host, label, - status: "available", + status: "available" as const, installedCount: installations.length, message: null, }, diff --git a/apps/local/src/prepared-trace-candidate-draft.ts b/apps/local/src/prepared-trace-candidate-draft.ts index fa478642..c16fe192 100644 --- a/apps/local/src/prepared-trace-candidate-draft.ts +++ b/apps/local/src/prepared-trace-candidate-draft.ts @@ -112,5 +112,6 @@ const preparedCandidateDraftSchema = Schema.Union([ export type PreparedTraceCandidateDraft = typeof preparedCandidateDraftSchema.Type; -export const decodePreparedTraceCandidateDraft = (value: unknown) => - Schema.decodeUnknownEffect(preparedCandidateDraftSchema)(value); +export const decodePreparedTraceCandidateDraft = Schema.decodeUnknownEffect( + preparedCandidateDraftSchema, +); diff --git a/apps/local/src/proactive-correction-e2-service.ts b/apps/local/src/proactive-correction-e2-service.ts index fb0d3201..0f8de997 100644 --- a/apps/local/src/proactive-correction-e2-service.ts +++ b/apps/local/src/proactive-correction-e2-service.ts @@ -10,6 +10,8 @@ import { import { createOrGetCorrectionCandidateEvaluation } from "@selftune/local-store"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import { changedLineCount } from "./historical-evidence-safety.js"; @@ -119,6 +121,17 @@ export interface ProactiveCandidateEvaluationPersistence { >; } +export class ProactiveEvaluationPersistence extends Context.Service< + ProactiveEvaluationPersistence, + ProactiveCandidateEvaluationPersistence +>()("SelfTune/ProactiveEvaluationPersistence") {} + +export function makeLocalStoreProactiveEvaluationLayer(database: Database) { + return Layer.sync(ProactiveEvaluationPersistence)(() => + makeLocalStoreProactiveCandidateEvaluationPersistence(database), + ); +} + /** * Adapter for the append-only local-store API. The database table keeps its * lifecycle vocabulary intentionally small; blocked requests are immutable @@ -266,11 +279,11 @@ function record( } export const runProactiveCorrectionE2 = Effect.fn("ProactiveCorrectionE2.run")(function* ( - unknownInput: unknown, + request: ProactiveCorrectionE2Request, executor: BlindBenchmarkExecutor, persistence: ProactiveCandidateEvaluationPersistence, ) { - const input = yield* Schema.decodeUnknownEffect(ProactiveCorrectionE2Request)(unknownInput).pipe( + const input = yield* Schema.decodeUnknownEffect(ProactiveCorrectionE2Request)(request).pipe( Effect.mapError( (error) => new ProactiveCorrectionE2Failure({ code: "INVALID_REQUEST", message: error.message }), diff --git a/apps/local/src/remote-library-operations.ts b/apps/local/src/remote-library-operations.ts index a5a18f26..2897d538 100644 --- a/apps/local/src/remote-library-operations.ts +++ b/apps/local/src/remote-library-operations.ts @@ -1,5 +1,7 @@ import { dirname, join, resolve } from "node:path"; import { createHash } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import type { CreateRemoteLibraryShareRequest } from "@selftune/runtime/dashboard-contract"; import { createRemoteLibraryHandle } from "@selftune/library/remote/transport"; @@ -63,6 +65,15 @@ export type RemoteWorkspaceInput = reason?: string | null; }; +export class RemoteLibraryService extends Context.Service< + RemoteLibraryService, + ReturnType +>()("SelfTune/RemoteLibrary") {} + +export function makeRemoteLibraryLayer(configRoot: string) { + return Layer.sync(RemoteLibraryService)(() => makeRemoteLibraryOperations(configRoot)); +} + export function makeRemoteLibraryOperations(configRootInput: string) { const configRoot = resolve(configRootInput); diff --git a/apps/local/src/report-builders.ts b/apps/local/src/report-builders.ts index f9924f7e..23cd6a39 100644 --- a/apps/local/src/report-builders.ts +++ b/apps/local/src/report-builders.ts @@ -1,4 +1,5 @@ import type { InsightsResponse } from "@selftune/runtime/dashboard-contract"; +import type { Database } from "bun:sqlite"; import type { PortfolioAuditResult } from "@selftune/runtime/skill-portfolio"; import { scanSynthesisCandidates } from "@selftune/runtime/synthesis"; @@ -11,8 +12,9 @@ import type { ReportComputeOptions } from "./report-compute.js"; export async function buildInsightsResponse( audit: PortfolioAuditResult, options: ReportComputeOptions, + db: Database, ): Promise { - const snapshot = await scanSynthesisCandidates({ configRoot: options.configRoot }); + const snapshot = await scanSynthesisCandidates({ configRoot: options.configRoot, db }); const portfolio = audit.skills.filter( (skill) => skill.recommendation === "review_quarantine" || diff --git a/apps/local/src/report-caches.ts b/apps/local/src/report-caches.ts new file mode 100644 index 00000000..e4d0331a --- /dev/null +++ b/apps/local/src/report-caches.ts @@ -0,0 +1,137 @@ +import { join } from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { LocalDatabaseService } from "@selftune/local-store"; +import { + makeMaterializedCacheLayer, + type CachedOperation, + type MaterializedCacheOptions, +} from "./operation-cache.js"; +import { attempt, DashboardOperationError, operationError } from "./dashboard-operation-errors.js"; +import { computeReportInWorker, resolveReportComputeOptions } from "./report-compute.js"; +import { dashboardReportDependencyVersion } from "./report-version.js"; +import { + reportPayloadSchemas, + type DashboardReportName, + type DashboardReportPayloads, +} from "./report-contract.js"; + +type ReportCache = CachedOperation< + DashboardReportPayloads[Name], + DashboardOperationError, + never +>; +export class PortfolioAuditCache extends Context.Service< + PortfolioAuditCache, + ReportCache<"portfolio-audit"> +>()("SelfTune/PortfolioAuditCache") {} +export class SkillIntelligenceCache extends Context.Service< + SkillIntelligenceCache, + ReportCache<"skill-intelligence"> +>()("SelfTune/SkillIntelligenceCache") {} +export class InsightsCache extends Context.Service>()( + "SelfTune/InsightsCache", +) {} +export class LibraryCache extends Context.Service>()( + "SelfTune/LibraryCache", +) {} + +export interface DashboardReportCacheOptions { + readonly skillSetConfigRoot?: string; + readonly portfolioSearchDirs?: string[]; + readonly quarantineRoot?: string; + readonly reportVersionReaders?: Partial string>>; + readonly portfolioLoader?: () => DashboardReportPayloads["portfolio-audit"]; + readonly skillIntelligenceLoader?: () => + | DashboardReportPayloads["skill-intelligence"] + | Promise; + readonly insightsLoader?: () => + | DashboardReportPayloads["insights"] + | Promise; + readonly libraryLoader?: () => + | DashboardReportPayloads["library"] + | Promise; +} + +function cacheLayer( + key: Context.Service>, + operation: string, + compute: Effect.Effect, + loader: (() => A | Promise) | undefined, + options: MaterializedCacheOptions, +) { + return loader + ? Layer.succeed(key)({ read: attempt(operation, loader), invalidate: Effect.void }) + : makeMaterializedCacheLayer(key, compute, options); +} + +export function makeDashboardReportCachesLayer(options: DashboardReportCacheOptions) { + return Layer.unwrap( + Effect.gen(function* () { + const database = Option.getOrUndefined( + yield* Effect.serviceOption(LocalDatabaseService), + )?.sqlite; + const reportOptions = resolveReportComputeOptions({ + configRoot: options.skillSetConfigRoot, + searchDirs: options.portfolioSearchDirs, + quarantineRoot: options.quarantineRoot, + }); + const reportsDir = join(reportOptions.storagePaths.configRoot, "cache", "reports"); + const readVersion = (name: DashboardReportName) => + options.reportVersionReaders?.[name] ?? + (() => dashboardReportDependencyVersion(name, database)); + const compute = (name: Name, operation: string) => + computeReportInWorker(name, reportOptions, reportsDir).pipe( + Effect.mapError((cause) => operationError(operation, cause)), + ); + return Layer.mergeAll( + cacheLayer( + PortfolioAuditCache, + "portfolio.load", + compute("portfolio-audit", "portfolio.load"), + options.portfolioLoader, + { + artifactPath: join(reportsDir, "portfolio-audit.json"), + schema: reportPayloadSchemas["portfolio-audit"], + readVersion: readVersion("portfolio-audit"), + }, + ), + cacheLayer( + SkillIntelligenceCache, + "skill_intelligence.load", + compute("skill-intelligence", "skill_intelligence.load"), + options.skillIntelligenceLoader, + { + artifactPath: join(reportsDir, "skill-intelligence.json"), + schema: reportPayloadSchemas["skill-intelligence"], + readVersion: readVersion("skill-intelligence"), + }, + ), + cacheLayer( + InsightsCache, + "insights.load", + compute("insights", "insights.load"), + options.insightsLoader, + { + artifactPath: join(reportsDir, "insights.json"), + schema: reportPayloadSchemas.insights, + readVersion: readVersion("insights"), + }, + ), + cacheLayer( + LibraryCache, + "library.load", + compute("library", "library.load"), + options.libraryLoader, + { + artifactPath: join(reportsDir, "library.json"), + schema: reportPayloadSchemas.library, + readVersion: readVersion("library"), + }, + ), + ); + }), + ); +} diff --git a/apps/local/src/report-compute.ts b/apps/local/src/report-compute.ts index 721cc92b..2c2ce256 100644 --- a/apps/local/src/report-compute.ts +++ b/apps/local/src/report-compute.ts @@ -7,27 +7,27 @@ import { fileURLToPath } from "node:url"; import * as Effect from "effect/Effect"; import { resolveSelftunePaths } from "@selftune/config"; -export type DashboardReportName = "portfolio-audit" | "skill-intelligence" | "insights" | "library"; +import { + decodeReportOutput, + ReportComputeError, + type DashboardReportName, + type DashboardReportPayloads, + type ReportComputeOptions, + type ReportComputeStoragePaths, +} from "./report-contract.js"; +export type { + DashboardReportName, + ReportComputeOptions, + ReportComputeStoragePaths, +} from "./report-contract.js"; -export interface ReportComputeStoragePaths { - readonly configRoot: string; - readonly localDatabasePath: string; - readonly localAnalyticsPath: string; -} +const WORKER_TIMEOUT_MS = 180_000; -export interface ReportComputeOptions { - readonly configRoot?: string | undefined; - readonly searchDirs?: string[] | undefined; - readonly quarantineRoot?: string | undefined; - /** - * Resolved by the host before spawning. The worker must never infer storage - * ownership from its own ambient environment. - */ - readonly storagePaths?: ReportComputeStoragePaths | undefined; +export interface ReportWorkerProcessOptions { + readonly command?: readonly string[]; + readonly timeoutMs?: number; } -const WORKER_TIMEOUT_MS = 180_000; - export function resolveReportComputeOptions( options: ReportComputeOptions, ): ReportComputeOptions & { readonly storagePaths: ReportComputeStoragePaths } { @@ -85,13 +85,14 @@ export function reportWorkerCommand( * materialized cache retains its last successful artifact instead of recreating this * heavyweight work in the long-lived dashboard daemon. */ -export function computeReportInWorker( - report: DashboardReportName, +export function computeReportInWorker( + report: Name, options: ReportComputeOptions, reportsDir: string, -): Effect.Effect { + processOptions: ReportWorkerProcessOptions = {}, +): Effect.Effect { const outPath = join(reportsDir, `${report}.compute-${process.pid}-${randomUUID()}.json`); - return computeReportInSubprocess(report, options, outPath).pipe( + return computeReportInSubprocess(report, options, outPath, processOptions).pipe( Effect.ensuring( Effect.sync(() => { try { @@ -104,14 +105,15 @@ export function computeReportInWorker( ); } -export function computeReportInSubprocess( - report: DashboardReportName, +export function computeReportInSubprocess( + report: Name, options: ReportComputeOptions, outPath: string, -): Effect.Effect { + processOptions: ReportWorkerProcessOptions = {}, +): Effect.Effect { return Effect.tryPromise({ try: async () => { - const workerCommand = reportWorkerCommand(); + const workerCommand = processOptions.command ?? reportWorkerCommand(); const child = Bun.spawn( [...workerCommand, ...reportWorkerArguments(report, options, outPath)], { @@ -119,22 +121,27 @@ export function computeReportInSubprocess( stderr: "pipe", }, ); - const killTimer = setTimeout(() => child.kill(), WORKER_TIMEOUT_MS); + const killTimer = setTimeout( + () => child.kill(), + processOptions.timeoutMs ?? WORKER_TIMEOUT_MS, + ); try { const [stderrText, exitCode] = await Promise.all([ new Response(child.stderr).text(), child.exited, ]); if (exitCode !== 0) { - throw new Error( - `SelfTune report worker for ${report} exited with ${exitCode}: ${stderrText.slice(-500)}`, - ); + throw new ReportComputeError({ + report, + exitCode, + message: `SelfTune report worker for ${report} exited with ${exitCode}: ${stderrText.slice(-500)}`, + }); } - return JSON.parse(await Bun.file(outPath).text()) as A; + return decodeReportOutput(report, await Bun.file(outPath).text()); } finally { clearTimeout(killTimer); } }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => ReportComputeError.fromCause(report, cause), }); } diff --git a/apps/local/src/report-contract.ts b/apps/local/src/report-contract.ts new file mode 100644 index 00000000..3972c053 --- /dev/null +++ b/apps/local/src/report-contract.ts @@ -0,0 +1,94 @@ +import * as Schema from "effect/Schema"; +import { LibrarySnapshot } from "@selftune/control-plane"; +import { + InsightsResponseSchema, + PortfolioAuditResultSchema, + SkillIntelligenceReportSchema, +} from "@selftune/runtime/dashboard-contract/report-schemas"; + +export const DashboardReportNameSchema = Schema.Literals([ + "portfolio-audit", + "skill-intelligence", + "insights", + "library", +]); +export type DashboardReportName = typeof DashboardReportNameSchema.Type; + +export const ReportComputeStoragePathsSchema = Schema.Struct({ + configRoot: Schema.NonEmptyString, + localDatabasePath: Schema.NonEmptyString, + localAnalyticsPath: Schema.NonEmptyString, +}); +export type ReportComputeStoragePaths = typeof ReportComputeStoragePathsSchema.Type; + +export const ReportComputeOptionsSchema = Schema.Struct({ + configRoot: Schema.optionalKey(Schema.String), + searchDirs: Schema.optionalKey(Schema.mutable(Schema.Array(Schema.String))), + quarantineRoot: Schema.optionalKey(Schema.String), + storagePaths: Schema.optionalKey(ReportComputeStoragePathsSchema), +}); +export type ReportComputeOptions = typeof ReportComputeOptionsSchema.Type; + +export const ResolvedReportComputeOptionsSchema = Schema.Struct({ + ...ReportComputeOptionsSchema.fields, + storagePaths: ReportComputeStoragePathsSchema, +}); +export type ResolvedReportComputeOptions = typeof ResolvedReportComputeOptionsSchema.Type; + +export const ReportWorkerArgumentsSchema = Schema.Tuple([ + DashboardReportNameSchema, + Schema.String, + Schema.NonEmptyString, +]); + +export const reportPayloadSchemas = { + "portfolio-audit": PortfolioAuditResultSchema, + "skill-intelligence": SkillIntelligenceReportSchema, + insights: InsightsResponseSchema, + library: LibrarySnapshot, +}; +export type DashboardReportPayloads = { + [Name in DashboardReportName]: (typeof reportPayloadSchemas)[Name]["Type"]; +}; + +function reportDecoder(schema: Schema.Codec) { + const decode = Schema.decodeUnknownSync(Schema.fromJsonString(schema)); + return (text: string): A => decode(text, { onExcessProperty: "preserve" }); +} + +type DashboardReportDecoders = { + [Name in DashboardReportName]: (text: string) => DashboardReportPayloads[Name]; +}; +const reportDecoders: DashboardReportDecoders = { + "portfolio-audit": reportDecoder(reportPayloadSchemas["portfolio-audit"]), + "skill-intelligence": reportDecoder(reportPayloadSchemas["skill-intelligence"]), + insights: reportDecoder(reportPayloadSchemas.insights), + library: reportDecoder(reportPayloadSchemas.library), +}; + +export function decodeReportOutput( + report: Name, + text: string, +): DashboardReportPayloads[Name] { + return reportDecoders[report](text); +} + +export class ReportComputeError extends Schema.TaggedErrorClass()( + "ReportComputeError", + { + report: DashboardReportNameSchema, + message: Schema.String, + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect), + }, +) { + static fromCause(report: DashboardReportName, cause: unknown) { + return cause instanceof ReportComputeError + ? cause + : new ReportComputeError({ + report, + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + } +} diff --git a/apps/local/src/report-version.ts b/apps/local/src/report-version.ts new file mode 100644 index 00000000..52137ad6 --- /dev/null +++ b/apps/local/src/report-version.ts @@ -0,0 +1,74 @@ +import type { Database } from "bun:sqlite"; +import { getDb } from "@selftune/local-store"; +import type { DashboardReportName } from "./report-contract.js"; + +interface ReportDependency { + readonly table: string; + /** An indexed append/update timestamp, when the report needs one. */ + readonly cursorColumn?: string; +} + +const REPORT_DEPENDENCIES: Record = { + "portfolio-audit": [ + { table: "session_telemetry", cursorColumn: "timestamp" }, + { table: "skill_invocations", cursorColumn: "occurred_at" }, + { table: "prompts", cursorColumn: "occurred_at" }, + { table: "queries", cursorColumn: "timestamp" }, + { table: "skill_usage", cursorColumn: "timestamp" }, + ], + "skill-intelligence": [ + { table: "sessions" }, + { table: "prompts", cursorColumn: "occurred_at" }, + { table: "skill_invocations", cursorColumn: "occurred_at" }, + { table: "session_telemetry", cursorColumn: "timestamp" }, + { table: "queries", cursorColumn: "timestamp" }, + { table: "skill_usage", cursorColumn: "timestamp" }, + { table: "skill_classification_overrides", cursorColumn: "updated_at" }, + { table: "skill_set_suggestion_reviews", cursorColumn: "reviewed_at" }, + { table: "skill_set_outcomes", cursorColumn: "measured_at" }, + // DuckDB facts are rebuildable, but this SQLite checkpoint records the + // accepted source revision that changes their dashboard projection. + { table: "analytical_import_checkpoints", cursorColumn: "imported_at" }, + ], + insights: [ + { table: "session_telemetry", cursorColumn: "timestamp" }, + { table: "skill_invocations", cursorColumn: "occurred_at" }, + { table: "prompts", cursorColumn: "occurred_at" }, + { table: "queries", cursorColumn: "timestamp" }, + { table: "skill_usage", cursorColumn: "timestamp" }, + ], + library: [ + { table: "skill_install_receipts" }, + { table: "skill_install_receipt_files" }, + { table: "skill_install_operations" }, + ], +}; + +function dependencyCursor(db: Database, dependency: ReportDependency): string { + const rowid = db + .query<{ max_rowid: number }, []>( + `SELECT COALESCE(MAX(rowid), 0) AS max_rowid FROM ${dependency.table}`, + ) + .get(); + if (!dependency.cursorColumn) return `${dependency.table}:${rowid?.max_rowid ?? 0}`; + const timestamp = db + .query<{ max_cursor: string }, []>( + `SELECT COALESCE(MAX(${dependency.cursorColumn}), '') AS max_cursor FROM ${dependency.table}`, + ) + .get(); + return `${dependency.table}:${rowid?.max_rowid ?? 0}:${timestamp?.max_cursor ?? ""}`; +} + +export function dashboardReportDependencyVersion( + report: DashboardReportName, + database?: Database, +): string { + const dependencies = REPORT_DEPENDENCIES[report]; + try { + const db = database ?? getDb(); + return dependencies.map((dependency) => dependencyCursor(db, dependency)).join("|"); + } catch { + // The cache TTL remains a safe fallback while a host is still bringing up SQLite. + return `unavailable:${report}`; + } +} diff --git a/apps/local/src/report-worker.ts b/apps/local/src/report-worker.ts index b678e3d4..f6ae63b8 100644 --- a/apps/local/src/report-worker.ts +++ b/apps/local/src/report-worker.ts @@ -4,59 +4,63 @@ * never blocks the daemon's event loop. Exits non-zero with the failure on stderr. */ import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; +import type { Database } from "bun:sqlite"; import { openDb } from "@selftune/local-store"; +import * as Schema from "effect/Schema"; -import type { DashboardReportName, ReportComputeOptions } from "./report-compute.js"; - -function requireStoragePaths( - options: ReportComputeOptions, -): NonNullable { - if (options.storagePaths === undefined) { - throw new Error("Report worker requires host-resolved storage paths."); - } - return options.storagePaths; -} +import { + ReportWorkerArgumentsSchema, + ResolvedReportComputeOptionsSchema, + type DashboardReportName, + type ResolvedReportComputeOptions, +} from "./report-contract.js"; async function computeReport( report: DashboardReportName, - options: ReportComputeOptions, -): Promise { - const storagePaths = requireStoragePaths(options); - const workerOptions: ReportComputeOptions = { + options: ResolvedReportComputeOptions, + db: Database, +) { + const storagePaths = options.storagePaths; + const workerOptions = { ...options, configRoot: storagePaths.configRoot, storagePaths, }; if (report === "portfolio-audit") { const { loadPortfolioAudit } = await import("@selftune/runtime/skill-portfolio"); - return loadPortfolioAudit(workerOptions.searchDirs); + return loadPortfolioAudit(workerOptions.searchDirs, db); } if (report === "skill-intelligence") { const { loadSkillIntelligenceWithCatalog } = await import("@selftune/runtime/skill-intelligence/catalog-expansions"); - const db = openDb(storagePaths.localDatabasePath); - try { - return await loadSkillIntelligenceWithCatalog({ - db, - configRoot: storagePaths.configRoot, - traceAnalyticsPath: storagePaths.localAnalyticsPath, - searchDirs: workerOptions.searchDirs, - quarantineRoot: workerOptions.quarantineRoot, - }); - } finally { - db.close(); - } + return loadSkillIntelligenceWithCatalog({ + db, + configRoot: storagePaths.configRoot, + traceAnalyticsPath: storagePaths.localAnalyticsPath, + searchDirs: workerOptions.searchDirs, + quarantineRoot: workerOptions.quarantineRoot ?? join(storagePaths.configRoot, "quarantine"), + }); } if (report === "library") { - const [{ createControlPlaneRuntime }, { loadLibraryReport }] = await Promise.all([ + const [ + { createControlPlaneRuntime }, + { loadLibraryReport }, + { queryKnownWorkspacePaths, queryTrustedSkillObservationRows }, + ] = await Promise.all([ import("@selftune/runtime/control-plane-runtime"), import("./library-report.js"), + import("@selftune/runtime/localdb/queries"), ]); const controlPlane = createControlPlaneRuntime(); try { - return await loadLibraryReport(workerOptions.configRoot, controlPlane); + return await loadLibraryReport(workerOptions.configRoot, controlPlane, { + searchDirs: workerOptions.searchDirs, + quarantineRoot: workerOptions.quarantineRoot ?? join(storagePaths.configRoot, "quarantine"), + usageRows: queryTrustedSkillObservationRows(db), + workspacePaths: queryKnownWorkspacePaths(db), + }); } finally { await controlPlane.dispose(); } @@ -65,18 +69,24 @@ async function computeReport( import("@selftune/runtime/skill-portfolio"), import("./report-builders.js"), ]); - return buildInsightsResponse(loadPortfolioAudit(workerOptions.searchDirs), workerOptions); + return buildInsightsResponse(loadPortfolioAudit(workerOptions.searchDirs, db), workerOptions, db); } async function main(): Promise { - const [, , report, optionsJson, outPath] = process.argv; - if (!report || !outPath) { - throw new Error("Usage: report-worker.ts "); + const [report, optionsJson, outPath] = Schema.decodeUnknownSync(ReportWorkerArgumentsSchema)( + process.argv.slice(2), + ); + const options = Schema.decodeUnknownSync( + Schema.fromJsonString(ResolvedReportComputeOptionsSchema), + )(optionsJson); + const db = openDb(options.storagePaths.localDatabasePath); + try { + const result = await computeReport(report, options, db); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, JSON.stringify(result)); + } finally { + db.close(); } - const options = JSON.parse(optionsJson || "{}") as ReportComputeOptions; - const result = await computeReport(report as DashboardReportName, options); - mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, JSON.stringify(result)); } main().catch((cause) => { diff --git a/apps/local/src/routes/actions.ts b/apps/local/src/routes/actions.ts index 77d0fa62..7e163305 100644 --- a/apps/local/src/routes/actions.ts +++ b/apps/local/src/routes/actions.ts @@ -5,16 +5,15 @@ */ import { randomUUID } from "node:crypto"; +import { Option, Schema } from "effect"; import { dashboardActionContextEnv, type DashboardActionContext, } from "@selftune/runtime/dashboard-action-events"; import { resolveDashboardActionOutcome } from "@selftune/runtime/dashboard-action-result"; -import type { - DashboardActionEvent, - DashboardActionName, -} from "@selftune/runtime/dashboard-contract"; +import type { DashboardActionEvent } from "@selftune/runtime/dashboard-contract"; +import { DashboardActionName } from "@selftune/runtime/dashboard-contract/action-name"; import { isCreateSkillDraft } from "@selftune/runtime/create/readiness"; import { getCanonicalEvalSetPath, getUnitTestPath } from "@selftune/runtime/testing-readiness"; import { saveWatchedSkills } from "@selftune/runtime/watchlist"; @@ -135,27 +134,25 @@ export async function runAction( } } -function requireSkillInput( - body: Record, -): { skill: string; skillPath: string } | Response { - const skill = body.skill as string | undefined; - const skillPath = body.skillPath as string | undefined; - if (!skill || !skillPath) { - return Response.json( - { success: false, error: "Missing required fields: skill, skillPath" }, - { status: 400 }, - ); - } - return { skill, skillPath }; -} +const SkillActionInput = Schema.Struct({ + skill: Schema.NonEmptyString, + skillPath: Schema.NonEmptyString, + autoSynthetic: Schema.optionalKey(Schema.Boolean), + proposalId: Schema.optionalKey(Schema.String), +}); +const decodeSkillActionInput = Schema.decodeUnknownOption(SkillActionInput); +const decodeActionName = Schema.decodeUnknownOption(DashboardActionName); +const decodeWatchlist = Schema.decodeUnknownOption( + Schema.Struct({ + skills: Schema.optionalKey(Schema.NullOr(Schema.mutable(Schema.Array(Schema.String)))), + }), +); function buildActionExecution( action: DashboardActionName, - body: Record, + body: typeof SkillActionInput.Type, ): { command: string; args: string[]; skill: string; skillPath: string } | Response { - const skillInput = requireSkillInput(body); - if (skillInput instanceof Response) return skillInput; - const { skill, skillPath } = skillInput; + const { skill, skillPath } = body; const isDraftPackage = isCreateSkillDraft(skillPath); if (action === "generate-evals") { @@ -297,7 +294,7 @@ function buildActionExecution( } if (action === "rollback") { - const proposalId = body.proposalId as string | undefined; + const proposalId = body.proposalId; const args = ["rollback", "--skill", skill, "--skill-path", skillPath]; if (proposalId) { args.push("--proposal-id", proposalId); @@ -310,19 +307,13 @@ function buildActionExecution( export async function handleAction( action: string, - body: Record, + body: Schema.Json, executeAction: ActionRunner = runAction, emitEvent?: ActionEventEmitter, ): Promise { if (action === "watchlist") { - const skills = body.skills; - if (skills === undefined || skills === null) { - return Response.json( - { success: false, error: "Missing required field: skills[]" }, - { status: 400 }, - ); - } - if (!Array.isArray(skills) || !skills.every((skill) => typeof skill === "string")) { + const input = decodeWatchlist(body); + if (Option.isNone(input)) { return Response.json( { success: false, @@ -331,6 +322,13 @@ export async function handleAction( { status: 400 }, ); } + const skills = input.value.skills; + if (skills === undefined || skills === null) { + return Response.json( + { success: false, error: "Missing required field: skills[]" }, + { status: 400 }, + ); + } try { const saved = saveWatchedSkills(skills); return Response.json({ @@ -350,8 +348,23 @@ export async function handleAction( } } - const normalizedAction = action === "evolve" ? "deploy-candidate" : action; - const executable = buildActionExecution(normalizedAction as DashboardActionName, body); + const decodedAction = decodeActionName(action === "evolve" ? "deploy-candidate" : action); + if (Option.isNone(decodedAction)) { + return Response.json({ success: false, error: `Unknown action: ${action}` }, { status: 400 }); + } + const input = decodeSkillActionInput(body); + if (Option.isNone(input)) { + return Response.json( + { + success: false, + error: + "Expected non-empty skill and skillPath strings, optional boolean autoSynthetic, and optional string proposalId.", + }, + { status: 400 }, + ); + } + const normalizedAction = decodedAction.value; + const executable = buildActionExecution(normalizedAction, input.value); if (executable instanceof Response) { return executable; } @@ -359,7 +372,7 @@ export async function handleAction( const eventId = randomUUID(); emitEvent?.({ event_id: eventId, - action: normalizedAction as DashboardActionName, + action: normalizedAction, stage: "started", skill_name: executable.skill, skill_path: executable.skillPath, @@ -369,14 +382,14 @@ export async function handleAction( const result = await executeAction(executable.command, executable.args, { actionContext: { eventId, - action: normalizedAction as DashboardActionName, + action: normalizedAction, skillName: executable.skill, skillPath: executable.skillPath, }, onStdout(chunk) { emitEvent?.({ event_id: eventId, - action: normalizedAction as DashboardActionName, + action: normalizedAction, stage: "stdout", skill_name: executable.skill, skill_path: executable.skillPath, @@ -387,7 +400,7 @@ export async function handleAction( onStderr(chunk) { emitEvent?.({ event_id: eventId, - action: normalizedAction as DashboardActionName, + action: normalizedAction, stage: "stderr", skill_name: executable.skill, skill_path: executable.skillPath, @@ -397,7 +410,7 @@ export async function handleAction( }, }); const outcome = resolveDashboardActionOutcome({ - action: normalizedAction as DashboardActionName, + action: normalizedAction, stdout: result.output, stderr: result.error, exitCode: result.exitCode ?? 0, @@ -405,7 +418,7 @@ export async function handleAction( emitEvent?.({ event_id: eventId, - action: normalizedAction as DashboardActionName, + action: normalizedAction, stage: "finished", skill_name: executable.skill, skill_path: executable.skillPath, diff --git a/apps/local/src/routes/application.ts b/apps/local/src/routes/application.ts index c82ffb8d..02f4132e 100644 --- a/apps/local/src/routes/application.ts +++ b/apps/local/src/routes/application.ts @@ -1,6 +1,7 @@ // oxlint-disable max-lines -- The local HTTP application keeps route ordering and shared guards in one auditable boundary. import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import type { Mutable } from "effect/Types"; import { SkillSetDependencyResolutionInput, SkillSetDependencyResolution, @@ -18,6 +19,7 @@ import type { CreateRemoteLibraryShareRequest, CreateSkillSetRequest, DeriveSkillSetRequest, + DesktopBillingCheckoutRequest, DraftInsightRequest, ExportSkillSetRequest, PlanSkillSetRequest, @@ -49,6 +51,7 @@ import { dashboardCorsHeaders, dashboardOperationErrorResponse, sameOriginFailure, + withDashboardCors, } from "../dashboard-http.js"; import { routeWorkspaceSettings } from "./workspace-settings.js"; import { routeLibraryTransfer } from "./library-transfer.js"; @@ -282,6 +285,8 @@ const PortfolioQuarantineBatchBody = Schema.Struct({ Schema.Struct({ skill_name: Schema.String, skill_path: Schema.String, + keep_searchable: Schema.optionalKey(Schema.Boolean), + expected_content_hash: Schema.optionalKey(Schema.String), }), ), }); @@ -322,10 +327,10 @@ const decodeBody = Effect.fn("DashboardApplication.decodeBody")(function* => request.json(), + try: () => request.text(), catch: () => requestError(operation, code, message), }); - return yield* Schema.decodeUnknownEffect(schema)(input).pipe( + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(input).pipe( Effect.mapError(() => requestError(operation, code, message)), ); }); @@ -350,9 +355,6 @@ function decodeRouteSegment( ), ); } -function json(value: unknown, status = 200): Response { - return Response.json(value, { status, headers: dashboardCorsHeaders() }); -} function readOnlySkillSetsResponse(): Response { return Response.json( { @@ -382,7 +384,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function Effect.sync(() => context.onResourcesChanged?.(resources)); if (url.pathname === "/api/v2/portfolio" && request.method === "GET") { - return json(yield* operations.portfolio); + return Response.json(yield* operations.portfolio); } if (url.pathname === "/api/v2/portfolio/quarantine" && request.method === "POST") { @@ -407,7 +409,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function confirm: body.confirm === true, }); if (body.confirm === true) yield* resourcesChanged(libraryLocationWriteResources); - return json(receipt); + return Response.json(receipt); } if (url.pathname === "/api/v2/portfolio/quarantine-batch" && request.method === "POST") { @@ -433,10 +435,12 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function body.skills.map((skill) => ({ skillName: skill.skill_name, skillPath: skill.skill_path, + keepSearchable: skill.keep_searchable, + expectedContentHash: skill.expected_content_hash, })), ); yield* resourcesChanged(libraryLocationWriteResources); - return json(result); + return Response.json(result); } if (url.pathname === "/api/v2/portfolio/restore" && request.method === "POST") { @@ -451,7 +455,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ); const receipt = yield* operations.restore(body.quarantine_id); yield* resourcesChanged(libraryLocationWriteResources); - return json(receipt); + return Response.json(receipt); } const libraryTransfer = yield* routeLibraryTransfer( request, @@ -464,7 +468,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function return libraryTransfer.response; } if (url.pathname === "/api/v2/decisions" && request.method === "GET") { - return json({ decisions: yield* operations.decisions }); + return Response.json({ decisions: yield* operations.decisions }); } if (url.pathname === "/api/v2/decisions/removals" && request.method === "POST") { const unauthorized = mutationFailure(request, context); @@ -484,7 +488,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function })), }); yield* resourcesChanged(durableDecisionResources.prepare); - return json(decision); + return Response.json(decision); } if (url.pathname === "/api/v2/decisions/consolidations" && request.method === "POST") { const unauthorized = mutationFailure(request, context); @@ -502,7 +506,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function targetSkillPaths: body.target_skill_paths, }); yield* resourcesChanged(durableDecisionResources.prepare); - return json(decision); + return Response.json(decision); } if (url.pathname === "/api/v2/decisions/skill-set-conflicts" && request.method === "POST") { const unauthorized = mutationFailure(request, context); @@ -519,7 +523,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function project_root: body.project_root, }); yield* resourcesChanged(durableDecisionResources.prepare); - return json(decision); + return Response.json(decision); } const durableDecisionReadMatch = url.pathname.match(/^\/api\/v2\/decisions\/([0-9a-f-]{36})$/i); const durableDecisionArtifactMatch = url.pathname.match( @@ -537,14 +541,14 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ); } const review = adaptLocalSourceMerge(decision); - return json( + return Response.json( durableDecisionArtifactMatch[2] === "run-package" ? buildRunPackage(review) : summarizeRunReview(review), ); } if (durableDecisionReadMatch && request.method === "GET") { - return json(yield* operations.decision(durableDecisionReadMatch[1]!)); + return Response.json(yield* operations.decision(durableDecisionReadMatch[1]!)); } const durableDecisionActionMatch = url.pathname.match( /^\/api\/v2\/decisions\/([0-9a-f-]{36})\/(approve|decline|rollback)$/i, @@ -568,11 +572,11 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ? durableDecisionResources.approve : durableDecisionResources.decide, ); - return json(decision); + return Response.json(decision); } if (url.pathname === "/api/v2/skill-intelligence" && request.method === "GET") { - return json(yield* operations.skillIntelligence); + return Response.json(yield* operations.skillIntelligence); } if (url.pathname === "/api/v2/skill-intelligence/classification" && request.method === "POST") { @@ -586,7 +590,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "A skill, inferred category, and valid category or null are required.", ); const input: UpdateSkillClassificationRequest = { ...body }; - return json(yield* operations.updateSkillClassification(input)); + return Response.json(yield* operations.updateSkillClassification(input)); } if ( @@ -628,7 +632,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function } : undefined, }; - return json(yield* operations.reviewSkillSetSuggestion(input)); + return Response.json(yield* operations.reviewSkillSetSuggestion(input)); } if (url.pathname.startsWith("/api/v2/library/source-update/") && request.method === "POST") { @@ -648,7 +652,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "skill_name is required.", ); - return json(yield* operations.previewSourceUpdate(body.skill_name)); + return Response.json(yield* operations.previewSourceUpdate(body.skill_name)); } if (url.pathname === "/api/v2/library/source-update/apply") { const body = yield* decodeBody( @@ -660,7 +664,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ); const receipt = yield* operations.applySourceUpdate(body.skill_name, body.strategy); yield* resourcesChanged(sourceUpdateResources.apply); - return json(receipt); + return Response.json(receipt); } if (url.pathname === "/api/v2/library/source-update/merge/prepare") { const body = yield* decodeBody( @@ -676,7 +680,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function body.model ?? null, ); yield* resourcesChanged(sourceMergeDecisionResources.prepare); - return json(preview); + return Response.json(preview); } return new Response("Not found", { status: 404, @@ -685,7 +689,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function } if (url.pathname === "/api/v2/insights" && request.method === "GET") { - return json(yield* operations.insights); + return Response.json(yield* operations.insights); } if (url.pathname.startsWith("/api/v2/insights/") && request.method === "POST") { @@ -715,7 +719,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function }; const candidate = yield* operations.reviewInsight(input); yield* resourcesChanged(insightDecisionResources.review); - return json(candidate); + return Response.json(candidate); } if (url.pathname === "/api/v2/insights/draft") { const body = yield* decodeBody( @@ -731,7 +735,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function }; const draft = yield* operations.draftInsight(input); yield* resourcesChanged(insightDecisionResources.draft); - return json(draft); + return Response.json(draft); } if (url.pathname === "/api/v2/insights/evaluate") { const body = yield* decodeBody( @@ -743,7 +747,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ); const gate = yield* operations.evaluateInsight(body.candidate_id); yield* resourcesChanged(insightDecisionResources.evaluate); - return json(gate); + return Response.json(gate); } if (url.pathname === "/api/v2/insights/release") { const body = yield* decodeBody( @@ -755,7 +759,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ); const release = yield* operations.releaseInsight(body.candidate_id); yield* resourcesChanged(insightDecisionResources.release); - return json(release); + return Response.json(release); } return new Response("Not found", { status: 404, @@ -764,17 +768,17 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function } if (url.pathname === "/api/v2/skill-sets" && request.method === "GET") { - return json(yield* operations.skillSets); + return Response.json(yield* operations.skillSets); } if (url.pathname === "/api/v2/skill-sets/assignments" && request.method === "GET") { - return json(yield* operations.assignedSkillSets); + return Response.json(yield* operations.assignedSkillSets); } if (url.pathname === "/api/v2/skill-sets/assignments/sync" && request.method === "POST") { const unauthorized = mutationFailure(request, context); if (unauthorized) return unauthorized; - return json(yield* operations.assignedSkillSets); + return Response.json(yield* operations.assignedSkillSets); } if (url.pathname === "/api/v2/skill-sets/assignments/preview" && request.method === "POST") { @@ -787,14 +791,13 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "assignment_id is required.", ); - return json( - yield* operations.previewAssignedSkillSet({ - assignmentId: body.assignment_id, - ...(body.scope ? { scope: body.scope } : {}), - ...(body.project_root ? { projectRoot: body.project_root } : {}), - ...(body.target_agents ? { targetAgents: body.target_agents } : {}), - }), - ); + const input: Mutable[0]> = { + assignmentId: body.assignment_id, + }; + if (body.scope) input.scope = body.scope; + if (body.project_root) input.projectRoot = body.project_root; + if (body.target_agents) input.targetAgents = body.target_agents; + return Response.json(yield* operations.previewAssignedSkillSet(input)); } if (url.pathname === "/api/v2/skill-sets/assignments/install" && request.method === "POST") { @@ -807,7 +810,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "Reviewed assignment hashes and explicit install confirmation are required.", ); - return json( + return Response.json( yield* operations.installAssignedSkillSet({ assignmentId: body.assignment_id, requestId: body.request_id, @@ -829,7 +832,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "assignment_id, receipt_id, and explicit Undo confirmation are required.", ); - return json( + return Response.json( yield* operations.rollbackAssignedSkillSet({ assignmentId: body.assignment_id, receiptId: body.receipt_id, @@ -848,14 +851,13 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "assignment_id, title, and message are required.", ); - return json( - yield* operations.previewTeamContribution({ - assignmentId: body.assignment_id, - title: body.title, - message: body.message, - ...(body.source_receipt_ids ? { sourceReceiptIds: body.source_receipt_ids } : {}), - }), - ); + const input: Mutable[0]> = { + assignmentId: body.assignment_id, + title: body.title, + message: body.message, + }; + if (body.source_receipt_ids) input.sourceReceiptIds = body.source_receipt_ids; + return Response.json(yield* operations.previewTeamContribution(input)); } if (url.pathname === "/api/v2/skill-sets/contributions/submit" && request.method === "POST") { @@ -868,7 +870,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "A preview token and explicit confirmation are required.", ); - return json( + return Response.json( yield* operations.submitTeamContribution({ previewToken: body.preview_token, confirmSubmit: body.confirm_submit, @@ -879,11 +881,11 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function if (url.pathname === "/api/v2/skill-sets/contributions/sync" && request.method === "POST") { const unauthorized = mutationFailure(request, context); if (unauthorized) return unauthorized; - return json(yield* operations.syncTeamContributions); + return Response.json(yield* operations.syncTeamContributions); } if (url.pathname === "/api/v2/plugins" && request.method === "GET") { - return json(yield* operations.plugins); + return Response.json(yield* operations.plugins); } if (url.pathname === "/api/v2/plugins/manage" && request.method === "POST") { @@ -902,7 +904,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "plugin_id is required.", ); - return json( + return Response.json( yield* operations.managePlugin({ host: body.host, pluginId: body.plugin_id, @@ -912,7 +914,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function } if (url.pathname === "/api/v2/skill-sets/packs" && request.method === "GET") { - return json(yield* operations.listSkillSetPacks()); + return Response.json(yield* operations.listSkillSetPacks()); } const skillSetMatch = /^\/api\/v2\/skill-sets\/([^/]+)$/.exec(url.pathname); @@ -923,14 +925,14 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const result = yield* operations.deleteSkillSet(decodeURIComponent(skillSetMatch[1] ?? "")); yield* resourcesChanged(projectSkillSetResources.remove); context.onSkillSetChanged?.(); - return json(result); + return Response.json(result); } const skillSetPackMatch = /^\/api\/v2\/skill-sets\/packs\/([^/]+)$/.exec(url.pathname); if (skillSetPackMatch && request.method === "DELETE") { const unauthorized = mutationFailure(request, context); if (unauthorized) return unauthorized; - return json( + return Response.json( yield* operations.revokeSkillSetPack(decodeURIComponent(skillSetPackMatch[1] ?? "")), ); } @@ -945,7 +947,9 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "set_id is required.", ); - return json(yield* operations.previewSkillSetPublish(body.set_id, body.dependency_resolution)); + return Response.json( + yield* operations.previewSkillSetPublish(body.set_id, body.dependency_resolution), + ); } if (url.pathname === "/api/v2/skill-sets/publish" && request.method === "POST") { @@ -958,7 +962,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "set_id, reviewed release hashes, and explicit publish confirmation are required.", ); - return json( + return Response.json( yield* operations.publishSkillSet({ setId: body.set_id, expectedSkillSetRevisionSha256: body.expected_skill_set_revision_sha256, @@ -982,7 +986,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "pack_url is required.", ); - return json(yield* operations.previewSkillSetPack(body.pack_url)); + return Response.json(yield* operations.previewSkillSetPack(body.pack_url)); } if (url.pathname === "/api/v2/skill-sets/packs/import") { const body = yield* decodeBody( @@ -998,7 +1002,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function }); yield* resourcesChanged(projectSkillSetResources.create); context.onSkillSetChanged?.(); - return json(result); + return Response.json(result); } if (url.pathname === "/api/v2/skill-sets") { const body = yield* decodeBody( @@ -1017,7 +1021,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const manifest = yield* operations.createSkillSet(input); yield* resourcesChanged(projectSkillSetResources.create); context.onSkillSetChanged?.(); - return json(manifest); + return Response.json(manifest); } if (url.pathname === "/api/v2/skill-sets/update") { const body = yield* decodeBody( @@ -1038,7 +1042,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const manifest = yield* operations.updateSkillSet(input); yield* resourcesChanged(projectSkillSetResources.update); context.onSkillSetChanged?.(); - return json(manifest); + return Response.json(manifest); } if (url.pathname === "/api/v2/skill-sets/derive") { const body = yield* decodeBody( @@ -1057,7 +1061,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const manifest = yield* operations.deriveSkillSet(input); yield* resourcesChanged(projectSkillSetResources.derive); context.onSkillSetChanged?.(); - return json(manifest); + return Response.json(manifest); } if (url.pathname === "/api/v2/skill-sets/export") { const body = yield* decodeBody( @@ -1070,7 +1074,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const input: ExportSkillSetRequest = { ...body }; const receipt = yield* operations.exportSkillSet(input); yield* resourcesChanged(projectSkillSetResources.export); - return json(receipt); + return Response.json(receipt); } if (url.pathname === "/api/v2/skill-sets/plugin-export") { const body = yield* decodeBody( @@ -1080,7 +1084,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "set_id and target are required.", ); - return json(yield* operations.exportSkillSetPlugin(body)); + return Response.json(yield* operations.exportSkillSetPlugin(body)); } if (url.pathname === "/api/v2/skill-sets/plugin-install/preview") { const body = yield* decodeBody( @@ -1090,7 +1094,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "set_id is required.", ); - return json(yield* operations.previewSkillSetPluginInstall(body.set_id)); + return Response.json(yield* operations.previewSkillSetPluginInstall(body.set_id)); } if (url.pathname === "/api/v2/skill-sets/plugin-install") { const body = yield* decodeBody( @@ -1100,7 +1104,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "set_id, expected_revision_hash, and hosts are required.", ); - return json( + return Response.json( yield* operations.installSkillSetPlugin({ setId: body.set_id, expectedRevisionHash: body.expected_revision_hash, @@ -1119,7 +1123,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const input: PlanSkillSetRequest = { ...body }; const plan = yield* operations.planSkillSet(input); yield* resourcesChanged(projectSkillSetResources.plan); - return json(plan); + return Response.json(plan); } if (url.pathname === "/api/v2/skill-sets/project-plan") { const body = yield* decodeBody( @@ -1129,7 +1133,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "project_root and set_ids are required.", ); - return json( + return Response.json( yield* operations.previewProjectProvision({ project_root: body.project_root, set_ids: body.set_ids, @@ -1152,7 +1156,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function create_react_project: body.create_react_project === true, }); yield* resourcesChanged(projectSkillSetResources.apply); - return json(result); + return Response.json(result); } if (url.pathname === "/api/v2/skill-sets/apply") { const body = yield* decodeBody( @@ -1165,7 +1169,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const input: ApplySkillSetRequest = { ...body }; const receipt = yield* operations.applySkillSet(input); yield* resourcesChanged(projectSkillSetResources.apply); - return json(receipt); + return Response.json(receipt); } if (url.pathname === "/api/v2/skill-sets/rollback") { const body = yield* decodeBody( @@ -1178,22 +1182,22 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const input: RollbackSkillSetRequest = { ...body }; const receipt = yield* operations.rollbackSkillSet(input); yield* resourcesChanged(projectSkillSetResources.rollback); - return json(receipt); + return Response.json(receipt); } - return json( + return Response.json( { error: { code: "NOT_FOUND", message: "Unknown Skill Set operation." }, }, - 404, + { status: 404 }, ); } if (url.pathname === "/api/v2/settings" && request.method === "GET") { - return json(yield* operations.settings); + return Response.json(yield* operations.settings); } if (url.pathname === "/api/v2/settings/cloud-account/link/start" && request.method === "POST") { const unauthorized = mutationFailure(request, context); if (unauthorized) return unauthorized; - return json(yield* operations.startCloudAccountLink); + return Response.json(yield* operations.startCloudAccountLink); } if ( url.pathname === "/api/v2/settings/cloud-account/link/complete" && @@ -1212,15 +1216,15 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function link_id: body.link_id, preferences: { ...body.preferences }, }; - return json(yield* operations.completeCloudAccountLink(input)); + return Response.json(yield* operations.completeCloudAccountLink(input)); } if (url.pathname === "/api/v2/settings/billing/status" && request.method === "GET") { - return json(yield* operations.cloudBilling("status")); + return Response.json(yield* operations.cloudBilling("status")); } if (url.pathname === "/api/v2/settings/billing/portal" && request.method === "POST") { const unauthorized = mutationFailure(request, context); if (unauthorized) return unauthorized; - return json(yield* operations.cloudBilling("portal")); + return Response.json(yield* operations.cloudBilling("portal")); } if (url.pathname === "/api/v2/settings/billing/checkout" && request.method === "POST") { const unauthorized = mutationFailure(request, context); @@ -1232,12 +1236,9 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "Choose a billing plan before checkout.", ); - return json( - yield* operations.cloudBilling("checkout", { - plan: body.plan, - ...(body.seats === undefined ? {} : { seats: body.seats }), - }), - ); + const input: Mutable = { plan: body.plan }; + if (body.seats !== undefined) input.seats = body.seats; + return Response.json(yield* operations.cloudBilling("checkout", input)); } if (url.pathname === "/api/v2/settings/billing/checkout/finalize" && request.method === "POST") { const unauthorized = mutationFailure(request, context); @@ -1249,7 +1250,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "MISSING_FLAG", "Checkout session details are required.", ); - return json( + return Response.json( yield* operations.cloudBilling("finalize", { sessionId: body.session_id, }), @@ -1257,10 +1258,10 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function } if (url.pathname === "/api/v2/team-collaboration/access" && request.method === "GET") { - return json(yield* operations.teamCollaborationAccess); + return Response.json(yield* operations.teamCollaborationAccess); } if (url.pathname === "/api/v2/team-collaboration" && request.method === "GET") { - return json(yield* operations.teamCollaborationSnapshot); + return Response.json(yield* operations.teamCollaborationSnapshot); } const collaborationRolloutMatch = url.pathname.match( /^\/api\/v2\/team-collaboration\/registry\/([^/]+)\/rollout-policy$/, @@ -1279,7 +1280,9 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "INVALID_FLAG", "Choose manual, notify, or automatic rollout.", ); - return json(yield* operations.updateTeamCollaborationRolloutPolicy(entryId, body.policy)); + return Response.json( + yield* operations.updateTeamCollaborationRolloutPolicy(entryId, body.policy), + ); } const collaborationDecisionMatch = url.pathname.match( /^\/api\/v2\/team-collaboration\/contributions\/([^/]+)\/(adopt|reject|rollback)$/, @@ -1293,7 +1296,9 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function ); const action = collaborationDecisionMatch[2]; if (action === "adopt" || action === "reject" || action === "rollback") { - return json(yield* operations.decideTeamCollaborationContribution(contributionId, action)); + return Response.json( + yield* operations.decideTeamCollaborationContribution(contributionId, action), + ); } } @@ -1307,11 +1312,11 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "OPERATION_FAILED", "Sync & Backup preview failed.", ); - return json(yield* operations.previewRemoteLibrary(body.preferences)); + return Response.json(yield* operations.previewRemoteLibrary(body.preferences)); } if (url.pathname === "/api/v2/settings/remote-library/status" && request.method === "GET") { - return json(yield* operations.remoteLibrary("status")); + return Response.json(yield* operations.remoteLibrary("status")); } const workspaceResponse = yield* routeWorkspaceSettings( request, @@ -1322,7 +1327,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function if (workspaceResponse) return workspaceResponse; if (url.pathname === "/api/v2/settings/remote-library/shares" && request.method === "GET") { - return json(yield* operations.remoteLibraryShare("list")); + return Response.json(yield* operations.remoteLibraryShare("list")); } if ( @@ -1340,7 +1345,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "Private share details are required.", ); const input: CreateRemoteLibraryShareRequest = { ...body }; - return json(yield* operations.remoteLibraryShare("create", input)); + return Response.json(yield* operations.remoteLibraryShare("create", input)); } const match = url.pathname.match( /^\/api\/v2\/settings\/remote-library\/shares\/([^/]+)\/(accept|import|revoke)$/, @@ -1358,21 +1363,21 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function "Private share ID is malformed.", ), }); - return json( + return Response.json( yield* operations.remoteLibraryShare(shareAction, { share_id: shareId, }), ); } } - return json( + return Response.json( { error: { code: "NOT_FOUND", message: "Unknown private share action.", }, }, - 404, + { status: 404 }, ); } @@ -1382,7 +1387,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const unauthorized = mutationFailure(request, context, "A same-origin request is required."); if (unauthorized) return unauthorized; const remoteAction: RemoteLibraryAction = action; - return json(yield* operations.remoteLibrary(remoteAction)); + return Response.json(yield* operations.remoteLibrary(remoteAction)); } } @@ -1399,7 +1404,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function const input: UpdateDesktopScheduleRequest = { jobs: body.jobs.map((job) => ({ ...job })), }; - return json(yield* operations.updateSchedule(input)); + return Response.json(yield* operations.updateSchedule(input)); } if (url.pathname === "/api/v2/settings/remote-library" && request.method === "POST") { @@ -1417,7 +1422,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function api_key: body.api_key, preferences: { ...body.preferences }, }; - return json(yield* operations.updateRemoteSettings(input)); + return Response.json(yield* operations.updateRemoteSettings(input)); } if (url.pathname === "/api/v2/settings/onboarding" && request.method === "POST") { @@ -1435,7 +1440,7 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function hook_harnesses: [...body.hook_harnesses], features: { ...body.features }, }; - return json(yield* operations.applyOnboarding(input)); + return Response.json(yield* operations.applyOnboarding(input)); } return null; @@ -1448,5 +1453,6 @@ export function handleDashboardApplicationRoute( ) { return routeApplicationRequest(request, url, context).pipe( Effect.catch((error) => Effect.succeed(dashboardOperationErrorResponse(error))), + Effect.map((response) => (response ? withDashboardCors(response) : null)), ); } diff --git a/apps/local/src/routes/core.ts b/apps/local/src/routes/core.ts index 3bb0a15b..55f67493 100644 --- a/apps/local/src/routes/core.ts +++ b/apps/local/src/routes/core.ts @@ -1,6 +1,6 @@ import type { Database } from "bun:sqlite"; +import { Schema } from "effect"; -import type { BadgeFormat } from "@selftune/runtime/badge/badge-data"; import type { DashboardActionEvent, OverviewResponse, @@ -17,7 +17,7 @@ import { import { doctor } from "@selftune/runtime/observability"; import type { StatusResult } from "@selftune/runtime/status"; import { computeStatus } from "@selftune/runtime/status"; -import type { EvolutionAuditEntry, EvolutionEvidenceEntry } from "@selftune/runtime/types"; +import type { EvolutionEvidenceEntry } from "@selftune/runtime/types"; import { dashboardCorsHeaders, withDashboardCors } from "../dashboard-http.js"; import type { ActionRunner } from "./index.js"; @@ -73,7 +73,7 @@ async function computeStatusFromDb(db: Database): Promise { const telemetry = querySessionTelemetry(db); const skillRecords = querySkillUsageRecords(db); const queryRecords = queryQueryLog(db); - const auditEntries = queryEvolutionAudit(db) as EvolutionAuditEntry[]; + const auditEntries = queryEvolutionAudit(db); const doctorResult = await doctor(); return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult); } @@ -103,19 +103,21 @@ export function createDashboardCoreRoutes(options: DashboardCoreRouteOptions): D let cachedStatus: StatusResult | null = null; let lastStatusRefreshAt = 0; - let statusRefresh: Promise | null = null; + let statusRefresh: Promise | null = null; const statusCacheTtlMs = 30_000; - const refreshStatus = async (force = false): Promise => { - const fresh = cachedStatus !== null && Date.now() - lastStatusRefreshAt < statusCacheTtlMs; - if (!force && fresh) return; + const refreshStatus = async (): Promise => { + if (cachedStatus !== null && Date.now() - lastStatusRefreshAt < statusCacheTtlMs) { + return cachedStatus; + } if (statusRefresh) return statusRefresh; statusRefresh = Promise.resolve(getStatusResult()).then((status) => { cachedStatus = status; lastStatusRefreshAt = Date.now(); + return status; }); try { - await statusRefresh; + return await statusRefresh; } finally { statusRefresh = null; } @@ -123,11 +125,12 @@ export function createDashboardCoreRoutes(options: DashboardCoreRouteOptions): D const getCachedStatus = async (): Promise => { if (!cachedStatus) { - await refreshStatus(true); - } else { - void refreshStatus(); + return refreshStatus(); } - return cachedStatus as StatusResult; + void refreshStatus().catch((cause) => { + console.error("Dashboard status refresh failed:", cause); + }); + return cachedStatus; }; const handle = async ( @@ -151,12 +154,11 @@ export function createDashboardCoreRoutes(options: DashboardCoreRouteOptions): D { status: 403, headers: dashboardCorsHeaders() }, ); } - let body: Record = {}; + let body: Schema.Json; try { - const parsed: unknown = await request.json(); - if (typeof parsed === "object" && parsed !== null) { - body = parsed as Record; - } + body = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json)), + )(await request.text()); } catch { return Response.json( { @@ -191,11 +193,8 @@ export function createDashboardCoreRoutes(options: DashboardCoreRouteOptions): D ); } const requestedFormat = url.searchParams.get("format"); - const validFormats = new Set(["svg", "markdown", "url"]); - const format: BadgeFormat = - requestedFormat && validFormats.has(requestedFormat) - ? (requestedFormat as BadgeFormat) - : "svg"; + const validFormats = ["svg", "markdown", "url"] as const; + const format = validFormats.find((candidate) => candidate === requestedFormat) ?? "svg"; return withDashboardCors(handleBadge(await getCachedStatus(), skillName, format)); } diff --git a/apps/local/src/routes/correction-studies.ts b/apps/local/src/routes/correction-studies.ts index 06cc6afb..f950be3a 100644 --- a/apps/local/src/routes/correction-studies.ts +++ b/apps/local/src/routes/correction-studies.ts @@ -1,4 +1,13 @@ import { dashboardCorsHeaders, sameOriginFailure } from "../dashboard-http.js"; +import { Option, Schema } from "effect"; +import { CorrectionReviewRequest } from "../correction-review-request.js"; +import type { recordLocalCorrectionReviewDecision } from "../correction-review-service.js"; +import { + ExplicitCorrectionStudyRequest, + type CorrectionStudyServiceResponse, +} from "../correction-study-service.js"; +import type { listCorrectionReviews } from "../correction-review-projection.js"; +import type { CorrectionSignalPage } from "@selftune/runtime/correction-study/signal-discovery"; const MAX_CORRECTION_STUDY_REQUEST_BYTES = 8 * 1024; const correctionEpisodeIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; @@ -32,21 +41,27 @@ export interface CorrectionStudyRouteOptions { * Captures one explicit correction episode. The domain owns idempotency; * transport deliberately forwards the decoded payload unchanged. */ - readonly captureExplicitCorrection: (input: unknown) => Promise; - readonly lookup: (episodeId: string) => Promise; + readonly captureExplicitCorrection: ( + input: ExplicitCorrectionStudyRequest, + ) => Promise; + readonly lookup: (episodeId: string) => Promise; readonly discoverSignals?: (input: { readonly limit: number; readonly cursor: string | null; - }) => Promise; - readonly recordReviewDecision?: (input: unknown) => Promise; - readonly listReviews?: (limit: number) => Promise; + }) => Promise; + readonly recordReviewDecision?: ( + input: CorrectionReviewRequest, + ) => Promise>; + readonly listReviews?: ( + limit: number, + ) => Promise<{ readonly items: ReturnType }>; } function errorResponse(status: number, code: string, message: string): Response { return Response.json({ error: { code, message } }, { status, headers: dashboardCorsHeaders() }); } -async function boundedJson(request: Request): Promise { +async function boundedText(request: Request): Promise { const declaredLength = Number.parseInt(request.headers.get("content-length") ?? "", 10); if (Number.isFinite(declaredLength) && declaredLength > MAX_CORRECTION_STUDY_REQUEST_BYTES) { throw new RangeError("too large"); @@ -69,18 +84,18 @@ async function boundedJson(request: Request): Promise { } body += decoder.decode(chunk.value, { stream: true }); } - return JSON.parse(body + decoder.decode()); + return body + decoder.decode(); } function serviceErrorResponse( - error: unknown, + cause: unknown, fallbackStatus: number, fallbackCode: string, fallbackMessage: string, ): Response { - if (error instanceof CorrectionStudyServiceError) { - const status = error.status >= 400 && error.status <= 599 ? error.status : 409; - return errorResponse(status, error.code, error.message); + if (cause instanceof CorrectionStudyServiceError) { + const status = cause.status >= 400 && cause.status <= 599 ? cause.status : 409; + return errorResponse(status, cause.code, cause.message); } return errorResponse(fallbackStatus, fallbackCode, fallbackMessage); } @@ -152,15 +167,30 @@ export function createCorrectionStudyRoutes( if (unauthorized) return unauthorized; } const input = signalQuery(url); - if (!input || !options.listReviews) + if (!input) + return errorResponse( + 400, + "INVALID_CORRECTION_REVIEW_QUERY", + "Review listing requires a limit from 1 to 128 and a bounded cursor.", + ); + if (!options.listReviews) return errorResponse( 503, "CORRECTION_REVIEW_UNAVAILABLE", "Correction review is unavailable.", ); - return Response.json(await options.listReviews(input.limit), { - headers: dashboardCorsHeaders(), - }); + try { + return Response.json(await options.listReviews(input.limit), { + headers: dashboardCorsHeaders(), + }); + } catch (cause) { + return serviceErrorResponse( + cause, + 503, + "CORRECTION_REVIEW_UNAVAILABLE", + "Correction review is unavailable.", + ); + } } if ( @@ -177,7 +207,16 @@ export function createCorrectionStudyRoutes( ); } try { - return Response.json(await options.recordReviewDecision(await boundedJson(request)), { + const payload: unknown = JSON.parse(await boundedText(request)); + const input = Schema.decodeUnknownOption(CorrectionReviewRequest)(payload); + if (Option.isNone(input)) { + return errorResponse( + 400, + "INVALID_CORRECTION_REVIEW", + "A review must name a candidate, action, reason, and immutable manifest.", + ); + } + return Response.json(await options.recordReviewDecision(input.value), { headers: dashboardCorsHeaders(), }); } catch (error) { @@ -211,8 +250,16 @@ export function createCorrectionStudyRoutes( const unauthorized = sameOriginFailure(request, allowedOrigins); if (unauthorized) return unauthorized; try { - const input = await boundedJson(request); - return Response.json(await options.captureExplicitCorrection(input), { + const payload: unknown = JSON.parse(await boundedText(request)); + const input = Schema.decodeUnknownOption(ExplicitCorrectionStudyRequest)(payload); + if (Option.isNone(input)) { + return errorResponse( + 400, + "INVALID_CORRECTION_STUDY_REQUEST", + "The explicit correction payload must match the correction study contract.", + ); + } + return Response.json(await options.captureExplicitCorrection(input.value), { status: 200, headers: dashboardCorsHeaders(), }); diff --git a/apps/local/src/routes/evaluation-draft-submissions.ts b/apps/local/src/routes/evaluation-draft-submissions.ts deleted file mode 100644 index a3d170ae..00000000 --- a/apps/local/src/routes/evaluation-draft-submissions.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - CloudEvaluationTarget, - CloudEvaluationTargetBlocker, -} from "@selftune/runtime/evolution/cloud-evaluation-target-client"; -import type { CloudEvaluationSubmissionReceipt } from "@selftune/runtime/evolution/cloud-evaluation-submission-client"; - -import { dashboardCorsHeaders, sameOriginFailure } from "../dashboard-http.js"; - -const MAX_REQUEST_BYTES = 8 * 1024; -const draftIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; - -export interface EvaluationDraftTargetResponse { - readonly draft_id: string; - readonly lifecycle: "prepared" | "submitted" | "stale"; - readonly run_id: string | null; - readonly targets: readonly CloudEvaluationTarget[]; - readonly blockers: readonly CloudEvaluationTargetBlocker[]; -} - -export interface EvaluationDraftSubmissionRoutes { - readonly handle: ( - request: Request, - url: URL, - allowedOrigins: ReadonlySet, - ) => Promise; -} - -export interface EvaluationDraftSubmissionRouteOptions { - readonly discover: (draftId: string) => Promise; - readonly submit: (draftId: string, target: unknown) => Promise; -} - -function errorResponse(status: number, code: string, message: string): Response { - return Response.json({ error: { code, message } }, { status, headers: dashboardCorsHeaders() }); -} - -async function boundedJson(request: Request): Promise { - const length = Number.parseInt(request.headers.get("content-length") ?? "", 10); - if (Number.isFinite(length) && length > MAX_REQUEST_BYTES) throw new RangeError("too large"); - if (!request.body) throw new TypeError("A target selection is required."); - const reader = request.body.getReader(); - const decoder = new TextDecoder(); - let received = 0; - let body = ""; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - received += chunk.value.byteLength; - if (received > MAX_REQUEST_BYTES) { - await reader.cancel(); - throw new RangeError("too large"); - } - body += decoder.decode(chunk.value, { stream: true }); - } - return JSON.parse(body + decoder.decode()); -} - -export function createEvaluationDraftSubmissionRoutes( - options: EvaluationDraftSubmissionRouteOptions, -): EvaluationDraftSubmissionRoutes { - return { - async handle(request, url, allowedOrigins) { - const match = /^\/api\/v2\/trace-candidates\/([^/]+)\/(targets|submit)$/.exec(url.pathname); - if (!match) return null; - const unauthorized = sameOriginFailure(request, allowedOrigins); - if (unauthorized) return unauthorized; - const [, encodedDraftId, action] = match; - let draftId: string; - try { - draftId = decodeURIComponent(encodedDraftId); - } catch { - return errorResponse( - 400, - "INVALID_EVALUATION_DRAFT", - "The evaluation draft id is invalid.", - ); - } - if (!draftIdPattern.test(draftId)) { - return errorResponse( - 400, - "INVALID_EVALUATION_DRAFT", - "The evaluation draft id is invalid.", - ); - } - if (action === "targets") { - if (request.method !== "GET") return null; - try { - return Response.json(await options.discover(draftId), { - headers: dashboardCorsHeaders(), - }); - } catch (error) { - return errorResponse( - 409, - "EVALUATION_DRAFT_UNAVAILABLE", - error instanceof Error ? error.message : "The evaluation draft is unavailable.", - ); - } - } - if (request.method !== "POST") return null; - try { - const body = await boundedJson(request); - return Response.json(await options.submit(draftId, body), { - status: 202, - headers: dashboardCorsHeaders(), - }); - } catch (error) { - if (error instanceof RangeError) { - return errorResponse( - 413, - "EVALUATION_DRAFT_REQUEST_TOO_LARGE", - "Evaluation target selection cannot exceed 8 KiB.", - ); - } - return errorResponse( - 409, - "EVALUATION_DRAFT_SUBMISSION_REJECTED", - error instanceof Error ? error.message : "The evaluation draft could not be submitted.", - ); - } - }, - }; -} diff --git a/apps/local/src/routes/hooks.ts b/apps/local/src/routes/hooks.ts index 7a391de8..e13224ad 100644 --- a/apps/local/src/routes/hooks.ts +++ b/apps/local/src/routes/hooks.ts @@ -14,6 +14,7 @@ import { runSessionStopHook } from "@selftune/harness-claude-code/hooks/session- import { runSkillChangeGuardHook } from "@selftune/harness-claude-code/hooks/skill-change-guard"; import { runSkillEditCaptureHook } from "@selftune/harness-claude-code/hooks/skill-edit-capture"; import { runSkillEvalHook } from "@selftune/harness-claude-code/hooks/skill-eval"; +import { Option, Schema } from "effect"; const MAX_HOOK_BODY_BYTES = 2 * 1024 * 1024; const SHARED_SESSION_QUEUE = "__selftune_shared_session__"; @@ -23,7 +24,12 @@ const SYNCHRONOUS_HOOKS: ReadonlySet = new Set([ "evolution-guard", "skill-edit-capture", ]); -const KNOWN_HOOKS: ReadonlySet = new Set(CLAUDE_HOOK_NAMES); +const isHookName = Schema.is(Schema.Literals(CLAUDE_HOOK_NAMES)); +const SessionQueueIdentity = Schema.fromJsonString( + Schema.Struct({ + session_id: Schema.String.check(Schema.isNonEmpty()), + }), +); export type HookRunner = (rawStdin: string) => Promise; export type HookRunners = Readonly>; @@ -71,21 +77,8 @@ async function readHookBody(request: Request): Promise { } function sessionQueueKey(rawStdin: string): string { - try { - const payload: unknown = JSON.parse(rawStdin); - if ( - typeof payload === "object" && - payload !== null && - "session_id" in payload && - typeof payload.session_id === "string" && - payload.session_id.length > 0 - ) { - return `session:${payload.session_id}`; - } - } catch { - // Malformed hook input remains fail-open and uses the shared serial queue. - } - return SHARED_SESSION_QUEUE; + const identity = Schema.decodeUnknownOption(SessionQueueIdentity)(rawStdin); + return Option.isSome(identity) ? `session:${identity.value.session_id}` : SHARED_SESSION_QUEUE; } class SessionHookQueue { @@ -102,8 +95,8 @@ class SessionHookQueue { const current = previous .catch(() => undefined) .then(task) - .catch((error: unknown) => { - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + .catch((cause: unknown) => { + const message = cause instanceof Error ? (cause.stack ?? cause.message) : String(cause); this.logError(`SelfTune queued hook ${hookName} failed: ${message}\n`); }) .finally(() => { @@ -139,7 +132,7 @@ export function createHookRoutes(options: HookRouteOptions = {}): HookRoutes { const match = url.pathname.match(/^\/api\/hooks\/([^/]+)$/); if (!match || request.method !== "POST") return null; const hookName = match[1]; - if (!hookName || !KNOWN_HOOKS.has(hookName)) { + if (!isHookName(hookName)) { return Response.json({ error: { code: "HOOK_NOT_FOUND" } }, { status: 404 }); } @@ -153,23 +146,22 @@ export function createHookRoutes(options: HookRouteOptions = {}): HookRoutes { throw error; } - const typedHookName = hookName as ClaudeHookName; - const runner = runners[typedHookName]; - if (SYNCHRONOUS_HOOKS.has(typedHookName)) { + const runner = runners[hookName]; + if (SYNCHRONOUS_HOOKS.has(hookName)) { try { return Response.json(await runner(rawStdin)); } catch (error) { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - logError(`SelfTune synchronous hook ${typedHookName} failed: ${message}\n`); + logError(`SelfTune synchronous hook ${hookName} failed: ${message}\n`); return Response.json(SILENT_HOOK_SUCCESS); } } - queue.admit(sessionQueueKey(rawStdin), typedHookName, async () => { + queue.admit(sessionQueueKey(rawStdin), hookName, async () => { const result = await runner(rawStdin); if (result.stderr) logError(result.stderr); if (result.exit_code !== 0) { - logError(`SelfTune queued hook ${typedHookName} returned exit code ${result.exit_code}.\n`); + logError(`SelfTune queued hook ${hookName} returned exit code ${result.exit_code}.\n`); } }); return Response.json({ accepted: true }, { status: 202 }); diff --git a/apps/local/src/routes/library-transfer.test.ts b/apps/local/src/routes/library-transfer.test.ts index dc331c3d..bfc93048 100644 --- a/apps/local/src/routes/library-transfer.test.ts +++ b/apps/local/src/routes/library-transfer.test.ts @@ -1,8 +1,41 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; -import { backedArtifact } from "./library-transfer.js"; +import { backedArtifact, decodeLibraryBackup } from "./library-transfer.js"; describe("library share backup resolution", () => { + it("keeps the exact requested revision when malformed entries precede it", () => { + const backup = decodeLibraryBackup({ + snapshot: { + snapshotId: "snapshot-1", + artifacts: [ + null, + 42, + { artifactId: {}, artifactType: "skill_revision" }, + { artifactId: "skill/reviewer-plus/hash", artifactType: "skill_revision" }, + { artifactId: "skill/reviewer/hash", artifactType: "draft" }, + { artifactId: "skill/reviewer/revision", artifactType: "skill_revision" }, + ], + }, + }); + expect(backedArtifact(backup, "reviewer")).toEqual({ + snapshotId: "snapshot-1", + artifactId: "skill/reviewer/revision", + }); + }); + + it.each( + [ + null, + [], + {}, + { snapshot: { snapshotId: "", artifacts: [] } }, + { snapshot: { snapshotId: 123, artifacts: [] } }, + { snapshot: { snapshotId: "snapshot", artifacts: "invalid" } }, + ].map((value) => ({ value })), + )("rejects malformed backup envelopes: $value", ({ value }) => { + expect(backedArtifact(decodeLibraryBackup(value), "reviewer")).toBeNull(); + }); + it("selects the requested skill revision from the returned immutable snapshot", () => { expect( backedArtifact( diff --git a/apps/local/src/routes/library-transfer.ts b/apps/local/src/routes/library-transfer.ts index 6268e324..e99bc20e 100644 --- a/apps/local/src/routes/library-transfer.ts +++ b/apps/local/src/routes/library-transfer.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { Option, flow } from "effect"; +import { RemoteArtifact, RemoteSnapshot } from "@selftune/control-plane"; import { DashboardOperationError, type DashboardOperations } from "../dashboard-operations.js"; import { dashboardCorsHeaders, sameOriginFailure } from "../dashboard-http.js"; @@ -60,26 +62,32 @@ function licenseTerms(body: typeof LicenseDraftTermsBody.Type) { }; } -export function backedArtifact(value: unknown, skillId: string) { - const root = - typeof value === "object" && value !== null ? (value as Record) : null; - const snapshot = - typeof root?.snapshot === "object" && root.snapshot !== null - ? (root.snapshot as Record) - : null; - const snapshotId = typeof snapshot?.snapshotId === "string" ? snapshot.snapshotId : null; - const artifacts = Array.isArray(snapshot?.artifacts) ? snapshot.artifacts : []; - const artifact = artifacts.find((candidate) => { - if (typeof candidate !== "object" || candidate === null) return false; - const id = Reflect.get(candidate, "artifactId"); - const type = Reflect.get(candidate, "artifactType"); - return ( - type === "skill_revision" && typeof id === "string" && id.startsWith(`skill/${skillId}/`) - ); - }); - const artifactId = - artifact && typeof artifact === "object" ? Reflect.get(artifact, "artifactId") : null; - return snapshotId && typeof artifactId === "string" ? { snapshotId, artifactId } : null; +const BackupArtifact = Schema.NullOr( + Schema.Struct({ + artifactId: RemoteArtifact.fields.artifactId, + artifactType: RemoteArtifact.fields.artifactType, + }), +).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))); +const LibraryBackup = Schema.Struct({ + snapshot: Schema.Struct({ + snapshotId: RemoteSnapshot.fields.snapshotId.check(Schema.isNonEmpty()), + artifacts: Schema.Array(BackupArtifact), + }), +}); +export const decodeLibraryBackup = flow( + Schema.decodeUnknownOption(LibraryBackup), + Option.getOrUndefined, +); + +export function backedArtifact(value: typeof LibraryBackup.Type | undefined, skillId: string) { + const snapshot = value?.snapshot; + if (!snapshot) return null; + const artifact = snapshot.artifacts.find( + (candidate) => + candidate?.artifactType === "skill_revision" && + candidate.artifactId.startsWith(`skill/${skillId}/`), + ); + return artifact ? { snapshotId: snapshot.snapshotId, artifactId: artifact.artifactId } : null; } function invalidBody(operation: string, message: string): DashboardOperationError { @@ -96,7 +104,7 @@ const decode = Effect.fn("DashboardApplication.decodeLibraryTransfer")(function* S extends Schema.Top, >(request: Request, operation: string, schema: S, message: string) { const input = yield* Effect.tryPromise({ - try: () => request.json() as Promise, + try: () => request.json(), catch: () => invalidBody(operation, message), }); return yield* Schema.decodeUnknownEffect(schema)(input).pipe( @@ -222,7 +230,7 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary "Share details are required.", ); const backup = yield* operations.backupLibrarySkill(body.skill_id); - const artifact = backedArtifact(backup, body.skill_id); + const artifact = backedArtifact(decodeLibraryBackup(backup), body.skill_id); if (!artifact) { return yield* invalidBody( "library.skill.share", diff --git a/apps/local/src/routes/otlp.ts b/apps/local/src/routes/otlp.ts index 3a603304..2806ccaf 100644 --- a/apps/local/src/routes/otlp.ts +++ b/apps/local/src/routes/otlp.ts @@ -155,8 +155,8 @@ export function createOtlpRoutes(ingest: OtlpIngest): OtlpRoutes { }) .then( (outcome) => outcome, - (error: unknown) => - error instanceof OtlpInvalidPayloadError ? ("invalid" as const) : ("failed" as const), + (cause: unknown) => + cause instanceof OtlpInvalidPayloadError ? ("invalid" as const) : ("failed" as const), ); void completion.then(release); let timeout: ReturnType | undefined; diff --git a/apps/local/src/routes/overview.ts b/apps/local/src/routes/overview.ts index 3e3552a8..3b4b2889 100644 --- a/apps/local/src/routes/overview.ts +++ b/apps/local/src/routes/overview.ts @@ -32,6 +32,17 @@ import { import { buildTrustWatchlist } from "@selftune/runtime/trust-model"; import { loadWatchedSkills } from "@selftune/runtime/watchlist"; +function summarizePendingProposal( + proposal: OverviewResponse["overview"]["pending_proposals"][number], +): DashboardShellResponse["pending_proposals"][number] { + const summary: DashboardShellResponse["pending_proposals"][number] = { + proposal_id: proposal.proposal_id, + action: proposal.action, + }; + if (proposal.skill_name) summary.skill_name = proposal.skill_name; + return summary; +} + export function summarizeOverview(response: OverviewResponse): DashboardShellResponse { return { version: response.version, @@ -39,11 +50,7 @@ export function summarizeOverview(response: OverviewResponse): DashboardShellRes latest_evolutions: response.overview.evolution.flatMap((entry) => entry.skill_name ? [{ timestamp: entry.timestamp, skill_name: entry.skill_name }] : [], ), - pending_proposals: response.overview.pending_proposals.map((proposal) => ({ - proposal_id: proposal.proposal_id, - action: proposal.action, - ...(proposal.skill_name ? { skill_name: proposal.skill_name } : {}), - })), + pending_proposals: response.overview.pending_proposals.map(summarizePendingProposal), }; } @@ -64,11 +71,7 @@ export function handleDashboardShell(db: Database, version: string): Response { ORDER BY timestamp DESC`, ) .all(); - const pendingProposals = getPendingProposals(db).map((proposal) => ({ - proposal_id: proposal.proposal_id, - action: proposal.action, - ...(proposal.skill_name ? { skill_name: proposal.skill_name } : {}), - })); + const pendingProposals = getPendingProposals(db).map(summarizePendingProposal); const response: DashboardShellResponse = { version, skills, @@ -173,8 +176,10 @@ function buildAutonomyStatus(db: Database, input: AutonomyStatusInput): Autonomy let lastRun: string | null = null; try { const row = db - .query(`SELECT timestamp FROM orchestrate_runs ORDER BY timestamp DESC LIMIT 1`) - .get() as { timestamp: string } | null; + .query<{ timestamp: string }, []>( + `SELECT timestamp FROM orchestrate_runs ORDER BY timestamp DESC LIMIT 1`, + ) + .get(); lastRun = row?.timestamp ?? null; } catch { // Table may not exist diff --git a/apps/local/src/routes/report.ts b/apps/local/src/routes/report.ts index ca8f22b7..07b2a37a 100644 --- a/apps/local/src/routes/report.ts +++ b/apps/local/src/routes/report.ts @@ -81,7 +81,7 @@ function buildReportHTML( ); const passRateDisplay = skill.passRate !== null ? `${Math.round(skill.passRate * 100)}%` : "No data"; - const trendArrows: Record = { + const trendArrows = { up: "\u2191", down: "\u2193", stable: "\u2192", diff --git a/apps/local/src/routes/skill-report.ts b/apps/local/src/routes/skill-report.ts index 511e641e..e3cf9fa7 100644 --- a/apps/local/src/routes/skill-report.ts +++ b/apps/local/src/routes/skill-report.ts @@ -7,6 +7,9 @@ */ import type { Database } from "bun:sqlite"; +import { Option, Schema } from "effect"; +import { optionalEvidence } from "@selftune/runtime/utils/transcript-contract"; +import { CreatePackageEvaluationWatchSummary } from "@selftune/runtime/types"; import { parseCursorParam } from "@selftune/runtime/dashboard-contract"; import { @@ -32,10 +35,35 @@ import { getSkillTestingReadiness, readCanonicalPackageEvaluationArtifact, } from "@selftune/runtime/testing-readiness"; -import type { - CreatePackageEvaluationSummary, - CreatePackageEvaluationWatchSummary, -} from "@selftune/runtime/types"; +import type { CreatePackageEvaluationSummary } from "@selftune/runtime/types"; + +const decodeWatch = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + watch: optionalEvidence(CreatePackageEvaluationWatchSummary), + }), + ), +); +const decodeActions = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Schema.Json))); +const decodeAction = Schema.decodeUnknownOption( + Schema.Struct({ + skill: Schema.String, + action: optionalEvidence(Schema.String), + elapsed_ms: optionalEvidence( + Schema.Number.check(Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0)), + ), + llm_calls: optionalEvidence( + Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + ), + }), +); +const decodeSource = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + metadata: optionalEvidence(Schema.Struct({ miss_type: optionalEvidence(Schema.String) })), + }), + ), +); function readMeasuredDelta(summary: { candidate_acceptance?: { @@ -114,26 +142,22 @@ function hydrateWatchResult(summary: CreatePackageEvaluationWatchSummary): Watch recommended_command: summary.recommended_command, gradeAlert: summary.grade_alert, gradeRegression: summary.grade_regression, - ...(summary.efficiency_alert || summary.efficiency_regression - ? { - efficiencyAlert: summary.efficiency_alert ?? null, - efficiencyRegression: summary.efficiency_regression ?? null, - } - : {}), + efficiencyAlert: summary.efficiency_alert ?? null, + efficiencyRegression: summary.efficiency_regression ?? null, }; } function readWatchTrustScore(db: Database, skillName: string): number | null { const row = db - .query( + .query<{ summary_json: string }, (string | number)[]>( `SELECT summary_json FROM package_evaluation_reports WHERE skill_name = ?`, ) - .get(skillName) as { summary_json: string } | null; + .get(skillName); - const parsedSummary = row?.summary_json ? safeParseJson(row.summary_json) : null; - const summaryWatch = parsedSummary?.watch as CreatePackageEvaluationWatchSummary | undefined; + const parsedSummary = row?.summary_json ? decodeWatch(row.summary_json) : Option.none(); + const summaryWatch = Option.getOrUndefined(parsedSummary)?.watch; if (summaryWatch?.snapshot) { return computeWatchTrustScore(hydrateWatchResult(summaryWatch)); } @@ -168,7 +192,21 @@ export function handleSkillReport( // 1. Evolution audit with eval_snapshot const evolution = db - .query( + .query< + { + timestamp: string; + proposal_id: string; + skill_name: string | null; + action: string; + details: string; + eval_snapshot_json: string | null; + validation_mode: string | null; + validation_agent: string | null; + validation_fixture_id: string | null; + validation_evidence_ref: string | null; + }, + (string | number)[] + >( `SELECT timestamp, proposal_id, skill_name, action, details, eval_snapshot_json, validation_mode, validation_agent, validation_fixture_id, validation_evidence_ref FROM evolution_audit @@ -176,18 +214,7 @@ export function handleSkillReport( ORDER BY timestamp DESC LIMIT 100`, ) - .all(skillName, skillName) as Array<{ - timestamp: string; - proposal_id: string; - skill_name: string | null; - action: string; - details: string; - eval_snapshot_json: string | null; - validation_mode: string | null; - validation_agent: string | null; - validation_fixture_id: string | null; - validation_evidence_ref: string | null; - }>; + .all(skillName, skillName); const evolutionWithSnapshot = evolution.map((e) => ({ ...e, eval_snapshot: e.eval_snapshot_json ? safeParseJson(e.eval_snapshot_json) : null, @@ -205,36 +232,32 @@ export function handleSkillReport( // 3. Selftune resource usage from orchestrate runs that touched this skill const orchestrateRows = db - .query( + .query< + { + skill_actions_json: string; + }, + (string | number)[] + >( `SELECT skill_actions_json FROM orchestrate_runs WHERE skill_actions_json LIKE ? ESCAPE '\\'`, ) - .all( - `%${skillName.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`, - ) as Array<{ - skill_actions_json: string; - }>; + .all(`%${skillName.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`); let totalLlmCalls = 0; let totalSelftunElapsedMs = 0; let selftuneRunCount = 0; for (const row of orchestrateRows) { - try { - const actions = JSON.parse(row.skill_actions_json) as Array<{ - skill: string; - action?: string; - elapsed_ms?: number; - llm_calls?: number; - }>; - for (const a of actions) { - if (a.skill !== skillName || a.action === "skip" || a.action === "watch") continue; - if (a.elapsed_ms === undefined && a.llm_calls === undefined) continue; - totalSelftunElapsedMs += a.elapsed_ms ?? 0; - totalLlmCalls += a.llm_calls ?? 0; - selftuneRunCount++; - } - } catch { - // skip malformed JSON + const actions = decodeActions(row.skill_actions_json); + if (Option.isNone(actions)) continue; + for (const value of actions.value) { + const action = decodeAction(value); + if (Option.isNone(action)) continue; + const a = action.value; + if (a.skill !== skillName || a.action === "skip" || a.action === "watch") continue; + if (a.elapsed_ms === undefined && a.llm_calls === undefined) continue; + totalSelftunElapsedMs += a.elapsed_ms ?? 0; + totalLlmCalls += a.llm_calls ?? 0; + selftuneRunCount++; } } const selftuneStats = { @@ -272,7 +295,7 @@ export function handleSkillReport( if (invCursor) { invocationsWithConfidence = db - .query( + .query<(typeof invocationsWithConfidence)[number], (string | number)[]>( `SELECT si.occurred_at as timestamp, si.session_id, si.skill_name, si.invocation_mode, si.triggered, si.confidence, si.tool_name, si.agent_type, COALESCE(si.query, p.prompt_text) as query, si.source, @@ -290,10 +313,10 @@ export function handleSkillReport( invCursor.timestamp, String(invCursor.id), invFetchLimit, - ) as typeof invocationsWithConfidence; + ); } else { invocationsWithConfidence = db - .query( + .query<(typeof invocationsWithConfidence)[number], (string | number)[]>( `SELECT si.occurred_at as timestamp, si.session_id, si.skill_name, si.invocation_mode, si.triggered, si.confidence, si.tool_name, si.agent_type, COALESCE(si.query, p.prompt_text) as query, si.source, @@ -304,7 +327,7 @@ export function handleSkillReport( ORDER BY si.occurred_at DESC, si.skill_invocation_id DESC LIMIT ?`, ) - .all(skillName, invFetchLimit) as typeof invocationsWithConfidence; + .all(skillName, invFetchLimit); } const invHasMore = invocationsWithConfidence.length > invLimit; @@ -335,7 +358,16 @@ export function handleSkillReport( // 5. Duration stats from execution_facts + missed trigger count const executionRow = db - .query( + .query< + { + avg_duration_ms: number; + total_duration_ms: number; + execution_count: number; + total_input_tokens: number; + total_output_tokens: number; + }, + (string | number)[] + >( `${skillSessionsCte} SELECT COALESCE(AVG(ef.duration_ms), 0) AS avg_duration_ms, @@ -346,27 +378,23 @@ export function handleSkillReport( FROM execution_facts ef WHERE ef.session_id IN (SELECT session_id FROM skill_sessions)`, ) - .get(skillName) as { - avg_duration_ms: number; - total_duration_ms: number; - execution_count: number; - total_input_tokens: number; - total_output_tokens: number; - } | null; + .get(skillName); // Missed triggers: checks where the skill was evaluated but did not fire const missedRow = db - .query( + .query<{ missed_triggers: number }, (string | number)[]>( `SELECT COUNT(*) AS missed_triggers FROM skill_invocations WHERE skill_name = ? AND triggered = 0`, ) - .get(skillName) as { missed_triggers: number } | null; + .get(skillName); // 5b. Execution metrics (enrichment columns from execution_facts) const skillSessionIds = db - .query(`SELECT DISTINCT session_id FROM skill_invocations WHERE skill_name = ?`) - .all(skillName) as Array<{ session_id: string }>; + .query<{ session_id: string }, (string | number)[]>( + `SELECT DISTINCT session_id FROM skill_invocations WHERE skill_name = ?`, + ) + .all(skillName); const executionMetrics = getExecutionMetrics( db, skillSessionIds.map((r) => r.session_id), @@ -378,7 +406,17 @@ export function handleSkillReport( // 6. Prompt texts — prefer matched prompts (the prompt that invoked the skill), // fall back to all prompts from sessions that used the skill. const promptSamples = db - .query( + .query< + { + prompt_text: string; + prompt_kind: string | null; + is_actionable: number; + occurred_at: string; + session_id: string; + is_matched: number; + }, + (string | number)[] + >( `${skillSessionsCte} SELECT p.prompt_text, p.prompt_kind, p.is_actionable, p.occurred_at, p.session_id, CASE WHEN si.matched_prompt_id IS NOT NULL THEN 1 ELSE 0 END AS is_matched @@ -391,18 +429,24 @@ export function handleSkillReport( ORDER BY is_matched DESC, p.occurred_at DESC LIMIT 50`, ) - .all(skillName, skillName) as Array<{ - prompt_text: string; - prompt_kind: string | null; - is_actionable: number; - occurred_at: string; - session_id: string; - is_matched: number; - }>; + .all(skillName, skillName); // 7. Session metadata for sessions that used this skill const sessionMeta = db - .query( + .query< + { + session_id: string; + platform: string | null; + model: string | null; + agent_cli: string | null; + branch: string | null; + workspace_path: string | null; + started_at: string | null; + ended_at: string | null; + completion_status: string | null; + }, + (string | number)[] + >( `${skillSessionsCte} SELECT s.session_id, s.platform, s.model, s.agent_cli, s.branch, s.workspace_path, s.started_at, s.ended_at, s.completion_status @@ -411,26 +455,16 @@ export function handleSkillReport( ORDER BY s.started_at DESC LIMIT 50`, ) - .all(skillName) as Array<{ - session_id: string; - platform: string | null; - model: string | null; - agent_cli: string | null; - branch: string | null; - workspace_path: string | null; - started_at: string | null; - ended_at: string | null; - completion_status: string | null; - }>; + .all(skillName); // 8. Description quality score — computed from latest evolution evidence const latestEvidence = db - .query( + .query<{ proposed_text: string | null; original_text: string | null }, (string | number)[]>( `SELECT proposed_text, original_text FROM evolution_evidence WHERE skill_name = ? AND (proposed_text IS NOT NULL OR original_text IS NOT NULL) ORDER BY timestamp DESC LIMIT 1`, ) - .get(skillName) as { proposed_text: string | null; original_text: string | null } | null; + .get(skillName); // Use the most recent description: deployed proposed_text, or fallback to original_text const currentDescriptionText = latestEvidence?.proposed_text ?? latestEvidence?.original_text; @@ -476,9 +510,7 @@ export function handleSkillReport( ): "canonical" | "repaired_trigger" | "repaired_contextual_miss" | "legacy_materialized" => { if (skillInvocationId.includes(":su:")) return "legacy_materialized"; if (captureMode === "repair") { - const rawSourceRef = safeParseJson(rawSourceRefJson) as { - metadata?: { miss_type?: string }; - } | null; + const rawSourceRef = Option.getOrUndefined(decodeSource(rawSourceRefJson)); if (triggered === 0 && rawSourceRef?.metadata?.miss_type === "contextual_read") { return "repaired_contextual_miss"; } @@ -489,7 +521,31 @@ export function handleSkillReport( // Fetch all invocations for this skill with joined prompt + session data const allInvocations = db - .query( + .query< + { + timestamp: string | null; + session_id: string; + skill_name: string; + invocation_mode: string | null; + triggered: number; + confidence: number | null; + tool_name: string | null; + agent_type: string | null; + inline_query: string | null; + source: string | null; + matched_prompt_id: string | null; + skill_scope: string | null; + skill_path: string | null; + skill_invocation_id: string; + capture_mode: string | null; + raw_source_ref: string | null; + prompt_text: string | null; + prompt_kind: string | null; + platform: string | null; + workspace_path: string | null; + }, + (string | number)[] + >( `SELECT si.occurred_at AS timestamp, si.session_id, si.skill_name, si.invocation_mode, si.triggered, si.confidence, si.tool_name, si.agent_type, si.query AS inline_query, si.source, @@ -503,28 +559,7 @@ export function handleSkillReport( WHERE si.skill_name = ? ORDER BY si.occurred_at DESC`, ) - .all(skillName) as Array<{ - timestamp: string | null; - session_id: string; - skill_name: string; - invocation_mode: string | null; - triggered: number; - confidence: number | null; - tool_name: string | null; - agent_type: string | null; - inline_query: string | null; - source: string | null; - matched_prompt_id: string | null; - skill_scope: string | null; - skill_path: string | null; - skill_invocation_id: string; - capture_mode: string | null; - raw_source_ref: string | null; - prompt_text: string | null; - prompt_kind: string | null; - platform: string | null; - workspace_path: string | null; - }>; + .all(skillName); const totalInv = allInvocations.length; const safeDiv = (num: number, den: number): number => (den > 0 ? num / den : 0); @@ -606,22 +641,24 @@ export function handleSkillReport( // Evolution state const evidenceCountRow = db - .query(`SELECT COUNT(*) AS cnt FROM evolution_evidence WHERE skill_name = ?`) - .get(skillName) as { cnt: number } | null; + .query<{ cnt: number }, (string | number)[]>( + `SELECT COUNT(*) AS cnt FROM evolution_evidence WHERE skill_name = ?`, + ) + .get(skillName); const evolutionCountRow = db - .query( + .query<{ cnt: number }, (string | number)[]>( `SELECT COUNT(*) AS cnt FROM evolution_audit WHERE skill_name = ? OR (skill_name IS NULL AND proposal_id LIKE 'evo-' || ? || '-%')`, ) - .get(skillName, skillName) as { cnt: number } | null; + .get(skillName, skillName); const latestAuditRow = db - .query( + .query<{ action: string; timestamp: string }, (string | number)[]>( `SELECT action, timestamp FROM evolution_audit WHERE (skill_name = ? OR (skill_name IS NULL AND proposal_id LIKE 'evo-' || ? || '-%')) AND action IN ('deployed', 'rolled_back', 'validated', 'proposed', 'approved') ORDER BY timestamp DESC LIMIT 1`, ) - .get(skillName, skillName) as { action: string; timestamp: string } | null; + .get(skillName, skillName); const evolution_state = { has_evidence: (evidenceCountRow?.cnt ?? 0) > 0, @@ -634,26 +671,28 @@ export function handleSkillReport( // Data hygiene const namingVariants = db - .query(`SELECT DISTINCT skill_name FROM skill_invocations WHERE lower(skill_name) = lower(?)`) - .all(skillName) as Array<{ skill_name: string }>; + .query<{ skill_name: string }, (string | number)[]>( + `SELECT DISTINCT skill_name FROM skill_invocations WHERE lower(skill_name) = lower(?)`, + ) + .all(skillName); const sourceBreakdown = db - .query( + .query<{ source: string; count: number }, (string | number)[]>( `SELECT COALESCE(source, '(null)') AS source, COUNT(*) AS count FROM skill_invocations WHERE skill_name = ? GROUP BY source ORDER BY count DESC`, ) - .all(skillName) as Array<{ source: string; count: number }>; + .all(skillName); const promptKindBreakdown = db - .query( + .query<{ kind: string; count: number }, (string | number)[]>( `SELECT COALESCE(p.prompt_kind, '(null)') AS kind, COUNT(*) AS count FROM skill_invocations si LEFT JOIN prompts p ON si.matched_prompt_id = p.prompt_id WHERE si.skill_name = ? GROUP BY p.prompt_kind ORDER BY count DESC`, ) - .all(skillName) as Array<{ kind: string; count: number }>; + .all(skillName); const observationBreakdownMap = new Map< "canonical" | "repaired_trigger" | "repaired_contextual_miss" | "legacy_materialized", diff --git a/apps/local/src/routes/skill-set-collision-readiness.ts b/apps/local/src/routes/skill-set-collision-readiness.ts index be05b06e..0aec9a63 100644 --- a/apps/local/src/routes/skill-set-collision-readiness.ts +++ b/apps/local/src/routes/skill-set-collision-readiness.ts @@ -1,4 +1,5 @@ import type { Database } from "bun:sqlite"; +import { Option, Schema } from "effect"; import { checkSkillSetCollisionReadiness } from "@selftune/runtime/skill-sets/collision-readiness"; import { @@ -7,20 +8,20 @@ import { querySkillUsageRecords, } from "@selftune/runtime/localdb/queries"; -function readSkillNames(body: Record): string[] | null { - const value = body.skillNames; - if (!Array.isArray(value) || value.length < 2 || value.length > 50) return null; - if (!value.every((name) => typeof name === "string" && name.trim().length > 0)) return null; - const names = value.map((name) => name.trim()); - return new Set(names).size === names.length ? names : null; -} +const decodeSkillNames = Schema.decodeUnknownOption( + Schema.Struct({ + skillNames: Schema.Array(Schema.String).check(Schema.isMinLength(2), Schema.isMaxLength(50)), + }), +); -export function handleSkillSetCollisionReadiness( - body: Record, - db: Database, -): Response { - const skillNames = readSkillNames(body); - if (!skillNames) { +export function handleSkillSetCollisionReadiness(body: Schema.Json, db: Database): Response { + const input = decodeSkillNames(body); + const skillNames = Option.isSome(input) ? input.value.skillNames.map((name) => name.trim()) : []; + if ( + skillNames.length === 0 || + skillNames.some((name) => name.length === 0) || + new Set(skillNames).size !== skillNames.length + ) { return Response.json( { success: false, diff --git a/apps/local/src/routes/trace-candidates.ts b/apps/local/src/routes/trace-candidates.ts index 324fe44f..a7e22d38 100644 --- a/apps/local/src/routes/trace-candidates.ts +++ b/apps/local/src/routes/trace-candidates.ts @@ -1,4 +1,10 @@ import { dashboardCorsHeaders, sameOriginFailure } from "../dashboard-http.js"; +import { Schema } from "effect"; +import { TraceCandidateRequest, type TraceCandidateReview } from "../trace-candidate-contract.js"; +import { + HistoricalSkillImprovementRequest, + type HistoricalSkillImprovementResponse, +} from "../historical-skill-improvement-service.js"; const MAX_TRACE_CANDIDATE_BYTES = 8 * 1024; @@ -11,11 +17,13 @@ export interface TraceCandidateRoutes { } export interface TraceCandidateRouteOptions { - readonly prepare: (input: unknown) => Promise; - readonly evaluate?: (input: unknown) => Promise; + readonly prepare: (input: TraceCandidateRequest) => Promise; + readonly evaluate?: ( + input: HistoricalSkillImprovementRequest, + ) => Promise; } -async function readBoundedJson(request: Request): Promise { +async function readBoundedText(request: Request): Promise { const declaredLength = Number.parseInt(request.headers.get("content-length") ?? "", 10); if (Number.isFinite(declaredLength) && declaredLength > MAX_TRACE_CANDIDATE_BYTES) { throw new RangeError("Trace candidate request is too large."); @@ -35,7 +43,7 @@ async function readBoundedJson(request: Request): Promise { } body += decoder.decode(chunk.value, { stream: true }); } - return JSON.parse(body + decoder.decode()); + return body + decoder.decode(); } export function createTraceCandidateRoutes( @@ -54,22 +62,28 @@ export function createTraceCandidateRoutes( } const unauthorized = sameOriginFailure(request, allowedOrigins); if (unauthorized) return unauthorized; - if (action === "evaluate" && !options.evaluate) { - return Response.json( - { - error: { - code: "HISTORICAL_REPLAY_UNAVAILABLE", - message: "No managed replay harness is registered for historical evaluation.", - }, - }, - { status: 503, headers: dashboardCorsHeaders() }, - ); - } try { - const input = await readBoundedJson(request); - const result = - action === "prepare" ? await options.prepare(input) : await options.evaluate!(input); - return Response.json(result, { + if (action === "prepare") { + const input = Schema.decodeUnknownSync(Schema.fromJsonString(TraceCandidateRequest))( + await readBoundedText(request), + ); + return Response.json(await options.prepare(input), { headers: dashboardCorsHeaders() }); + } + if (!options.evaluate) { + return Response.json( + { + error: { + code: "HISTORICAL_REPLAY_UNAVAILABLE", + message: "No managed replay harness is registered for historical evaluation.", + }, + }, + { status: 503, headers: dashboardCorsHeaders() }, + ); + } + const input = Schema.decodeUnknownSync( + Schema.fromJsonString(HistoricalSkillImprovementRequest), + )(await readBoundedText(request)); + return Response.json(await options.evaluate(input), { headers: dashboardCorsHeaders(), }); } catch (error) { diff --git a/apps/local/src/routes/workspace-settings.ts b/apps/local/src/routes/workspace-settings.ts index 7ad21956..2a6db037 100644 --- a/apps/local/src/routes/workspace-settings.ts +++ b/apps/local/src/routes/workspace-settings.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import type { RemoteWorkspaceAction, RemoteWorkspaceInput } from "../remote-library-operations.js"; import { DashboardOperationError } from "../dashboard-operation-errors.js"; -import { dashboardCorsHeaders, sameOriginFailure } from "../dashboard-http.js"; +import { withDashboardCors, sameOriginFailure } from "../dashboard-http.js"; const WorkspaceSkillSetPolicyBody = Schema.Struct({ action: Schema.Literals(["allow", "require_approval", "block", "require"]), @@ -41,18 +41,14 @@ const decodeBody = Effect.fn("WorkspaceSettings.decodeBody")(function* => request.json(), + try: () => request.text(), catch: () => requestError(operation, message), }); - return yield* Schema.decodeUnknownEffect(schema)(input).pipe( + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(input).pipe( Effect.mapError(() => requestError(operation, message)), ); }); -function json(value: unknown): Response { - return Response.json(value, { headers: dashboardCorsHeaders() }); -} - function decodeIdentifier( operation: string, value: string, @@ -63,75 +59,82 @@ function decodeIdentifier( }); } -export const routeWorkspaceSettings = Effect.fn("WorkspaceSettings.route")(function* ( - request: Request, - url: URL, - allowedOrigins: ReadonlySet, - operations: WorkspaceOperations, -) { - if (url.pathname === "/api/v2/settings/workspace/policies" && request.method === "GET") { - return json(yield* operations.workspace("policies")); - } - if (url.pathname === "/api/v2/team" && request.method === "GET") { - return json(yield* operations.workspace("overview")); - } - if (url.pathname === "/api/v2/settings/workspace/members" && request.method === "GET") { - return json(yield* operations.workspace("members")); - } - if (url.pathname === "/api/v2/settings/workspace/invite" && request.method === "POST") { - const unauthorized = sameOriginFailure(request, allowedOrigins); - if (unauthorized) return unauthorized; - const body = yield* decodeBody( - "workspace.invite", - request, - WorkspaceInviteBody, - "A member email and role are required.", - ); - return json(yield* operations.workspace("invite", body)); - } - - const memberMatch = url.pathname.match( - /^\/api\/v2\/settings\/workspace\/members\/([^/]+)\/(role|remove)$/, - ); - if (memberMatch && request.method === "POST") { - const unauthorized = sameOriginFailure(request, allowedOrigins); - if (unauthorized) return unauthorized; - const userId = yield* decodeIdentifier("workspace.member", memberMatch[1] ?? ""); - if (memberMatch[2] === "remove") { - return json(yield* operations.workspace("remove", { user_id: userId })); +export const routeWorkspaceSettings = Effect.fn("WorkspaceSettings.route")( + function* ( + request: Request, + url: URL, + allowedOrigins: ReadonlySet, + operations: WorkspaceOperations, + ) { + if (url.pathname === "/api/v2/settings/workspace/policies" && request.method === "GET") { + return Response.json(yield* operations.workspace("policies")); } - const body = yield* decodeBody( - "workspace.role", - request, - WorkspaceRoleBody, - "A workspace role is required.", - ); - return json(yield* operations.workspace("role", { user_id: userId, role: body.role })); - } - - const policyMatch = url.pathname.match( - /^\/api\/v2\/settings\/workspace\/policies\/([^/]+)(?:\/(reset))?$/, - ); - if (policyMatch && request.method === "POST") { - const unauthorized = sameOriginFailure(request, allowedOrigins); - if (unauthorized) return unauthorized; - const skillSetId = yield* decodeIdentifier("workspace_policies.update", policyMatch[1] ?? ""); - if (policyMatch[2] === "reset") { - return json(yield* operations.workspace("policy_reset", { skill_set_id: skillSetId })); + if (url.pathname === "/api/v2/team" && request.method === "GET") { + return Response.json(yield* operations.workspace("overview")); + } + if (url.pathname === "/api/v2/settings/workspace/members" && request.method === "GET") { + return Response.json(yield* operations.workspace("members")); } - const body = yield* decodeBody( - "workspace_policies.update", - request, - WorkspaceSkillSetPolicyBody, - "A policy action is required.", + if (url.pathname === "/api/v2/settings/workspace/invite" && request.method === "POST") { + const unauthorized = sameOriginFailure(request, allowedOrigins); + if (unauthorized) return unauthorized; + const body = yield* decodeBody( + "workspace.invite", + request, + WorkspaceInviteBody, + "A member email and role are required.", + ); + return Response.json(yield* operations.workspace("invite", body)); + } + + const memberMatch = url.pathname.match( + /^\/api\/v2\/settings\/workspace\/members\/([^/]+)\/(role|remove)$/, ); - return json( - yield* operations.workspace("policy_update", { - skill_set_id: skillSetId, - action: body.action, - reason: body.reason, - }), + if (memberMatch && request.method === "POST") { + const unauthorized = sameOriginFailure(request, allowedOrigins); + if (unauthorized) return unauthorized; + const userId = yield* decodeIdentifier("workspace.member", memberMatch[1] ?? ""); + if (memberMatch[2] === "remove") { + return Response.json(yield* operations.workspace("remove", { user_id: userId })); + } + const body = yield* decodeBody( + "workspace.role", + request, + WorkspaceRoleBody, + "A workspace role is required.", + ); + return Response.json( + yield* operations.workspace("role", { user_id: userId, role: body.role }), + ); + } + + const policyMatch = url.pathname.match( + /^\/api\/v2\/settings\/workspace\/policies\/([^/]+)(?:\/(reset))?$/, ); - } - return null; -}); + if (policyMatch && request.method === "POST") { + const unauthorized = sameOriginFailure(request, allowedOrigins); + if (unauthorized) return unauthorized; + const skillSetId = yield* decodeIdentifier("workspace_policies.update", policyMatch[1] ?? ""); + if (policyMatch[2] === "reset") { + return Response.json( + yield* operations.workspace("policy_reset", { skill_set_id: skillSetId }), + ); + } + const body = yield* decodeBody( + "workspace_policies.update", + request, + WorkspaceSkillSetPolicyBody, + "A policy action is required.", + ); + return Response.json( + yield* operations.workspace("policy_update", { + skill_set_id: skillSetId, + action: body.action, + reason: body.reason, + }), + ); + } + return null; + }, + Effect.map((response) => (response ? withDashboardCors(response) : null)), +); diff --git a/apps/local/src/service-contract.ts b/apps/local/src/service-contract.ts index ca255b31..864e0c9f 100644 --- a/apps/local/src/service-contract.ts +++ b/apps/local/src/service-contract.ts @@ -68,6 +68,11 @@ export interface WindowsServiceBackend extends ServiceBackendBase { export type ServiceBackend = NonWindowsServiceBackend | WindowsServiceBackend; +export class ServiceBackendProvider extends Context.Service< + ServiceBackendProvider, + ServiceBackend +>()("SelfTune/ServiceBackend") {} + export interface LocalRuntimeControl { readonly status: (configDir: string) => Effect.Effect; readonly stop: ( @@ -88,6 +93,11 @@ export interface WindowsRuntimeRecovery { ) => Effect.Effect; } +export class WindowsRuntimeRecoveryProvider extends Context.Service< + WindowsRuntimeRecoveryProvider, + WindowsRuntimeRecovery +>()("SelfTune/WindowsRuntimeRecovery") {} + export class ServiceFailure extends Schema.TaggedErrorClass()("ServiceFailure", { operation: Schema.String, message: Schema.String, diff --git a/apps/local/src/service-definition.ts b/apps/local/src/service-definition.ts index 5eabbcd0..08fd65fa 100644 --- a/apps/local/src/service-definition.ts +++ b/apps/local/src/service-definition.ts @@ -34,8 +34,8 @@ export function serviceProgramArguments( ]; } -export function serviceEnvironment(descriptor: ServiceDescriptor): Record { - return { +export function serviceEnvironment(descriptor: ServiceDescriptor) { + const environment = { PATH: resolveLoginShellPath(), SELFTUNE_CONFIG_DIR: descriptor.configDir, SELFTUNE_DESKTOP: descriptor.resourceDir ? "1" : "0", @@ -44,11 +44,12 @@ export function serviceEnvironment(descriptor: ServiceDescriptor): Record( command: string, args: ReadonlyArray, environment: Record | undefined, - failure: (operation: string, cause: unknown) => E, + failure: (operation: string, cause: string) => E, ): Effect.Effect { return Effect.callback((resume) => { - const child = execFile( - command, - [...args], - { - encoding: "utf8", - ...(environment ? { env: { ...process.env, ...environment } } : {}), - }, - (error, stdout, stderr) => { - if (error && typeof error.code === "string") { - resume(Effect.fail(failure(command, `${error.code}: ${error.message}`))); - return; - } - resume( - Effect.succeed({ - code: error && typeof error.code === "number" ? error.code : error ? 1 : 0, - stdout: stdout ?? "", - stderr: stderr ?? "", - }), - ); - }, - ); + const options: ExecFileOptionsWithStringEncoding = { encoding: "utf8" }; + if (environment) options.env = { ...process.env, ...environment }; + const child = execFile(command, [...args], options, (error, stdout, stderr) => { + const launchFailure = Option.getOrNull( + Schema.decodeUnknownOption(ProcessLaunchFailure)(error), + ); + if (launchFailure) { + resume(Effect.fail(failure(command, `${launchFailure.code}: ${launchFailure.message}`))); + return; + } + resume( + Effect.succeed({ + code: + Option.getOrNull(Schema.decodeUnknownOption(ProcessExitFailure)(error))?.code ?? + (error ? 1 : 0), + stdout: stdout ?? "", + stderr: stderr ?? "", + }), + ); + }); return Effect.promise(() => terminateProcess(child)); }); } diff --git a/apps/local/src/service-programs.ts b/apps/local/src/service-programs.ts index 621684d7..d940ac02 100644 --- a/apps/local/src/service-programs.ts +++ b/apps/local/src/service-programs.ts @@ -1,9 +1,8 @@ -import { readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import * as Effect from "effect/Effect"; -import { findSelftunePackageRoot } from "@selftune/runtime/package-root"; +import { getSelftuneVersion } from "@selftune/runtime/utils/selftune-meta"; import { ServiceFailure, @@ -56,10 +55,7 @@ function isCompiledBunEntrypoint(path: string | undefined): boolean { return normalized.startsWith("/$bunfs/") || /^[a-z]:\/~BUN\//i.test(normalized); } -function resolveCliInvocation(explicitExecutable: string | undefined): { - readonly executableArgsPrefix: ReadonlyArray; - readonly executablePath: string; -} { +function resolveCliInvocation(explicitExecutable: string | undefined) { if (explicitExecutable) { return { executablePath: resolve(explicitExecutable), executableArgsPrefix: [] }; } @@ -76,24 +72,7 @@ function resolveCliInvocation(explicitExecutable: string | undefined): { } function installedVersion(): string { - const environmentVersion = process.env.SELFTUNE_VERSION; - if (environmentVersion) return environmentVersion; - try { - const value: unknown = JSON.parse( - readFileSync(join(findSelftunePackageRoot(), "package.json"), "utf8"), - ); - if ( - typeof value === "object" && - value !== null && - "version" in value && - typeof value.version === "string" - ) { - return value.version; - } - } catch { - // A compiled desktop binary supplies its version through the environment. - } - return "unknown"; + return process.env.SELFTUNE_VERSION || getSelftuneVersion("unknown"); } export const resolveServiceDescriptor = Effect.fn("SelfTuneService.resolveDescriptor")(function* ( @@ -101,7 +80,7 @@ export const resolveServiceDescriptor = Effect.fn("SelfTuneService.resolveDescri environment: NodeJS.ProcessEnv = process.env, ) { return yield* Effect.try({ - try: () => { + try: (): ServiceDescriptor => { if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65_535) { throw serviceFailure("parse", `Invalid service port: ${input.port}`); } @@ -111,15 +90,15 @@ export const resolveServiceDescriptor = Effect.fn("SelfTuneService.resolveDescri const invocation = resolveCliInvocation(input.executable); const packagedResourceDir = environment.SELFTUNE_DESKTOP_RESOURCE_DIR?.trim(); const resourceDir = input.resourceDir ?? (packagedResourceDir || undefined); - return { + const descriptor = { ...invocation, boot: input.boot, configDir: input.configDir ?? resolveLocalConfigDir(), owner: input.owner ?? (process.env.SELFTUNE_DESKTOP === "1" ? "desktop" : "cli"), port: input.port, version: input.version ?? installedVersion(), - ...(resourceDir ? { resourceDir: resolve(resourceDir) } : {}), } satisfies ServiceDescriptor; + return resourceDir ? { ...descriptor, resourceDir: resolve(resourceDir) } : descriptor; }, catch: (cause) => cause instanceof ServiceFailure ? cause : serviceFailure("resolve-descriptor", cause), diff --git a/apps/local/src/service.ts b/apps/local/src/service.ts index 50cae357..1151e925 100644 --- a/apps/local/src/service.ts +++ b/apps/local/src/service.ts @@ -14,20 +14,24 @@ import { } from "./daemon.js"; import { runServiceProcess as runCancellableServiceProcess } from "./service-process.js"; import { prepareServiceDirectories } from "./service/directories.js"; -import { makeLaunchdBackend } from "./service/launchd/backend.js"; -import { makeSystemdBackend } from "./service/systemd/backend.js"; -import { makeLiveWindowsServiceBackend } from "./service/windows/backend.js"; +import { makeLaunchdBackendLayer } from "./service/launchd/backend.js"; +import { makeSystemdBackendLayer } from "./service/systemd/backend.js"; +import { + makeLiveWindowsServiceBackendLayer, + WindowsBackendProvider, +} from "./service/windows/backend.js"; import { runWindowsServiceCommand } from "./service/windows/orchestration.js"; -import { makeLiveWindowsRuntimeRecovery } from "./service/windows/runtime/live.js"; +import { makeWindowsRuntimeRecoveryLayer } from "./service/windows/runtime/live.js"; import { observeWindowsServiceStatus } from "./service/windows/status.js"; import { ServiceManager, + ServiceBackendProvider, + WindowsRuntimeRecoveryProvider, serviceFailure, type ServiceBackend, type ServiceCommandResponse, type ServiceDescriptor, type ServiceStatus, - type WindowsServiceBackend, } from "./service-contract.js"; export { serviceEnvironment, serviceProgramArguments } from "./service-definition.js"; @@ -74,11 +78,11 @@ const runCommand = Effect.fn("SelfTuneService.runCommand")(function* ( export const runServiceProcess = runCommand; function currentUid(): number { - return typeof process.getuid === "function" ? process.getuid() : userInfo().uid; + return process.getuid?.() ?? userInfo().uid; } -function makeWindowsBackend(): WindowsServiceBackend { - return makeLiveWindowsServiceBackend({ +function makeWindowsBackendLayer() { + const windows = makeLiveWindowsServiceBackendLayer({ prepareDirectories: (configDir) => Effect.try({ try: () => prepareServiceDirectories(configDir), @@ -87,6 +91,9 @@ function makeWindowsBackend(): WindowsServiceBackend { run: runCommand, systemRoot: process.env.SystemRoot, }); + return Layer.effect(ServiceBackendProvider)(Effect.service(WindowsBackendProvider)).pipe( + Layer.provide(windows), + ); } function makeUnsupportedBackend(platform: NodeJS.Platform): ServiceBackend { @@ -113,16 +120,16 @@ function makeUnsupportedBackend(platform: NodeJS.Platform): ServiceBackend { }; } -export function getServiceBackend(platform: NodeJS.Platform = process.platform): ServiceBackend { +export function getServiceBackendLayer(platform: NodeJS.Platform = process.platform) { switch (platform) { case "darwin": - return makeLaunchdBackend({ + return makeLaunchdBackendLayer({ homeDirectory: homedir(), run: runCommand, uid: currentUid(), }); case "linux": - return makeSystemdBackend({ + return makeSystemdBackendLayer({ homeDirectory: homedir(), run: runCommand, uid: currentUid(), @@ -131,20 +138,30 @@ export function getServiceBackend(platform: NodeJS.Platform = process.platform): xdgRuntimeDir: process.env.XDG_RUNTIME_DIR, }); case "win32": - return makeWindowsBackend(); + return makeWindowsBackendLayer(); default: - return makeUnsupportedBackend(platform); + return Layer.succeed(ServiceBackendProvider)(makeUnsupportedBackend(platform)); } } -export const ServiceManagerLive = Layer.succeed(ServiceManager)({ - backend: getServiceBackend(), - runtime: { - status: getDaemonStatus, - stop: stopDaemon, - }, - windowsRecovery: makeLiveWindowsRuntimeRecovery(runCommand), -}); +/** Synchronous adapter for callers outside the managed service runtime. */ +export function getServiceBackend(platform: NodeJS.Platform = process.platform): ServiceBackend { + return Effect.runSync( + Effect.service(ServiceBackendProvider).pipe(Effect.provide(getServiceBackendLayer(platform))), + ); +} + +const ServiceDependenciesLive = Layer.mergeAll( + getServiceBackendLayer(), + makeWindowsRuntimeRecoveryLayer(runCommand), +); +export const ServiceManagerLive = Layer.effect(ServiceManager)( + Effect.gen(function* () { + const backend = yield* ServiceBackendProvider; + const windowsRecovery = yield* WindowsRuntimeRecoveryProvider; + return { backend, runtime: { status: getDaemonStatus, stop: stopDaemon }, windowsRecovery }; + }), +).pipe(Layer.provide(ServiceDependenciesLive)); function withManifestDetail(status: ServiceStatus, daemon: DaemonStatus): ServiceStatus { const manifest = daemon.manifest; diff --git a/apps/local/src/service/authority/index.ts b/apps/local/src/service/authority/index.ts index 70ac9197..f0f0d1c7 100644 --- a/apps/local/src/service/authority/index.ts +++ b/apps/local/src/service/authority/index.ts @@ -1,4 +1,3 @@ export * from "./durable-artifact.js"; export * from "./evidence.js"; -export * from "./receipt.js"; export * from "./reproof.js"; diff --git a/apps/local/src/service/authority/receipt.ts b/apps/local/src/service/authority/receipt.ts deleted file mode 100644 index 8fc5045f..00000000 --- a/apps/local/src/service/authority/receipt.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type * as Effect from "effect/Effect"; - -export interface ReceiptGenerationContract { - readonly absent: () => TExpectation; - readonly fromReceipt: (receipt: TReceipt) => TExpectation; - readonly matches: (receipt: TReceipt | null, expected: TExpectation) => boolean; -} - -export interface DurableReceiptContract { - readonly create: (input: TInput) => TReceipt; - readonly decode: (input: unknown) => Effect.Effect; - readonly encodeForStorage: (receipt: TReceipt) => Effect.Effect; - readonly generation: ReceiptGenerationContract; -} - -export function defineDurableReceiptContract( - contract: DurableReceiptContract, -): DurableReceiptContract { - return contract; -} diff --git a/apps/local/src/service/definition-file.ts b/apps/local/src/service/definition-file.ts index 00aca598..9bcf536c 100644 --- a/apps/local/src/service/definition-file.ts +++ b/apps/local/src/service/definition-file.ts @@ -41,10 +41,11 @@ function sameFile(left: FileIdentity, right: BigIntStats): boolean { } function currentEffectiveUid(): bigint { - if (typeof process.geteuid !== "function") { + const uid = process.geteuid?.(); + if (uid === undefined) { throw new Error("The current effective user ID is unavailable."); } - return BigInt(process.geteuid()); + return BigInt(uid); } function verifyDefinitionFile( @@ -68,11 +69,6 @@ function verifyDefinitionFile( } } -function isUnsupportedDirectorySync(cause: unknown): boolean { - if (!(cause instanceof Error) || !("code" in cause)) return false; - return cause.code === "EINVAL" || cause.code === "ENOTSUP"; -} - function syncParentDirectory(path: string): void { if (!POSIX) return; const flags = constants.O_RDONLY | (constants.O_DIRECTORY ?? 0); @@ -81,7 +77,12 @@ function syncParentDirectory(path: string): void { descriptor = openSync(path, flags); fsyncSync(descriptor); } catch (cause) { - if (!isUnsupportedDirectorySync(cause)) throw cause; + if ( + !(cause instanceof Error) || + !("code" in cause) || + (cause.code !== "EINVAL" && cause.code !== "ENOTSUP") + ) + throw cause; } finally { if (descriptor !== null) closeSync(descriptor); } diff --git a/apps/local/src/service/launchd/backend.ts b/apps/local/src/service/launchd/backend.ts index 15236c8a..b90b0b72 100644 --- a/apps/local/src/service/launchd/backend.ts +++ b/apps/local/src/service/launchd/backend.ts @@ -3,10 +3,12 @@ import { homedir } from "node:os"; import { join } from "node:path"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { LOCAL_SERVICE_LABEL } from "../../local-runtime.js"; import { serviceFailure, + ServiceBackendProvider, type ServiceBackend, type ServiceDescriptor, type ServiceFailure, @@ -148,6 +150,10 @@ function launchdServiceMissing(result: ServiceProcessResult): boolean { ); } +export function makeLaunchdBackendLayer(options: LaunchdBackendOptions) { + return Layer.sync(ServiceBackendProvider)(() => makeLaunchdBackend(options)); +} + export function makeLaunchdBackend(options: LaunchdBackendOptions): ServiceBackend { const agentsDir = launchAgentsDir(options.homeDirectory); const plistPath = launchdPlistPathFor(options.homeDirectory); diff --git a/apps/local/src/service/maintenance/command.ts b/apps/local/src/service/maintenance/command.ts index 25730ac0..095f999d 100644 --- a/apps/local/src/service/maintenance/command.ts +++ b/apps/local/src/service/maintenance/command.ts @@ -57,7 +57,8 @@ function response( diagnostic.state === "fenced" || diagnostic.state === "ready_to_fence" || diagnostic.state === "not_applicable"; - return { action, diagnostic, ok, platform, ...(result === undefined ? {} : { result }) }; + const response = { action, diagnostic, ok, platform }; + return result === undefined ? response : { ...response, result }; } export const runServiceMaintenanceCommand = Effect.fn("SelfTuneService.maintenance.command")( diff --git a/apps/local/src/service/systemd/backend.ts b/apps/local/src/service/systemd/backend.ts index 53a0ca84..66ee0f71 100644 --- a/apps/local/src/service/systemd/backend.ts +++ b/apps/local/src/service/systemd/backend.ts @@ -3,10 +3,12 @@ import { homedir, userInfo } from "node:os"; import { join } from "node:path"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { LOCAL_SERVICE_LABEL } from "../../local-runtime.js"; import { serviceFailure, + ServiceBackendProvider, type ServiceBackend, type ServiceFailure, type ServiceStatus, @@ -15,7 +17,7 @@ import { serviceEnvironment, serviceProgramArguments } from "../../service-defin import type { ServiceProcessResult } from "../../service-process.js"; import { replaceServiceDefinitionFile } from "../definition-file.js"; import { prepareServiceDirectories, serviceLogDir } from "../directories.js"; -import { makeSystemdManager } from "./manager.js"; +import { makeSystemdManagerLayer, SystemdManagerService } from "./manager.js"; export interface SystemdUnitOptions { readonly environment: Record; @@ -95,10 +97,7 @@ export function systemdUnitPath(): string { }); } -function systemdEnvironment(options: { - readonly uid: number; - readonly xdgRuntimeDir?: string; -}): Record { +function systemdEnvironment(options: Pick) { return { XDG_RUNTIME_DIR: options.xdgRuntimeDir ?? `/run/user/${options.uid}` }; } @@ -113,18 +112,29 @@ export function systemdLingerMarkerPath(configDir: string): string { return join(configDir, "server-control", "systemd-linger-enabled-by-selftune"); } -export function makeSystemdBackend(options: SystemdBackendOptions): ServiceBackend { +export function makeSystemdBackendLayer(options: SystemdBackendOptions) { + const manager = makeSystemdManagerLayer({ + failure: serviceFailure, + run: (args) => options.run("systemctl", ["--user", ...args], systemdEnvironment(options)), + unitName: `${LOCAL_SERVICE_LABEL}.service`, + }); + return Layer.effect(ServiceBackendProvider)( + Effect.gen(function* () { + return makeSystemdBackend(options, yield* SystemdManagerService); + }), + ).pipe(Layer.provide(manager)); +} + +function makeSystemdBackend( + options: SystemdBackendOptions, + manager: SystemdManagerService["Service"], +): ServiceBackend { const environment = systemdEnvironment(options); const unitDir = systemdUnitDir(options); const unitPath = systemdUnitPathFor(options); const unitName = `${LOCAL_SERVICE_LABEL}.service`; const systemctl = (args: ReadonlyArray) => options.run("systemctl", ["--user", ...args], environment); - const manager = makeSystemdManager({ - failure: serviceFailure, - run: systemctl, - unitName, - }); const checked = (operation: string, args: ReadonlyArray) => Effect.gen(function* () { const result = yield* systemctl(args); diff --git a/apps/local/src/service/systemd/manager.ts b/apps/local/src/service/systemd/manager.ts index ee97f0d1..fccbed78 100644 --- a/apps/local/src/service/systemd/manager.ts +++ b/apps/local/src/service/systemd/manager.ts @@ -1,4 +1,7 @@ import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import type { ServiceFailure } from "../../service-contract.js"; import type { ServiceProcessResult } from "../../service-process.js"; @@ -87,6 +90,15 @@ function parseManagerState( }); } +export class SystemdManagerService extends Context.Service< + SystemdManagerService, + SystemdManager +>()("SelfTune/SystemdManager") {} + +export function makeSystemdManagerLayer(dependencies: SystemdManagerDependencies) { + return Layer.sync(SystemdManagerService)(() => makeSystemdManager(dependencies)); +} + export function makeSystemdManager( dependencies: SystemdManagerDependencies, ): SystemdManager { diff --git a/apps/local/src/service/windows/artifact-store.ts b/apps/local/src/service/windows/artifact-store.ts index 8c35365f..c73f3e2f 100644 --- a/apps/local/src/service/windows/artifact-store.ts +++ b/apps/local/src/service/windows/artifact-store.ts @@ -7,10 +7,10 @@ export interface WindowsServiceArtifactRemoval { readonly generation: string; } -export interface WindowsServiceInstallationArtifactStore { - readonly read: (path: string) => Effect.Effect; - readonly removeMatching: (removal: WindowsServiceArtifactRemoval) => Effect.Effect; - readonly write: (path: string, contents: Uint8Array) => Effect.Effect; +export interface WindowsServiceInstallationArtifactStore { + readonly read: (path: string) => Effect.Effect; + readonly removeMatching: (removal: WindowsServiceArtifactRemoval) => Effect.Effect; + readonly write: (path: string, contents: Uint8Array) => Effect.Effect; } const SAFE_GENERATION_PATTERN = /^[A-Za-z0-9_-]+$/; diff --git a/apps/local/src/service/windows/backend.ts b/apps/local/src/service/windows/backend.ts index 85b13efd..783c7fd5 100644 --- a/apps/local/src/service/windows/backend.ts +++ b/apps/local/src/service/windows/backend.ts @@ -3,6 +3,8 @@ import { userInfo } from "node:os"; import { win32 } from "node:path"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; import { serviceProgramArguments } from "../../service-definition.js"; import { @@ -12,24 +14,31 @@ import { type WindowsServiceBackend, } from "../../service-contract.js"; import { - makeWindowsServiceInstallationController, + WindowsInstallationController, + makeWindowsInstallationControllerLayer, type WindowsServiceInstallationControllerDependencies, type WindowsServiceInstallationPlan, } from "./installation/controller.js"; import type { WindowsServiceInstallationEvidence } from "./installation/contract.js"; import { - makeLiveWindowsServiceInstallationArtifactStore, - makeLiveWindowsServiceInstallationStore, + WindowsInstallationArtifacts, + WindowsInstallationArtifactsLive, + makeLiveWindowsInstallationStoreLayer, } from "./installation/live.js"; import { - makeLiveWindowsUserServiceMutationLock, + WindowsMutationLock, + WindowsMutationLockLive, type WindowsUserServiceMutationLock, } from "./mutation-lock.js"; import { - makeLiveWindowsServiceLockCompatibility, + WindowsLockCompatibility, + WindowsLockCompatibilityLive, type WindowsServiceLockCompatibility, } from "./lock-compatibility.js"; -import type { WindowsServiceInstallationStoreWithUserControl } from "./installation/store.js"; +import { + WindowsInstallationStore, + type WindowsServiceInstallationStoreWithUserControl, +} from "./installation/store.js"; import { sha256Hex, type WindowsServiceInstallationReceipt } from "./installation/model.js"; import { generateWindowsDaemonWrapper, @@ -42,7 +51,11 @@ import { type WindowsLegacyUserIdentity, WINDOWS_TASK_NAME, } from "./installation/definition.js"; -import { makeWindowsTaskScheduler, windowsSystemExecutable } from "./scheduler.js"; +import { + WindowsTaskSchedulers, + makeWindowsTaskSchedulersLayer, + windowsSystemExecutable, +} from "./scheduler.js"; import type { WindowsRuntimeAuthorization } from "./runtime/contract.js"; import { windowsStatusFromEvidence } from "./status.js"; import type { ServiceProcessResult } from "../../service-process.js"; @@ -51,6 +64,11 @@ const WINDOWS_ARTIFACT_MODE = "utf8"; export type WindowsServiceBackendPlan = WindowsServiceInstallationPlan; +export class WindowsBackendProvider extends Context.Service< + WindowsBackendProvider, + WindowsServiceBackend +>()("SelfTune/WindowsBackend") {} + export type WindowsServiceAuthorization = | { readonly _tag: "Absent" } | { @@ -119,7 +137,7 @@ function legacyArtifactPaths(configDir: string) { function liveLegacyUser(): WindowsLegacyUserIdentity { return { - ...(process.env.USERDOMAIN ? { domain: process.env.USERDOMAIN } : {}), + domain: process.env.USERDOMAIN || undefined, username: userInfo().username, }; } @@ -183,7 +201,7 @@ export function makeWindowsServiceInstallationPlan( return { artifactPaths: (installId) => artifactPaths(descriptor.configDir, installId), encodeTaskDefinition: (xml) => Buffer.from(`\ufeff${xml}`, "utf16le"), - ...(legacy === undefined ? {} : { legacy }), + legacy, receipt: { boot: descriptor.boot, configDir: descriptor.configDir, @@ -296,10 +314,19 @@ function mapControllerFailure( ); } -export function makeWindowsServiceBackend( +export function makeWindowsServiceBackendLayer(dependencies: WindowsServiceBackendDependencies) { + const controller = makeWindowsInstallationControllerLayer(dependencies); + return Layer.effect(WindowsBackendProvider)( + Effect.gen(function* () { + return makeWindowsServiceBackend(dependencies, yield* WindowsInstallationController); + }), + ).pipe(Layer.provide(controller)); +} + +function makeWindowsServiceBackend( dependencies: WindowsServiceBackendDependencies, + controller: WindowsInstallationController["Service"], ): WindowsServiceBackend { - const controller = makeWindowsServiceInstallationController(dependencies); const planFor = (descriptor: ServiceDescriptor) => makeWindowsServiceInstallationPlan(descriptor, { legacyUser: dependencies.legacyUser, @@ -380,30 +407,41 @@ export function makeWindowsServiceBackend( }; } -export function makeLiveWindowsServiceBackend( - options: LiveWindowsServiceBackendOptions, -): WindowsServiceBackend { - const store = makeLiveWindowsServiceInstallationStore({ +export function makeLiveWindowsServiceBackendLayer(options: LiveWindowsServiceBackendOptions) { + const storeLayer = makeLiveWindowsInstallationStoreLayer({ process: { execute: options.run }, systemRoot: options.systemRoot, }); - const lockCompatibility = makeLiveWindowsServiceLockCompatibility(); - return makeWindowsServiceBackend({ - artifacts: makeLiveWindowsServiceInstallationArtifactStore(), - legacyUser: liveLegacyUser(), - lockCompatibility, - mutationLock: makeLiveWindowsUserServiceMutationLock(lockCompatibility), - prepareDirectories: options.prepareDirectories, - schedulerFor: (taskName) => - makeWindowsTaskScheduler({ - execute: options.run, - makeFailure: serviceFailure, - systemRoot: options.systemRoot, - taskName, - }), - store, + const schedulerLayer = makeWindowsTaskSchedulersLayer({ + execute: options.run, systemRoot: options.systemRoot, }); + const locks = WindowsMutationLockLive.pipe(Layer.provideMerge(WindowsLockCompatibilityLive)); + const dependencies = Layer.mergeAll( + storeLayer, + schedulerLayer, + locks, + WindowsInstallationArtifactsLive, + ); + return Layer.unwrap( + Effect.gen(function* () { + const artifacts = yield* WindowsInstallationArtifacts; + const lockCompatibility = yield* WindowsLockCompatibility; + const mutationLock = yield* WindowsMutationLock; + const store = yield* WindowsInstallationStore; + const schedulers = yield* WindowsTaskSchedulers; + return makeWindowsServiceBackendLayer({ + artifacts, + legacyUser: liveLegacyUser(), + lockCompatibility, + mutationLock, + prepareDirectories: options.prepareDirectories, + schedulerFor: schedulers.forTask, + store, + systemRoot: options.systemRoot, + }); + }), + ).pipe(Layer.provide(dependencies)); } export type { WindowsServiceBackend } from "../../service-contract.js"; diff --git a/apps/local/src/service/windows/installation/controller.ts b/apps/local/src/service/windows/installation/controller.ts index 7442a0d2..703ef6fd 100644 --- a/apps/local/src/service/windows/installation/controller.ts +++ b/apps/local/src/service/windows/installation/controller.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import { inspectDurableArtifactSet } from "../../authority/durable-artifact.js"; import { reproveAuthority } from "../../authority/reproof.js"; @@ -34,7 +36,10 @@ import { type WindowsServiceInstallationReceiptInput, type WindowsServiceInstallationStoreWithLegacyCleanup, } from "./store.js"; -import { makeWindowsServiceLegacyCleanupController } from "./legacy-cleanup-controller.js"; +import { + WindowsLegacyCleanupController, + makeWindowsLegacyCleanupControllerLayer, +} from "./legacy-cleanup-controller.js"; import type { WindowsServiceLegacyCleanupJournal } from "./legacy-cleanup.js"; import type { WindowsScheduledTaskState, WindowsTaskScheduler } from "../scheduler.js"; @@ -378,8 +383,26 @@ function refuseMutation( ); } -export function makeWindowsServiceInstallationController( +export class WindowsInstallationController extends Context.Service< + WindowsInstallationController, + WindowsServiceInstallationController +>()("SelfTune/WindowsInstallationController") {} + +export function makeWindowsInstallationControllerLayer( dependencies: WindowsServiceInstallationControllerDependencies, +) { + const legacy = makeWindowsLegacyCleanupControllerLayer(dependencies); + return Layer.effect(WindowsInstallationController)( + Effect.gen(function* () { + const legacyCleanup = yield* WindowsLegacyCleanupController; + return makeWindowsServiceInstallationController(dependencies, legacyCleanup); + }), + ).pipe(Layer.provide(legacy)); +} + +function makeWindowsServiceInstallationController( + dependencies: WindowsServiceInstallationControllerDependencies, + legacyCleanup: WindowsLegacyCleanupController["Service"], ): WindowsServiceInstallationController { const readArtifact = (path: string) => mapFailure("read-installation-artifact", dependencies.artifacts.read(path)); @@ -595,8 +618,6 @@ export function makeWindowsServiceInstallationController( yield* removeIfMatching(artifacts.wrapper); }); - const legacyCleanup = makeWindowsServiceLegacyCleanupController(dependencies); - const beginLegacyCleanup = Effect.fn("SelfTuneService.windowsInstallation.beginLegacyCleanup")( function* ( evidence: Extract, diff --git a/apps/local/src/service/windows/installation/io-error.ts b/apps/local/src/service/windows/installation/io-error.ts new file mode 100644 index 00000000..49058e2e --- /dev/null +++ b/apps/local/src/service/windows/installation/io-error.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; + +export class WindowsInstallationIOError extends Schema.TaggedErrorClass()( + "WindowsInstallationIOError", + { + operation: Schema.Literals([ + "read", + "removeMatching", + "openExclusive", + "writeAndSync", + "close", + "makeDirectory", + "readUtf8File", + "removeFile", + "rename", + "randomBytes", + ]), + message: Schema.String, + cause: Schema.Defect, + }, +) { + static fromCause(operation: WindowsInstallationIOError["operation"], cause: unknown) { + return new WindowsInstallationIOError({ + operation, + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + } +} diff --git a/apps/local/src/service/windows/installation/legacy-cleanup-controller.ts b/apps/local/src/service/windows/installation/legacy-cleanup-controller.ts index 5989937d..46a85283 100644 --- a/apps/local/src/service/windows/installation/legacy-cleanup-controller.ts +++ b/apps/local/src/service/windows/installation/legacy-cleanup-controller.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import type { WindowsServiceInstallationArtifactStore } from "../artifact-store.js"; import { matchLegacyWindowsServiceTaskDefinition } from "./evidence.js"; @@ -58,6 +60,19 @@ function sameSid(left: string, right: string): boolean { return left.toLocaleLowerCase("en-US") === right.toLocaleLowerCase("en-US"); } +export class WindowsLegacyCleanupController extends Context.Service< + WindowsLegacyCleanupController, + ReturnType +>()("SelfTune/WindowsLegacyCleanupController") {} + +export function makeWindowsLegacyCleanupControllerLayer( + dependencies: WindowsServiceLegacyCleanupControllerDependencies, +) { + return Layer.sync(WindowsLegacyCleanupController)(() => + makeWindowsServiceLegacyCleanupController(dependencies), + ); +} + export function makeWindowsServiceLegacyCleanupController( dependencies: WindowsServiceLegacyCleanupControllerDependencies, ) { diff --git a/apps/local/src/service/windows/installation/legacy-cleanup.ts b/apps/local/src/service/windows/installation/legacy-cleanup.ts index 39ee4ab1..ea7c7132 100644 --- a/apps/local/src/service/windows/installation/legacy-cleanup.ts +++ b/apps/local/src/service/windows/installation/legacy-cleanup.ts @@ -160,11 +160,9 @@ export function createWindowsServiceLegacyCleanupJournal( }); } -export function decodeWindowsServiceLegacyCleanupJournal( - input: unknown, -): WindowsServiceLegacyCleanupJournal { - return Schema.decodeUnknownSync(WindowsServiceLegacyCleanupJournalSchema)(input); -} +export const decodeWindowsServiceLegacyCleanupJournal = Schema.decodeUnknownSync( + WindowsServiceLegacyCleanupJournalSchema, +); export function windowsServiceLegacyCleanupPath(configDir: string): string { if (!win32.isAbsolute(configDir)) { diff --git a/apps/local/src/service/windows/installation/live.ts b/apps/local/src/service/windows/installation/live.ts index 81154d9a..8ff9d238 100644 --- a/apps/local/src/service/windows/installation/live.ts +++ b/apps/local/src/service/windows/installation/live.ts @@ -4,12 +4,13 @@ import { link, mkdir, open, readFile, rename, unlink } from "node:fs/promises"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import { - makeWindowsServiceInstallationStore, + makeWindowsInstallationStoreLayer, type WindowsInstallationFileSystem, type WindowsInstallationProcess, - type WindowsServiceInstallationStoreWithUserControl, } from "./store.js"; import { windowsServiceArtifactQuarantinePath, @@ -17,6 +18,7 @@ import { type WindowsServiceInstallationArtifactStore, } from "../artifact-store.js"; import { sha256Hex } from "./model.js"; +import { WindowsInstallationIOError } from "./io-error.js"; export interface LiveWindowsServiceInstallationStoreOptions { readonly process: WindowsInstallationProcess; @@ -62,10 +64,13 @@ function isMissingFileError(cause: unknown): boolean { return cause instanceof Error && "code" in cause && cause.code === "ENOENT"; } -function promiseEffect(operation: () => Promise): Effect.Effect { +function promiseEffect( + operation: WindowsInstallationIOError["operation"], + run: () => Promise, +): Effect.Effect { return Effect.tryPromise({ - try: operation, - catch: (cause) => cause, + try: run, + catch: (cause) => WindowsInstallationIOError.fromCause(operation, cause), }); } @@ -92,9 +97,9 @@ const liveInstallationFileSystem: LiveWindowsInstallationFileSystemDependencies function writeAllAndSync( file: LiveWindowsServiceInstallationFile, contents: Uint8Array, -): Effect.Effect { +): Effect.Effect { return Effect.uninterruptible( - promiseEffect(async () => { + promiseEffect("writeAndSync", async () => { let offset = 0; while (offset < contents.byteLength) { // oxlint-disable-next-line no-await-in-loop -- each short write determines the next offset @@ -111,9 +116,9 @@ function writeAllAndSync( function closePreservingUseFailure( file: LiveWindowsServiceInstallationFile, - useExit: Exit.Exit, -): Effect.Effect { - return promiseEffect(() => file.close()).pipe( + useExit: Exit.Exit, +): Effect.Effect { + return promiseEffect("close", () => file.close()).pipe( Effect.catchCause((closeCause) => Exit.isFailure(useExit) ? Effect.failCause(Cause.combine(useExit.cause, closeCause)) @@ -215,20 +220,22 @@ async function removeMatchingArtifact( export function makeLiveWindowsServiceInstallationArtifactStore( fileSystem: LiveWindowsServiceInstallationArtifactFileSystem = liveArtifactFileSystem, -): WindowsServiceInstallationArtifactStore { +): WindowsServiceInstallationArtifactStore { return { read: (path) => - promiseEffect(() => + promiseEffect("read", () => fileSystem.read(path).then( (contents) => contents, (cause: unknown) => (isMissingFileError(cause) ? null : Promise.reject(cause)), ), ), removeMatching: (removal) => - Effect.uninterruptible(promiseEffect(() => removeMatchingArtifact(fileSystem, removal))), + Effect.uninterruptible( + promiseEffect("removeMatching", () => removeMatchingArtifact(fileSystem, removal)), + ), write: (path, contents) => Effect.acquireUseRelease( - promiseEffect(() => fileSystem.openExclusive(path, 0o600)), + promiseEffect("openExclusive", () => fileSystem.openExclusive(path, 0o600)), (file) => writeAllAndSync(file, contents), closePreservingUseFailure, ), @@ -237,18 +244,18 @@ export function makeLiveWindowsServiceInstallationArtifactStore( export function makeLiveWindowsInstallationFileSystem( fileSystem: LiveWindowsInstallationFileSystemDependencies = liveInstallationFileSystem, -): WindowsInstallationFileSystem { +): WindowsInstallationFileSystem { return { - makeDirectory: (path) => promiseEffect(() => fileSystem.makeDirectory(path)), + makeDirectory: (path) => promiseEffect("makeDirectory", () => fileSystem.makeDirectory(path)), readUtf8File: (path) => - promiseEffect(() => + promiseEffect("readUtf8File", () => fileSystem.readUtf8File(path).then( (contents) => contents, (cause: unknown) => (isMissingFileError(cause) ? null : Promise.reject(cause)), ), ), removeFile: (path) => - promiseEffect(() => + promiseEffect("removeFile", () => fileSystem.remove(path).then( () => undefined, (cause: unknown) => (isMissingFileError(cause) ? undefined : Promise.reject(cause)), @@ -257,26 +264,31 @@ export function makeLiveWindowsInstallationFileSystem( rename: (from, to) => // Receipt persistence invokes this after writeUtf8File synced and closed the temp file. // Node has no reliable Windows directory fsync, so metadata durability is not claimed. - promiseEffect(() => fileSystem.rename(from, to)), + promiseEffect("rename", () => fileSystem.rename(from, to)), writeUtf8File: (path, contents, options) => Effect.acquireUseRelease( - promiseEffect(() => fileSystem.openExclusive(path, options.mode)), + promiseEffect("openExclusive", () => fileSystem.openExclusive(path, options.mode)), (file) => writeAllAndSync(file, new TextEncoder().encode(contents)), closePreservingUseFailure, ), }; } -export function makeLiveWindowsServiceInstallationStore( +export class WindowsInstallationArtifacts extends Context.Service< + WindowsInstallationArtifacts, + WindowsServiceInstallationArtifactStore +>()("SelfTune/WindowsInstallationArtifacts") {} + +export const WindowsInstallationArtifactsLive = Layer.sync(WindowsInstallationArtifacts)(() => + makeLiveWindowsServiceInstallationArtifactStore(), +); + +export function makeLiveWindowsInstallationStoreLayer( options: LiveWindowsServiceInstallationStoreOptions, -): WindowsServiceInstallationStoreWithUserControl { - return makeWindowsServiceInstallationStore({ +) { + return makeWindowsInstallationStoreLayer({ clock: { - now: () => - Effect.try({ - try: () => new Date(), - catch: (cause) => cause, - }), + now: () => Effect.sync(() => new Date()), }, fileSystem: makeLiveWindowsInstallationFileSystem(), process: options.process, @@ -284,7 +296,7 @@ export function makeLiveWindowsServiceInstallationStore( bytes: (length) => Effect.try({ try: () => randomBytes(length), - catch: (cause) => cause, + catch: (cause) => WindowsInstallationIOError.fromCause("randomBytes", cause), }), }, systemRoot: options.systemRoot, diff --git a/apps/local/src/service/windows/installation/model.ts b/apps/local/src/service/windows/installation/model.ts index 5a705682..83a3f765 100644 --- a/apps/local/src/service/windows/installation/model.ts +++ b/apps/local/src/service/windows/installation/model.ts @@ -208,11 +208,9 @@ export const WindowsServiceInstallationReceiptSchema = WindowsServiceInstallatio export type WindowsServiceInstallationReceipt = typeof WindowsServiceInstallationReceiptSchema.Type; -export function decodeWindowsServiceInstallationReceipt( - input: unknown, -): WindowsServiceInstallationReceipt { - return Schema.decodeUnknownSync(WindowsServiceInstallationReceiptSchema)(input); -} +export const decodeWindowsServiceInstallationReceipt = Schema.decodeUnknownSync( + WindowsServiceInstallationReceiptSchema, +); export function createWindowsServiceInstallationReceipt( input: WindowsServiceInstallationCreationInput, diff --git a/apps/local/src/service/windows/installation/store.ts b/apps/local/src/service/windows/installation/store.ts index 6381c0cd..3573bbf2 100644 --- a/apps/local/src/service/windows/installation/store.ts +++ b/apps/local/src/service/windows/installation/store.ts @@ -2,13 +2,11 @@ import { Buffer } from "node:buffer"; import { win32 } from "node:path"; import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { - defineDurableReceiptContract, - type ReceiptGenerationContract, -} from "../../authority/receipt.js"; import { createWindowsServiceInstallationReceipt, sameWindowsServiceInstallationReceipt, @@ -51,16 +49,16 @@ export interface WindowsInstallationCommandResult { readonly stdout: string; } -export interface WindowsInstallationFileSystem { - readonly makeDirectory: (path: string) => Effect.Effect; - readonly readUtf8File: (path: string) => Effect.Effect; - readonly removeFile: (path: string) => Effect.Effect; - readonly rename: (from: string, to: string) => Effect.Effect; +export interface WindowsInstallationFileSystem { + readonly makeDirectory: (path: string) => Effect.Effect; + readonly readUtf8File: (path: string) => Effect.Effect; + readonly removeFile: (path: string) => Effect.Effect; + readonly rename: (from: string, to: string) => Effect.Effect; readonly writeUtf8File: ( path: string, contents: string, options: { readonly flag: "wx"; readonly mode: number }, - ) => Effect.Effect; + ) => Effect.Effect; } export interface WindowsInstallationProcess { @@ -105,37 +103,29 @@ export type WindowsServiceInstallationReceiptExpectation = readonly receipt: WindowsServiceInstallationReceipt; }; -const WINDOWS_RECEIPT_GENERATION: ReceiptGenerationContract< - WindowsServiceInstallationReceipt, - WindowsServiceInstallationReceiptExpectation -> = { - absent: () => ({ _tag: "Absent" }), - fromReceipt: (receipt) => ({ - _tag: "Present", - receipt, - }), - matches: (receipt, expected) => { - if (expected._tag === "Absent") return receipt === null; - return receipt !== null && sameWindowsServiceInstallationReceipt(receipt, expected.receipt); - }, -}; - -const WINDOWS_RECEIPT_CONTRACT = defineDurableReceiptContract({ +const WINDOWS_RECEIPT_CONTRACT = { create: createWindowsServiceInstallationReceipt, - decode: (input: unknown) => Schema.decodeUnknownEffect(RECEIPT_JSON_SCHEMA)(input), + decode: Schema.decodeUnknownEffect(RECEIPT_JSON_SCHEMA), encodeForStorage: (receipt: WindowsServiceInstallationReceipt) => Schema.encodeEffect(RECEIPT_JSON_SCHEMA)(receipt).pipe(Effect.map((encoded) => `${encoded}\n`)), - generation: WINDOWS_RECEIPT_GENERATION, -}); +}; export function expectAbsentWindowsServiceInstallationReceipt(): WindowsServiceInstallationReceiptExpectation { - return WINDOWS_RECEIPT_CONTRACT.generation.absent(); + return { _tag: "Absent" }; } export function expectWindowsServiceInstallationReceipt( receipt: WindowsServiceInstallationReceipt, ): WindowsServiceInstallationReceiptExpectation { - return WINDOWS_RECEIPT_CONTRACT.generation.fromReceipt(receipt); + return { _tag: "Present", receipt }; +} + +function matchesReceiptExpectation( + receipt: WindowsServiceInstallationReceipt | null, + expected: WindowsServiceInstallationReceiptExpectation, +): boolean { + if (expected._tag === "Absent") return receipt === null; + return receipt !== null && sameWindowsServiceInstallationReceipt(receipt, expected.receipt); } export class WindowsServiceInstallationStoreError extends Schema.TaggedErrorClass()( @@ -336,6 +326,19 @@ function resolveLegacyCleanupPath( }); } +export class WindowsInstallationStore extends Context.Service< + WindowsInstallationStore, + WindowsServiceInstallationStoreWithUserControl +>()("SelfTune/WindowsInstallationStore") {} + +export function makeWindowsInstallationStoreLayer( + dependencies: WindowsServiceInstallationStoreDependencies, +) { + return Layer.sync(WindowsInstallationStore)(() => + makeWindowsServiceInstallationStore(dependencies), + ); +} + export function makeWindowsServiceInstallationStore( dependencies: WindowsServiceInstallationStoreDependencies, ): WindowsServiceInstallationStoreWithUserControl { @@ -631,7 +634,7 @@ export function makeWindowsServiceInstallationStore( operation: string, ) { const actual = yield* readReceipt(configDir); - if (!WINDOWS_RECEIPT_CONTRACT.generation.matches(actual, expected)) { + if (!matchesReceiptExpectation(actual, expected)) { return yield* Effect.fail( failure(operation, "Windows service installation receipt generation changed."), ); diff --git a/apps/local/src/service/windows/lock-compatibility.ts b/apps/local/src/service/windows/lock-compatibility.ts index 9d5e3796..85b1dfb7 100644 --- a/apps/local/src/service/windows/lock-compatibility.ts +++ b/apps/local/src/service/windows/lock-compatibility.ts @@ -6,7 +6,10 @@ import { Database } from "bun:sqlite"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; const LEGACY_LOCK_FILENAME = "windows-service-mutation.lock"; const SQLITE_LOCK_FILENAME = "windows-service-mutation.sqlite"; @@ -176,6 +179,11 @@ export class WindowsServiceLockCompatibilityError extends Schema.TaggedErrorClas { message: Schema.String, operation: Schema.String }, ) {} +export class WindowsServiceLockFileError extends Schema.TaggedErrorClass()( + "WindowsServiceLockFileError", + { message: Schema.String, code: Schema.NullOr(Schema.String), cause: Schema.Defect }, +) {} + export interface WindowsServiceLockCompatibility { readonly diagnose: ( scope: WindowsUserServiceMutationLockScope, @@ -192,16 +200,11 @@ export interface WindowsServiceLockCompatibility { ) => Effect.Effect; } -const LEGACY_KEYS: ReadonlyArray = [ - "controlDir", - "namespace", - "pid", - "startedAt", - "token", - "userSid", - "version", -]; -const FENCE_KEYS: ReadonlyArray = ["controlDir", "kind", "namespace", "userSid", "version"]; +const decodeLockPayload = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Union([WindowsLegacyMutationLockPayloadModel, WindowsMutationLockFenceSchema]), + ), +); function failure(operation: string, cause: unknown): WindowsServiceLockCompatibilityError { return WindowsServiceLockCompatibilityError.make({ @@ -217,39 +220,18 @@ function mapFailure( return effect.pipe(Effect.mapError((cause) => failure(operation, cause))); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function hasExactKeys(value: unknown, expected: ReadonlyArray): boolean { - if (!isRecord(value)) return false; - const actual = Object.keys(value).toSorted(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -} - function nodeErrorCode(cause: unknown): string | null { - return cause instanceof Error && "code" in cause && typeof cause.code === "string" - ? cause.code + return cause instanceof Error && "code" in cause + ? Option.getOrNull(Schema.decodeUnknownOption(Schema.String)(cause.code)) : null; } function parsePayload( raw: string, ): WindowsLegacyMutationLockPayloadModel | WindowsMutationLockFence | null { - try { - const parsed: unknown = JSON.parse(raw); - if (!isRecord(parsed)) return null; - if (parsed.version === 2 && hasExactKeys(parsed, LEGACY_KEYS)) { - return Schema.decodeUnknownSync(WindowsLegacyMutationLockPayloadModel)(parsed); - } - if (parsed.version === 3 && hasExactKeys(parsed, FENCE_KEYS)) { - const fence = Schema.decodeUnknownSync(WindowsMutationLockFenceSchema)(parsed); - return serializeWindowsMutationLockFence(fence) === raw ? fence : null; - } - return null; - } catch { - return null; - } + const payload = Option.getOrNull(decodeLockPayload(raw, { onExcessProperty: "error" })); + if (payload === null || payload.version === 2) return payload; + return serializeWindowsMutationLockFence(payload) === raw ? payload : null; } function generation(raw: string): string { @@ -545,8 +527,18 @@ export function makeWindowsServiceLockCompatibility( return { diagnose, ensureFence, repairStale }; } -function promiseEffect(operation: () => Promise): Effect.Effect { - return Effect.tryPromise({ try: operation, catch: (cause) => cause }); +function promiseEffect( + operation: () => Promise, +): Effect.Effect { + return Effect.tryPromise({ + try: operation, + catch: (cause) => + new WindowsServiceLockFileError({ + message: cause instanceof Error ? cause.message : String(cause), + code: nodeErrorCode(cause), + cause, + }), + }); } function makeLiveFileSystem(): WindowsServiceLockCompatibilityFileSystem { @@ -601,6 +593,15 @@ function makeLiveFileSystem(): WindowsServiceLockCompatibilityFileSystem { }; } +export class WindowsLockCompatibility extends Context.Service< + WindowsLockCompatibility, + WindowsServiceLockCompatibility +>()("SelfTune/WindowsLockCompatibility") {} + +export const WindowsLockCompatibilityLive = Layer.sync(WindowsLockCompatibility)(() => + makeLiveWindowsServiceLockCompatibility(), +); + export function makeLiveWindowsServiceLockCompatibility(): WindowsServiceLockCompatibility { return makeWindowsServiceLockCompatibility({ fileSystem: makeLiveFileSystem(), diff --git a/apps/local/src/service/windows/mutation-lock.ts b/apps/local/src/service/windows/mutation-lock.ts index b7e74ed6..ad97afff 100644 --- a/apps/local/src/service/windows/mutation-lock.ts +++ b/apps/local/src/service/windows/mutation-lock.ts @@ -2,10 +2,14 @@ import { randomUUID } from "node:crypto"; import { Database } from "bun:sqlite"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import { canonicalWindowsServiceControlDir, - makeLiveWindowsServiceLockCompatibility, + WindowsLockCompatibility, WINDOWS_USER_SERVICE_NAMESPACE, WindowsUserServiceMutationLockScopeSchema, windowsServiceMutationSqlitePath, @@ -27,7 +31,7 @@ export interface WindowsUserServiceMutationLockLease extends WindowsUserServiceM export interface WindowsUserServiceMutationLockDatabase { readonly close: () => void; - readonly run: (sql: string) => unknown; + readonly run: (sql: string) => void; } export interface WindowsUserServiceMutationLockDependencies { @@ -68,28 +72,32 @@ function failure(operation: string, cause: unknown): WindowsServiceMutationLockE }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} +const decodeLockFailure = Schema.decodeUnknownOption( + Schema.Struct({ + code: Schema.optionalKey(Schema.Unknown), + errno: Schema.optionalKey(Schema.Unknown), + cause: Schema.optionalKey(Schema.Unknown), + }), +); +const isBusyCode = Schema.is( + Schema.Union([ + Schema.Literals(["SQLITE_BUSY", "SQLITE_LOCKED"]), + Schema.String.check(Schema.isPattern(/^SQLITE_(?:BUSY|LOCKED)_/)), + ]), +); export function isWindowsServiceMutationLockBusy(cause: unknown): boolean { const visited = new WeakSet(); let current = cause; - while (isRecord(current) && !visited.has(current)) { + while (Predicate.isObject(current) && !visited.has(current)) { visited.add(current); - const code = current.code; - const errno = current.errno; - if ( - code === "SQLITE_BUSY" || - code === "SQLITE_LOCKED" || - (typeof code === "string" && - (code.startsWith("SQLITE_BUSY_") || code.startsWith("SQLITE_LOCKED_"))) || - errno === 5 || - errno === 6 - ) { + const decoded = decodeLockFailure(current); + if (Option.isNone(decoded)) return false; + const { code, errno } = decoded.value; + if (isBusyCode(code) || errno === 5 || errno === 6) { return true; } - current = current.cause; + current = decoded.value.cause; } return false; } @@ -175,8 +183,19 @@ export function makeWindowsUserServiceMutationLock( return { acquire, release, withLock }; } +export class WindowsMutationLock extends Context.Service< + WindowsMutationLock, + WindowsUserServiceMutationLock +>()("SelfTune/WindowsMutationLock") {} + +export const WindowsMutationLockLive = Layer.effect(WindowsMutationLock)( + Effect.gen(function* () { + return makeLiveWindowsUserServiceMutationLock(yield* WindowsLockCompatibility); + }), +); + export function makeLiveWindowsUserServiceMutationLock( - compatibility: WindowsServiceLockCompatibility = makeLiveWindowsServiceLockCompatibility(), + compatibility: WindowsServiceLockCompatibility, ): WindowsUserServiceMutationLock { return makeWindowsUserServiceMutationLock({ compatibility, diff --git a/apps/local/src/service/windows/runtime/live.ts b/apps/local/src/service/windows/runtime/live.ts index 5d427354..79e10698 100644 --- a/apps/local/src/service/windows/runtime/live.ts +++ b/apps/local/src/service/windows/runtime/live.ts @@ -1,8 +1,10 @@ import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { readLocalAuthToken } from "../../../local-runtime.js"; import { serviceFailure, + WindowsRuntimeRecoveryProvider, type ServiceFailure, type WindowsRuntimeRecovery, } from "../../../service-contract.js"; @@ -20,6 +22,10 @@ type RunServiceProcess = ( args: ReadonlyArray, ) => Effect.Effect; +export function makeWindowsRuntimeRecoveryLayer(run: RunServiceProcess) { + return Layer.sync(WindowsRuntimeRecoveryProvider)(() => makeLiveWindowsRuntimeRecovery(run)); +} + export function makeLiveWindowsRuntimeRecovery(run: RunServiceProcess): WindowsRuntimeRecovery { const dependencies = { makeFailure: serviceFailure, diff --git a/apps/local/src/service/windows/scheduler.ts b/apps/local/src/service/windows/scheduler.ts index 441aa3c1..52130fbd 100644 --- a/apps/local/src/service/windows/scheduler.ts +++ b/apps/local/src/service/windows/scheduler.ts @@ -1,10 +1,29 @@ import { win32 } from "node:path"; import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import { serviceFailure, type ServiceFailure } from "../../service-contract.js"; const DEFAULT_WINDOWS_ROOT = "C:\\Windows"; const SCHED_S_TASK_RUNNING = 267_009; +export class WindowsTaskSchedulers extends Context.Service< + WindowsTaskSchedulers, + { + readonly forTask: (taskName: string) => WindowsTaskScheduler; + } +>()("SelfTune/WindowsTaskSchedulers") {} + +export function makeWindowsTaskSchedulersLayer( + options: Pick, "execute" | "systemRoot">, +) { + return Layer.succeed(WindowsTaskSchedulers)({ + forTask: (taskName) => + makeWindowsTaskScheduler({ ...options, taskName, makeFailure: serviceFailure }), + }); +} + export interface WindowsSchedulerCommandResult { readonly code: number; readonly stderr: string; diff --git a/apps/local/src/skill-set-plugin-install.ts b/apps/local/src/skill-set-plugin-install.ts index b678e378..c5b76b17 100644 --- a/apps/local/src/skill-set-plugin-install.ts +++ b/apps/local/src/skill-set-plugin-install.ts @@ -1,10 +1,12 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; +import * as Schema from "effect/Schema"; import { projectSkillSetPlugin, type SkillSetServiceOptions } from "@selftune/library"; import { SELFTUNE_CONFIG_DIR } from "@selftune/runtime/constants"; import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { ClaudePlugin, CodexPlugin } from "./plugin-host-contract.js"; export type NativePluginHost = "claude" | "codex"; @@ -69,31 +71,15 @@ const defaultRuntime: PluginInstallRuntime = { now: () => new Date(), }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function stringField(value: unknown, key: string): string | null { - return isRecord(value) && typeof value[key] === "string" ? value[key] : null; -} - -function marketplaceEntries(host: NativePluginHost, value: unknown): ReadonlyArray { - if (host === "claude") return Array.isArray(value) ? value : []; - return isRecord(value) && Array.isArray(value.marketplaces) ? value.marketplaces : []; -} - -function installedEntries(host: NativePluginHost, value: unknown): ReadonlyArray { - if (host === "claude") return Array.isArray(value) ? value : []; - return isRecord(value) && Array.isArray(value.installed) ? value.installed : []; -} +const ClaudeInstalled = Schema.Array(ClaudePlugin); +const CodexInstalled = Schema.Struct({ installed: Schema.Array(CodexPlugin) }); +const ClaudeMarketplaces = Schema.Array( + Schema.Struct({ name: Schema.String, path: Schema.String }), +); +const CodexMarketplaces = Schema.Struct({ + marketplaces: Schema.Array(Schema.Struct({ name: Schema.String, root: Schema.String })), +}); +const PluginManifest = Schema.Record(Schema.String, Schema.Json); function hostLabel(host: NativePluginHost): string { return host === "claude" ? "Claude" : "Codex"; @@ -119,13 +105,27 @@ function commandOutput(result: PluginInstallCommandResult): string { return output.slice(0, 2_000); } -function runJson( +function runJson( runtime: PluginInstallRuntime, command: string, args: ReadonlyArray, -): unknown { + schema: Schema.Codec, +): A { const result = runtime.run(command, args); - return result.exitCode === 0 ? parseJson(result.stdout) : null; + if (result.exitCode !== 0) { + throw new CLIError( + `Could not inspect host plugins: ${commandOutput(result)}`, + "OPERATION_FAILED", + ); + } + try { + return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(result.stdout); + } catch { + throw new CLIError( + "Host plugin inventory response is invalid. Update the host CLI and retry.", + "OPERATION_FAILED", + ); + } } function hostState( @@ -146,15 +146,14 @@ function hostState( activation, }; } - const entries = installedEntries( - host, - runJson(runtime, executable, ["plugin", "list", "--json"]), - ); - const installed = entries.find((entry) => { - const id = stringField(entry, host === "claude" ? "id" : "pluginId"); - return id === pluginId; - }); - const installedVersion = stringField(installed, "version"); + const args = ["plugin", "list", "--json"]; + const installed = + host === "claude" + ? runJson(runtime, executable, args, ClaudeInstalled).find((entry) => entry.id === pluginId) + : runJson(runtime, executable, args, CodexInstalled).installed.find( + (entry) => entry.pluginId === pluginId, + ); + const installedVersion = installed?.version ?? null; return { host, label: hostLabel(host), @@ -220,11 +219,14 @@ function safeOutputPath(root: string, relativePath: string): string { function versionedManifest(path: string, bytes: Uint8Array, version: string): Uint8Array { if (path !== ".claude-plugin/plugin.json" && path !== ".codex-plugin/plugin.json") return bytes; - const decoded = parseJson(new TextDecoder().decode(bytes)); - if (!isRecord(decoded)) { + try { + const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(PluginManifest))( + new TextDecoder().decode(bytes), + ); + return new TextEncoder().encode(`${JSON.stringify({ ...decoded, version }, null, 2)}\n`); + } catch { throw new CLIError(`Plugin manifest is invalid: ${path}`, "GUARD_BLOCKED"); } - return new TextEncoder().encode(`${JSON.stringify({ ...decoded, version }, null, 2)}\n`); } function materializeMarketplace(input: { @@ -294,12 +296,18 @@ function configuredMarketplaceRoot( host: NativePluginHost, name: string, ): string | null { - const value = runJson(runtime, executable, ["plugin", "marketplace", "list", "--json"]); - const entry = marketplaceEntries(host, value).find( - (candidate) => stringField(candidate, "name") === name, + const args = ["plugin", "marketplace", "list", "--json"]; + if (host === "claude") { + return ( + runJson(runtime, executable, args, ClaudeMarketplaces).find((entry) => entry.name === name) + ?.path ?? null + ); + } + return ( + runJson(runtime, executable, args, CodexMarketplaces).marketplaces.find( + (entry) => entry.name === name, + )?.root ?? null ); - if (!entry) return null; - return stringField(entry, host === "claude" ? "path" : "root"); } function runRequired( @@ -325,6 +333,7 @@ function installIntoHost(input: { readonly marketplaceName: string; readonly pluginName: string; readonly preview: NativePluginHostPreview; + readonly configuredRoot: string | null; }): SkillSetPluginInstallReceipt["hosts"][number] { const executable = input.runtime.which(input.host === "claude" ? "claude" : "codex"); if (!executable) { @@ -334,20 +343,7 @@ function installIntoHost(input: { ); } const pluginId = `${input.pluginName}@${input.marketplaceName}`; - const configuredRoot = configuredMarketplaceRoot( - input.runtime, - executable, - input.host, - input.marketplaceName, - ); - if (configuredRoot && resolve(configuredRoot) !== resolve(input.marketplaceRoot)) { - throw new CLIError( - `${hostLabel(input.host)} already has a different marketplace named ${input.marketplaceName}.`, - "GUARD_BLOCKED", - "Remove the conflicting marketplace in the host plugin manager, then retry.", - ); - } - if (!configuredRoot) { + if (!input.configuredRoot) { const args = ["plugin", "marketplace", "add", input.marketplaceRoot]; if (input.host === "codex") args.push("--json"); runRequired( @@ -425,6 +421,28 @@ export function installSkillSetPlugin( } const projected = projection(input.setId, options); const configRoot = resolve(options.configRoot ?? SELFTUNE_CONFIG_DIR); + const expectedRoot = resolve(configRoot, "plugin-marketplaces", preview.marketplaceName); + const preparedHosts = hosts.map((host) => { + const hostPreview = preview.hosts.find((candidate) => candidate.host === host); + if (!hostPreview) throw new CLIError(`Unsupported plugin host: ${host}`, "INVALID_FLAG"); + const executable = runtime.which(host); + if (!executable) + throw new CLIError(`${hostLabel(host)} is not installed on this machine.`, "FILE_NOT_FOUND"); + const configuredRoot = configuredMarketplaceRoot( + runtime, + executable, + host, + preview.marketplaceName, + ); + if (configuredRoot && resolve(configuredRoot) !== expectedRoot) { + throw new CLIError( + `${hostLabel(host)} already has a different marketplace named ${preview.marketplaceName}.`, + "GUARD_BLOCKED", + "Remove the conflicting marketplace in the host plugin manager, then retry.", + ); + } + return { host, configuredRoot, preview: hostPreview }; + }); const marketRoot = materializeMarketplace({ configRoot, marketplaceName: preview.marketplaceName, @@ -433,18 +451,15 @@ export function installSkillSetPlugin( description: `SelfTune Skill Set: ${preview.setName}`, files: projected.files, }); - const installedHosts = hosts.map((host) => { - const hostPreview = preview.hosts.find((candidate) => candidate.host === host); - if (!hostPreview) { - throw new CLIError(`Unsupported plugin host: ${host}`, "INVALID_FLAG"); - } + const installedHosts = preparedHosts.map((prepared) => { return installIntoHost({ runtime, - host, + host: prepared.host, marketplaceRoot: marketRoot, marketplaceName: preview.marketplaceName, pluginName: preview.pluginName, - preview: hostPreview, + preview: prepared.preview, + configuredRoot: prepared.configuredRoot, }); }); const receipt: SkillSetPluginInstallReceipt = { diff --git a/apps/local/src/trace-candidate-contract.ts b/apps/local/src/trace-candidate-contract.ts index c76bd3bd..fdb254bd 100644 --- a/apps/local/src/trace-candidate-contract.ts +++ b/apps/local/src/trace-candidate-contract.ts @@ -1,5 +1,24 @@ import { Context, Effect, Schema } from "effect"; +export const TraceCandidateRequest = Schema.Struct({ + pattern_id: Schema.String.check(Schema.isNonEmpty()), + candidate_count: Schema.optionalKey( + Schema.Number.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(2), + Schema.isLessThanOrEqualTo(8), + ), + ), + calibration_repetitions: Schema.optionalKey( + Schema.Number.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(5), + ), + ), +}); +export type TraceCandidateRequest = typeof TraceCandidateRequest.Type; + export interface TraceCandidateReview { readonly draft_id: string | null; readonly pattern_id: string; @@ -52,7 +71,7 @@ export class TraceCandidatePreparationError extends Schema.TaggedErrorClass Effect.Effect; } diff --git a/apps/local/src/trace-candidate-service.ts b/apps/local/src/trace-candidate-service.ts index 5d6ad254..63a92dfb 100644 --- a/apps/local/src/trace-candidate-service.ts +++ b/apps/local/src/trace-candidate-service.ts @@ -30,6 +30,7 @@ import { import { TraceCandidatePreparation, TraceCandidatePreparationError, + TraceCandidateRequest, type TraceCandidateReview, } from "./trace-candidate-contract.js"; @@ -43,25 +44,6 @@ function taskMentionsSkill(task: string, skillName: string): boolean { return normalizedTask.includes(normalizedSkill) || normalizedTask.includes(`/${normalizedSkill}`); } -const requestSchema = Schema.Struct({ - pattern_id: Schema.String.check(Schema.isNonEmpty()), - candidate_count: Schema.optionalKey( - Schema.Number.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(2), - Schema.isLessThanOrEqualTo(8), - ), - ), - calibration_repetitions: Schema.optionalKey( - Schema.Number.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(5), - ), - ), -}); -export type TraceCandidateRequest = typeof requestSchema.Type; - const supportedPatternThreshold = { uniqueTraces: 3, errorTraces: 2, @@ -95,7 +77,7 @@ function matchingInstalledSkill(patternId: string, searchDirs: readonly string[] }); } -export function makeLiveCohortBodyTeacher(options?: { +function makeLiveCohortBodyTeacher(options?: { readonly agent?: LlmBackedAgent; }): CohortBodyTeacher { return async (input) => { @@ -106,18 +88,17 @@ export function makeLiveCohortBodyTeacher(options?: { maxTurns: 1, }); try { - return JSON.parse(raw); + return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(raw); } catch { throw new Error("The local teacher did not return a JSON object."); } }; } -export const liveCohortBodyTeacher: CohortBodyTeacher = makeLiveCohortBodyTeacher(); - export function makeTraceCandidatePreparationLayer(options: { sqlite: Database; teacher?: CohortBodyTeacher; + teacherAgent?: LlmBackedAgent; studentAgent?: LlmBackedAgent; studentModel?: string; searchDirs?: readonly string[]; @@ -129,8 +110,11 @@ export function makeTraceCandidatePreparationLayer(options: { TraceCandidatePreparation, Effect.gen(function* () { const analytical = yield* DuckDbAnalyticalStore; - const prepare = Effect.fn("TraceCandidatePreparation.prepare")(function* (unknown: unknown) { - const input = yield* Schema.decodeUnknownEffect(requestSchema)(unknown).pipe( + const teacher = options.teacher ?? makeLiveCohortBodyTeacher({ agent: options.teacherAgent }); + const prepare = Effect.fn("TraceCandidatePreparation.prepare")(function* ( + request: TraceCandidateRequest, + ) { + const input = yield* Schema.decodeUnknownEffect(TraceCandidateRequest)(request).pipe( Effect.mapError( (error) => new TraceCandidatePreparationError({ message: error.message }), ), @@ -188,7 +172,7 @@ export function makeTraceCandidatePreparationLayer(options: { return yield* prepareHistoricalTaskCandidate({ analytical, sqlite: options.sqlite, - teacher: options.teacher ?? liveCohortBodyTeacher, + teacher, patternId: input.pattern_id, installed, skillId, @@ -216,8 +200,22 @@ export function makeTraceCandidatePreparationLayer(options: { const rows = ids.length === 0 ? [] - : (options.sqlite - .query( + : options.sqlite + .query< + { + skill_invocation_id: string; + query: string | null; + matched_prompt: string | null; + triggered: number | null; + invocation_mode: string | null; + source: string | null; + capture_mode: string | null; + skill_version_hash: string | null; + occurred_at: string | null; + skill_path: string | null; + }, + string[] + >( `SELECT invocation.skill_invocation_id, invocation.query, @@ -233,18 +231,7 @@ export function makeTraceCandidatePreparationLayer(options: { LEFT JOIN prompts prompt ON prompt.prompt_id = invocation.matched_prompt_id WHERE invocation.skill_invocation_id IN (${placeholders})`, ) - .all(...ids) as Array<{ - skill_invocation_id: string; - query: string | null; - matched_prompt: string | null; - triggered: number | null; - invocation_mode: string | null; - source: string | null; - capture_mode: string | null; - skill_version_hash: string | null; - occurred_at: string | null; - skill_path: string | null; - }>); + .all(...ids); const packageMtimeMs = yield* Effect.try({ try: () => latestPackageMtimeMs(installed.package_path), catch: (error) => @@ -358,7 +345,7 @@ export function makeTraceCandidatePreparationLayer(options: { { cohort: materializedCohort, resolved_evidence: resolved, - teacher: options.teacher ?? liveCohortBodyTeacher, + teacher, student_agent: options.studentAgent, student_model: options.studentModel, }, @@ -392,12 +379,13 @@ export function makeTraceCandidatePreparationLayer(options: { }, excerpt_limit_bytes: materializedCohort.excerpt_limit_bytes, request_limit_bytes: materializedCohort.request_limit_bytes, - entries: materializedCohort.entries.map((entry) => ({ - ...entry, - ...(entry.redacted_excerpt === undefined - ? {} - : { redacted_excerpt: redactedPortableText(entry.redacted_excerpt) }), - })), + entries: materializedCohort.entries.map((entry) => { + const portable = { ...entry }; + if (entry.redacted_excerpt !== undefined) { + portable.redacted_excerpt = redactedPortableText(entry.redacted_excerpt); + } + return portable; + }), fingerprint: materializedCohort.fingerprint, }, candidate: { diff --git a/apps/local/tests/application-response-boundary.test.ts b/apps/local/tests/application-response-boundary.test.ts new file mode 100644 index 00000000..9f7a4fac --- /dev/null +++ b/apps/local/tests/application-response-boundary.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import { makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; +import { handleDashboardApplicationRoute } from "../src/routes/application.js"; + +const origin = "http://127.0.0.1:3141"; + +test.each([true, false])( + "preserves Skill Set error status and headers (read-only=%s)", + async (readOnly) => { + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer( + readOnly ? { skillSetsLoader: () => ({ sets: [], receipts: [] }) } : {}, + ), + ); + const request = new Request(`${origin}/api/v2/skill-sets/unknown`, { + method: "POST", + headers: { Origin: origin }, + body: "{}", + }); + try { + const response = await runtime.runPromise( + handleDashboardApplicationRoute(request, new URL(request.url), { + allowedOrigins: new Set([origin]), + }), + ); + expect(response?.status).toBe(readOnly ? 405 : 404); + expect(response?.headers.get("Access-Control-Allow-Origin")).toBe("*"); + if (readOnly) expect(response?.headers.get("Allow")).toBe("GET"); + expect(await response?.json()).toMatchObject({ + error: { code: readOnly ? "READ_ONLY_HOST" : "NOT_FOUND" }, + }); + } finally { + await runtime.dispose(); + } + }, +); diff --git a/apps/local/tests/cloud-billing-routes.test.ts b/apps/local/tests/cloud-billing-routes.test.ts index c00dafb7..b6cc1ab0 100644 --- a/apps/local/tests/cloud-billing-routes.test.ts +++ b/apps/local/tests/cloud-billing-routes.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import type * as Schema from "effect/Schema"; +import { jsonRequest } from "../../../tests/helpers/json-request.js"; import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; import { handleDashboardApplicationRoute } from "../src/routes/application.js"; @@ -38,13 +40,13 @@ describe("Cloud billing application routes", () => { }, }), ); - const request = async (path: string, body?: unknown) => { - const next = new Request(`${origin}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: - body === undefined ? undefined : { Origin: origin, "Content-Type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); + const request = async (path: string, body?: typeof Schema.Json.Type) => { + const next = jsonRequest( + `${origin}${path}`, + body === undefined ? "GET" : "POST", + body, + body === undefined ? undefined : origin, + ); return runtime.runPromise( Effect.gen(function* () { yield* DashboardOperations; diff --git a/apps/local/tests/compatibility-export-lifecycle.test.ts b/apps/local/tests/compatibility-export-lifecycle.test.ts deleted file mode 100644 index a98a54f1..00000000 --- a/apps/local/tests/compatibility-export-lifecycle.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import type { CompatibilityExportWorker } from "@selftune/runtime/alpha-upload/worker"; - -import { startDashboardServer } from "../src/dashboard-server.js"; - -const directories: string[] = []; -const servers: Array>> = []; -const authToken = "PLACEHOLDER_COMPATIBILITY_EXPORT_TOKEN"; - -afterEach(async () => { - await Promise.all(servers.splice(0).map((server) => server.close())); - for (const directory of directories.splice(0)) { - rmSync(directory, { force: true, recursive: true }); - } -}); - -function temporaryConfigDirectory(): string { - const directory = mkdtempSync(join(tmpdir(), "selftune-compatibility-export-")); - directories.push(directory); - return directory; -} - -function trackedWorker(events: string[]): CompatibilityExportWorker { - return { - start: () => events.push("worker:start"), - requestFlush: async () => null, - status: () => ({ state: "stopped", lastSummary: null, lastError: null, nextAttemptAt: null }), - stop: async () => { - events.push("worker:stop"); - }, - }; -} - -test("the standalone local daemon owns exactly one compatibility-export worker", async () => { - const events: string[] = []; - const server = await startDashboardServer({ - authToken, - compatibilityExportWorkerFactory: () => trackedWorker(events), - host: "127.0.0.1", - manageProcessSignals: false, - openBrowser: false, - port: 0, - runtimeMode: "standalone", - skillSetConfigRoot: temporaryConfigDirectory(), - }); - servers.push(server); - - expect(events).toEqual(["worker:start"]); - await server.close(); - expect(events).toEqual(["worker:start", "worker:stop"]); -}); - -test("test and selfhost hosts never start the cloud compatibility-export worker", async () => { - const events: string[] = []; - const testServer = await startDashboardServer({ - authToken, - compatibilityExportWorkerFactory: () => trackedWorker(events), - dashboardHost: "local", - host: "127.0.0.1", - manageProcessSignals: false, - openBrowser: false, - port: 0, - runtimeMode: "test", - skillSetConfigRoot: temporaryConfigDirectory(), - }); - const selfhostServer = await startDashboardServer({ - authToken, - compatibilityExportWorkerFactory: () => trackedWorker(events), - dashboardHost: "selfhost", - host: "127.0.0.1", - manageProcessSignals: false, - openBrowser: false, - port: 0, - runtimeMode: "standalone", - skillSetConfigRoot: temporaryConfigDirectory(), - }); - servers.push(testServer, selfhostServer); - - expect(events).toEqual([]); -}); - -test("a daemon bind failure stops its already-started compatibility-export worker", async () => { - const blocker = await startDashboardServer({ - authToken, - host: "127.0.0.1", - manageProcessSignals: false, - openBrowser: false, - port: 0, - runtimeMode: "test", - skillSetConfigRoot: temporaryConfigDirectory(), - }); - servers.push(blocker); - const events: string[] = []; - - await expect( - startDashboardServer({ - authToken, - compatibilityExportWorkerFactory: () => trackedWorker(events), - host: "127.0.0.1", - manageProcessSignals: false, - openBrowser: false, - port: blocker.port, - runtimeMode: "standalone", - skillSetConfigRoot: temporaryConfigDirectory(), - }), - ).rejects.toThrow("Failed to start server"); - - expect(events).toEqual(["worker:start", "worker:stop"]); -}); diff --git a/apps/local/tests/correction-learning-e2-acceptance.test.ts b/apps/local/tests/correction-learning-e2-acceptance.test.ts index e2a6133a..c8b67180 100644 --- a/apps/local/tests/correction-learning-e2-acceptance.test.ts +++ b/apps/local/tests/correction-learning-e2-acceptance.test.ts @@ -10,6 +10,7 @@ import { openDb, } from "@selftune/local-store"; import { captureCorrectionSignalStudies } from "@selftune/orchestration/orchestrate/correction-signal-studies"; +import type { ExplicitCorrectionSignal } from "@selftune/runtime/correction-study/signal-discovery"; import * as Effect from "effect/Effect"; import { captureManagedCorrectionStudy } from "../src/correction-study-service.js"; @@ -38,14 +39,16 @@ function verifier() { success_contract: "Portal status proves the claim.", check_description: "Checks portal status.", }, - evidence: ["known_failure", "known_good", "boundary", "adversarial"].map((label) => ({ - evidence_id: `control-${label}`, - label, - expected_decision: label === "known_failure" ? "reject" : "accept", - observed_decision: label === "known_failure" ? "reject" : "accept", - partition: "verifier_calibration", - candidate_strategy_reference: null, - })), + evidence: (["known_failure", "known_good", "boundary", "adversarial"] as const).map( + (label) => ({ + evidence_id: `control-${label}`, + label, + expected_decision: label === "known_failure" ? "reject" : "accept", + observed_decision: label === "known_failure" ? "reject" : "accept", + partition: "verifier_calibration", + candidate_strategy_reference: null, + }), + ), }); } @@ -64,15 +67,32 @@ describe("correction learning E2 acceptance", () => { const postRevision = revision(after); const id = skillId("release-checklist"); - const signal = { + const signal: ExplicitCorrectionSignal = { candidate_id: "signal-1", + kind: "explicit_correction_hypothesis", + dry_run: true, evidence_level: "E0.5" as const, review_status: "review_required" as const, - reason: "User corrected the skill after a false upload claim.", - skill: { name: "release-checklist", pre_revision: preRevision, post_revision: postRevision }, - source: { session_id: "session-1", prompt_id: "prompt-2", raw_source_ref_digest: null }, + reason: "raw_exact_contents", + skill: { + name: "release-checklist", + path: "[local-path-redacted]", + pre_revision: preRevision, + post_revision: postRevision, + }, + source: { + harness: "codex", + session_id: "session-1", + prompt_id: "prompt-2", + skill_invocation_id: "invocation-1", + raw_source_ref_digest: null, + }, raw_edit_digest: fingerprint(after), + raw_content_digests: { before: hash(before), after: hash(after) }, deferred_skill_names: null, + correlation_truncated: false, + intent_detection: "heuristic", + proves_causality: false, correction_intent: "Require portal confirmation before declaring upload success.", }; const captured = await captureCorrectionSignalStudies({ diff --git a/apps/local/tests/correction-review-projection.test.ts b/apps/local/tests/correction-review-projection.test.ts index 90ec8742..6a68b746 100644 --- a/apps/local/tests/correction-review-projection.test.ts +++ b/apps/local/tests/correction-review-projection.test.ts @@ -11,6 +11,9 @@ import { projectCorrectionReview, } from "../src/correction-review-projection.js"; import { recordLocalCorrectionReviewDecision } from "../src/correction-review-service.js"; +import type { CorrectionReviewRequest } from "../src/correction-review-request.js"; +import { createCorrectionStudyRoutes } from "../src/routes/correction-studies.js"; +import { studyResponse } from "./correction-route-fixtures.js"; const databases: Array> = []; const manifestDigest = `sha256:${"d".repeat(64)}`; @@ -194,6 +197,40 @@ test("bounds malformed projection payloads instead of exposing raw JSON", () => expect(() => listCorrectionReviews(database, 129)).toThrow(RangeError); }); +test.each(["accept", "reject", "defer"])( + "records an HTTP %s review once without applying a skill", + async (action) => { + const database = openDb(":memory:"); + databases.push(database); + seedCandidate(database); + const origin = "http://127.0.0.1:3141"; + const url = new URL(`${origin}/api/v2/correction-studies/review-decisions`); + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, + recordReviewDecision: async (input) => recordLocalCorrectionReviewDecision(database, input), + }); + const body = JSON.stringify({ + candidate_id: "candidate-review", + action, + reason: "Reviewed evidence", + manifest_digest: manifestDigest, + }); + const request = () => new Request(url, { method: "POST", headers: { origin }, body }); + const first = await routes.handle(request(), url, new Set([origin])); + const retry = await routes.handle(request(), url, new Set([origin])); + expect(first?.status).toBe(200); + expect(retry?.status).toBe(200); + expect(await first?.json()).toEqual({ recorded: true, action, applies_skill: false }); + expect(await retry?.json()).toEqual({ recorded: true, action, applies_skill: false }); + expect( + database + .query<{ count: number }, []>("SELECT COUNT(*) AS count FROM correction_review_decisions") + .get()?.count, + ).toBe(1); + }, +); + test("records identical dashboard review delivery exactly once without applying", () => { const database = openDb(":memory:"); databases.push(database); @@ -203,7 +240,7 @@ test("records identical dashboard review delivery exactly once without applying" action: "defer", reason: "Wait for the release window.", manifest_digest: manifestDigest, - }; + } satisfies CorrectionReviewRequest; expect( recordLocalCorrectionReviewDecision(database, input, "2026-07-29T12:10:00.000Z"), ).toMatchObject({ recorded: true, applies_skill: false }); @@ -214,3 +251,90 @@ test("records identical dashboard review delivery exactly once without applying" .get()?.count, ).toBe(1); }); + +test.each(["null", "[]", "42", '"unexpected"', "{"])( + "keeps scalar review metadata when saved evidence is %s", + (payload) => { + const projected = projectCorrectionReview({ + candidate_id: "candidate-partial", + evidence_level: "E1", + reason: "Saved candidate reason", + signal_payload_json: payload, + study_payload_json: payload, + evaluation_manifest_json: payload, + evaluation_result_json: payload, + verifier_provenance: payload, + evaluation_status: "inconclusive", + evaluation_reason: "Saved evaluation reason", + }); + expect(projected).toMatchObject({ + candidate_id: "candidate-partial", + evidence_level: "E1", + observed_failure: "Saved candidate reason", + evaluation: { summary: "inconclusive: Saved evaluation reason", regressions: [] }, + proposed_change: null, + provenance: ["Candidate manifest"], + terminal: false, + }); + }, +); + +test("retains valid regression neighbors and does not coerce malformed trial fields", () => { + const projected = projectCorrectionReview({ + signal_payload_json: JSON.stringify({ reason: "Valid reason", correction_intent: 12 }), + study_payload_json: JSON.stringify({ + task_capsule: { observed_failure: [], correction_intent: "Keep portal confirmation" }, + }), + evaluation_status: "rejected", + evaluation_manifest_json: JSON.stringify({ + candidate: { installed_body: "Before", proposed_body: "After", changed_lines: "2" }, + active_regression_cases: [null, 2, [], { case_id: 4 }, { case_id: "active" }], + }), + evaluation_result_json: JSON.stringify({ + reason: {}, + trials: [ + null, + [], + { + case_id: "active", + arm: "candidate_skill", + passed_repetitions: "0", + scored_repetitions: 3, + }, + { case_id: "active", arm: "candidate_skill", skipped: "true" }, + { case_id: "inactive", arm: "candidate_skill", skipped: true }, + { case_id: "active", arm: "current_skill", skipped: true }, + { case_id: "active", arm: "candidate_skill", passed_repetitions: 1, scored_repetitions: 3 }, + { case_id: "active", arm: "candidate_skill", skipped: true }, + ], + }), + verifier_provenance: JSON.stringify({ instrument: { verifier_id: "portal", version: 2 } }), + }); + expect(projected.observed_failure).toBe("Valid reason"); + expect(projected.correction_intent).toBe("Keep portal confirmation"); + expect(projected.proposed_change).toEqual({ + diff: "--- current skill body\n+++ proposed skill body\n-Before\n+After", + summary: "Bounded changed line(s)", + }); + expect(projected.evaluation).toEqual({ + summary: "rejected: No reason recorded", + regressions: ["active", "active"], + }); + expect(projected.provenance).toEqual(["Verifier portal"]); +}); + +test("keeps explicit empty evidence and bounded display text", () => { + const projected = projectCorrectionReview({ + reason: "Fallback reason", + signal_payload_json: JSON.stringify({ reason: "" }), + study_payload_json: JSON.stringify({ task_capsule: { correction_intent: "x".repeat(8_001) } }), + evaluation_manifest_json: JSON.stringify({ + candidate: { installed_body: "Before", proposed_body: "After", changed_lines: 0 }, + }), + last_action: "reject", + }); + expect(projected.observed_failure).toBe(""); + expect(projected.correction_intent).toHaveLength(8_000); + expect(projected.proposed_change?.summary).toBe("0 changed line(s)"); + expect(projected.terminal).toBeTrue(); +}); diff --git a/apps/local/tests/correction-route-fixtures.ts b/apps/local/tests/correction-route-fixtures.ts new file mode 100644 index 00000000..3103dd92 --- /dev/null +++ b/apps/local/tests/correction-route-fixtures.ts @@ -0,0 +1,77 @@ +import type { + ExplicitCorrectionStudyRequest, + CorrectionStudyServiceResponse, +} from "../src/correction-study-service.js"; +import type { ExplicitCorrectionSignal } from "@selftune/runtime/correction-study/signal-discovery"; + +export const studyRequest = { + episode: { + skill_id: "release-checklist", + skill_name: "release-checklist", + skill_path: "/tmp/release-checklist/SKILL.md", + task: "Prepare release", + observed_failure: "Upload was not confirmed", + correction_intent: "Check portal confirmation", + pre_edit_revision: "a".repeat(64), + post_edit_revision: "b".repeat(64), + bounded_diff: "Add confirmation check", + provenance: { harness: "codex", trace_id: "trace-1", session_id: "session-1" }, + captured_at: "2026-09-06T10:00:00Z", + }, + verifier: { + verifier_id: "portal-check", + version: "v1", + kind: "deterministic", + qualification: { rejects_known_failure: true, accepts_known_good: true }, + }, + trials: [], +} satisfies ExplicitCorrectionStudyRequest; + +export const studyResponse = { + episode_id: "episode-1", + skill_id: "release-checklist", + skill_name: "release-checklist", + evidence_level: "E0.5", + status: "inconclusive", + reason: "Insufficient scored pairs", + manifest_id: "manifest-1", + replay: { + source: "externally_supplied", + verified_by_selftune: false, + minimum_scored_trials: 3, + scored_pairs: 0, + censored_pairs: 0, + censored_attempts: 0, + }, + regression_case: null, + applies_change: false, +} satisfies CorrectionStudyServiceResponse; + +export const correctionSignal = { + candidate_id: "signal-1", + kind: "explicit_correction_hypothesis", + review_status: "review_required", + dry_run: true, + evidence_level: "E0", + reason: "missing_revision_evidence", + skill: { + name: "release-checklist", + path: "[local-path-redacted]", + pre_revision: null, + post_revision: null, + }, + source: { + harness: "codex", + session_id: "session-1", + prompt_id: "prompt-1", + skill_invocation_id: "invocation-1", + raw_source_ref_digest: null, + }, + raw_edit_digest: null, + raw_content_digests: null, + deferred_skill_names: null, + correlation_truncated: false, + correction_intent: "Check portal confirmation", + intent_detection: "heuristic", + proves_causality: false, +} satisfies ExplicitCorrectionSignal; diff --git a/apps/local/tests/correction-signal-discovery.test.ts b/apps/local/tests/correction-signal-discovery.test.ts index 3b237372..a4d94a6c 100644 --- a/apps/local/tests/correction-signal-discovery.test.ts +++ b/apps/local/tests/correction-signal-discovery.test.ts @@ -66,6 +66,56 @@ afterEach(() => { }); describe("explicit correction signal discovery", () => { + test("skips malformed captures without changing the retained artifact digest", () => { + const sqlite = database(); + insertCanonicalRows(sqlite, { invocationRevision: null }); + const artifact = { + event_type: "skill_md_edit_capture", + status: "captured", + session_id: "session-1", + target_digest: createHash("sha256").update("/local/release-checklist/SKILL.md").digest("hex"), + pre_revision: before, + post_revision: after, + pre_captured_at: "2026-07-29T10:05:15.000Z", + post_captured_at: "2026-07-29T10:05:30.000Z", + source_extension: { harness_version: "future-compatible" }, + }; + const serialized = JSON.stringify(artifact); + const signals = discoverExplicitCorrectionSignals(sqlite, { + inspectSkill: () => ({ revision: null, modified_at: null }), + readRawSource: () => null, + readSkillEditCaptures: () => + [ + "null", + "{broken", + JSON.stringify({ ...artifact, pre_revision: 42 }), + JSON.stringify({ ...artifact, post_captured_at: {} }), + serialized, + ].join("\n"), + }); + expect(signals).toHaveLength(1); + expect(signals[0]).toMatchObject({ + reason: "captured_package_revisions", + raw_edit_digest: createHash("sha256").update(serialized).digest("hex"), + proves_causality: false, + review_status: "review_required", + }); + }); + + test.each( + [ + null, + [], + { prompt_at: "not-a-date", prompt_id: "prompt-1" }, + { prompt_at: "2026-09-05", prompt_id: 1 }, + ].map((value) => ({ value })), + )("rejects malformed decoded cursor fields: %j", ({ value }) => { + const cursor = Buffer.from(JSON.stringify(value)).toString("base64url"); + expect(() => discoverExplicitCorrectionSignalPage(database(), { cursor })).toThrow( + "cursor is invalid", + ); + }); + test("discovers a bounded, redacted hash-backed hypothesis without writing a study", () => { const sqlite = database(); insertCanonicalRows(sqlite); diff --git a/apps/local/tests/correction-studies-route.test.ts b/apps/local/tests/correction-studies-route.test.ts index b4ea96d6..89c78cd1 100644 --- a/apps/local/tests/correction-studies-route.test.ts +++ b/apps/local/tests/correction-studies-route.test.ts @@ -4,10 +4,19 @@ import { CorrectionStudyServiceError, createCorrectionStudyRoutes, } from "../src/routes/correction-studies.js"; +import { correctionSignal, studyRequest, studyResponse } from "./correction-route-fixtures.js"; +import { projectCorrectionReview } from "../src/correction-review-projection.js"; const origin = "http://127.0.0.1:3141"; const allowedOrigins = new Set([origin]); const capturePath = "/api/v2/correction-studies/explicit-corrections"; +const reviewPath = "/api/v2/correction-studies/review-decisions"; +const review = { + candidate_id: "candidate-1", + action: "defer", + reason: "Needs evaluation.", + manifest_digest: `sha256:${"a".repeat(64)}`, +}; function captureRequest(body: string, headers: Record = {}): Request { return new Request(`${origin}${capturePath}`, { @@ -18,14 +27,160 @@ function captureRequest(body: string, headers: Record = {}): Req } describe("correction-study routes", () => { + test.each([ + null, + {}, + { ...studyRequest, episode: { ...studyRequest.episode, pre_edit_revision: "invalid" } }, + { ...studyRequest, verifier: { ...studyRequest.verifier, kind: "untrusted" } }, + { ...studyRequest, trials: [{ pair_id: "pair-1", pre_edit: "yes", post_edit: "pass" }] }, + ])("rejects malformed study contracts before capture: %j", async (payload) => { + let captured = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => { + captured = true; + return studyResponse; + }, + lookup: async () => studyResponse, + }); + const response = await routes.handle( + captureRequest(JSON.stringify(payload)), + new URL(`${origin}${capturePath}`), + allowedOrigins, + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + error: { code: "INVALID_CORRECTION_STUDY_REQUEST" }, + }); + expect(captured).toBeFalse(); + }); + + test.each( + [ + null, + [], + {}, + { ...review, candidate_id: "" }, + { ...review, candidate_id: "../candidate" }, + { ...review, candidate_id: "x".repeat(129) }, + { ...review, action: "edit" }, + { ...review, action: true }, + { ...review, reason: " " }, + { ...review, reason: "x".repeat(513) }, + { ...review, manifest_digest: "invalid" }, + { ...review, manifest_digest: null }, + ].map((payload) => ({ payload })), + )("rejects malformed review input before invoking persistence: %j", async ({ payload }) => { + let recorded = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, + recordReviewDecision: async () => { + recorded = true; + return { recorded: true, action: "defer", applies_skill: false }; + }, + }); + const response = await routes.handle( + new Request(`${origin}${reviewPath}`, { + method: "POST", + headers: { origin }, + body: JSON.stringify(payload), + }), + new URL(`${origin}${reviewPath}`), + allowedOrigins, + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ error: { code: "INVALID_CORRECTION_REVIEW" } }); + expect(recorded).toBeFalse(); + }); + + test.each([ + { body: "{broken", status: 400, code: "INVALID_CORRECTION_REVIEW_REQUEST" }, + { + body: JSON.stringify({ ...review, reason: "é".repeat(4_096) }), + status: 413, + code: "CORRECTION_REVIEW_REQUEST_TOO_LARGE", + }, + ])( + "retains JSON and byte-limit failures before review persistence: %j", + async ({ body, status, code }) => { + let recorded = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, + recordReviewDecision: async () => { + recorded = true; + return { recorded: true, action: "defer", applies_skill: false }; + }, + }); + const response = await routes.handle( + new Request(`${origin}${reviewPath}`, { + method: "POST", + headers: { origin }, + body, + }), + new URL(`${origin}${reviewPath}`), + allowedOrigins, + ); + expect(response?.status).toBe(status); + expect(await response?.json()).toMatchObject({ error: { code } }); + expect(recorded).toBeFalse(); + }, + ); + + test("rejects an invalid review-list query without calling the service", async () => { + let listed = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, + listReviews: async () => { + listed = true; + return { items: [] }; + }, + }); + const url = new URL(`${origin}/api/v2/correction-studies/reviews?limit=129`); + const response = await routes.handle(new Request(url), url, allowedOrigins); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + error: { code: "INVALID_CORRECTION_REVIEW_QUERY" }, + }); + expect(listed).toBeFalse(); + }); + + test.each([ + { + failure: new Error("Private database path"), + status: 503, + code: "CORRECTION_REVIEW_UNAVAILABLE", + message: "Correction review is unavailable.", + }, + { + failure: new CorrectionStudyServiceError("REVIEW_CONFLICT", "Review conflict", 409), + status: 409, + code: "REVIEW_CONFLICT", + message: "Review conflict", + }, + ])("returns bounded review-list failures: %j", async ({ failure, status, code, message }) => { + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, + listReviews: async () => { + throw failure; + }, + }); + const url = new URL(`${origin}/api/v2/correction-studies/reviews`); + const response = await routes.handle(new Request(url), url, allowedOrigins); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual({ error: { code, message } }); + }); + test("captures an allowed explicit correction without changing its payload", async () => { - const payload = { trace_id: "trace-1", correction: { intent: "Use the existing skill." } }; + const payload = studyRequest; const routes = createCorrectionStudyRoutes({ captureExplicitCorrection: async (input) => { expect(input).toEqual(payload); - return { episode_id: "episode-1", disposition: "captured" }; + return studyResponse; }, - lookup: async () => ({}), + lookup: async () => studyResponse, }); const response = await routes.handle( @@ -36,11 +191,10 @@ describe("correction-study routes", () => { expect(response?.status).toBe(200); if (!response) throw new Error("Expected correction capture response."); - expect(await response.json()).toEqual({ episode_id: "episode-1", disposition: "captured" }); + expect(await response.json()).toEqual(studyResponse); }); test("records a review decision without an apply operation", async () => { - let applied = false; const payload = { candidate_id: "candidate-1", action: "defer", @@ -48,11 +202,11 @@ describe("correction-study routes", () => { manifest_digest: `sha256:${"a".repeat(64)}`, }; const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, recordReviewDecision: async (input) => { expect(input).toEqual(payload); - return { recorded: true, applies_skill: applied }; + return { recorded: true, action: input.action, applies_skill: false }; }, }); const response = await routes.handle( @@ -65,18 +219,21 @@ describe("correction-study routes", () => { allowedOrigins, ); expect(response?.status).toBe(200); - expect(applied).toBeFalse(); - expect(await response?.json()).toEqual({ recorded: true, applies_skill: false }); + expect(await response?.json()).toEqual({ + recorded: true, + action: "defer", + applies_skill: false, + }); }); test("rejects a review decision mutation without Origin before recording it", async () => { let recorded = false; const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, recordReviewDecision: async () => { recorded = true; - return {}; + return { recorded: true, action: "defer", applies_skill: false }; }, }); const path = "/api/v2/correction-studies/review-decisions"; @@ -97,13 +254,17 @@ describe("correction-study routes", () => { }); test("lists a bounded persisted review projection for a same-origin dashboard GET without Origin", async () => { + const reviewItem = projectCorrectionReview({ + candidate_id: "candidate-1", + evidence_level: "E2", + }); const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), - listReviews: async (limit) => ({ - items: [{ candidate_id: "candidate-1", evidence_level: "E2" }], - limit, - }), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, + listReviews: async (limit) => { + expect(limit).toBe(7); + return { items: [reviewItem] }; + }, }); const response = await routes.handle( new Request(`${origin}/api/v2/correction-studies/reviews?limit=7`, { @@ -114,16 +275,15 @@ describe("correction-study routes", () => { ); expect(response?.status).toBe(200); expect(await response?.json()).toEqual({ - items: [{ candidate_id: "candidate-1", evidence_level: "E2" }], - limit: 7, + items: [reviewItem], }); }); test("rejects a review-list GET with an untrusted Origin", async () => { let listed = false; const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, listReviews: async () => { listed = true; return { items: [] }; @@ -144,9 +304,9 @@ describe("correction-study routes", () => { const routes = createCorrectionStudyRoutes({ captureExplicitCorrection: async () => { captured = true; - return {}; + return studyResponse; }, - lookup: async () => ({}), + lookup: async () => studyResponse, }); const response = await routes.handle( new Request(`${origin}${capturePath}`, { @@ -169,9 +329,9 @@ describe("correction-study routes", () => { const routes = createCorrectionStudyRoutes({ captureExplicitCorrection: async () => { captured = true; - return {}; + return studyResponse; }, - lookup: async () => ({}), + lookup: async () => studyResponse, }); const response = await routes.handle( captureRequest("{}", { origin: "https://evil.example" }), @@ -187,9 +347,9 @@ describe("correction-study routes", () => { const routes = createCorrectionStudyRoutes({ captureExplicitCorrection: async () => { captured = true; - return {}; + return studyResponse; }, - lookup: async () => ({}), + lookup: async () => studyResponse, }); const response = await routes.handle( captureRequest(JSON.stringify({ padding: "x".repeat(9_000) })), @@ -208,8 +368,8 @@ describe("correction-study routes", () => { test("rejects malformed capture JSON", async () => { const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, }); const response = await routes.handle( captureRequest("{not json"), @@ -224,10 +384,10 @@ describe("correction-study routes", () => { test("rejects missing and malformed episode ids before lookup", async () => { let lookedUp = false; const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), + captureExplicitCorrection: async () => studyResponse, lookup: async () => { lookedUp = true; - return {}; + return studyResponse; }, }); const missing = await routes.handle( @@ -256,10 +416,10 @@ describe("correction-study routes", () => { 409, ); }, - lookup: async () => ({}), + lookup: async () => studyResponse, }); const response = await routes.handle( - captureRequest("{}"), + captureRequest(JSON.stringify(studyRequest)), new URL(`${origin}${capturePath}`), allowedOrigins, ); @@ -275,10 +435,10 @@ describe("correction-study routes", () => { test("looks up an encoded, bounded correction episode id", async () => { const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), + captureExplicitCorrection: async () => studyResponse, lookup: async (episodeId) => { expect(episodeId).toBe("episode:one"); - return { episode_id: episodeId, status: "captured" }; + return { ...studyResponse, episode_id: episodeId }; }, }); const path = "/api/v2/correction-studies/episode%3Aone"; @@ -289,12 +449,12 @@ describe("correction-study routes", () => { ); expect(response?.status).toBe(200); if (!response) throw new Error("Expected correction study lookup response."); - expect(await response.json()).toEqual({ episode_id: "episode:one", status: "captured" }); + expect(await response.json()).toEqual({ ...studyResponse, episode_id: "episode:one" }); }); test("preserves a typed not-found lookup error", async () => { const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), + captureExplicitCorrection: async () => studyResponse, lookup: async () => { throw new CorrectionStudyServiceError( "CORRECTION_EPISODE_NOT_FOUND", @@ -322,12 +482,12 @@ describe("correction-study routes", () => { test("serves bounded, same-origin review-only correction signals", async () => { let received: { readonly limit: number; readonly cursor: string | null } | null = null; const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, discoverSignals: async (input) => { received = input; return { - items: [{ candidate_id: "signal-1", review_status: "review_required" }], + items: [correctionSignal], next_cursor: null, }; }, @@ -342,7 +502,7 @@ describe("correction-study routes", () => { expect(received).toEqual({ limit: 12, cursor: "cursor-1" }); if (!response) throw new Error("Expected signal discovery response."); expect(await response.json()).toEqual({ - items: [{ candidate_id: "signal-1", review_status: "review_required" }], + items: [correctionSignal], next_cursor: null, }); }); @@ -350,8 +510,8 @@ describe("correction-study routes", () => { test("rejects cross-origin and unbounded signal discovery queries", async () => { let discovered = false; const routes = createCorrectionStudyRoutes({ - captureExplicitCorrection: async () => ({}), - lookup: async () => ({}), + captureExplicitCorrection: async () => studyResponse, + lookup: async () => studyResponse, discoverSignals: async () => { discovered = true; return { items: [], next_cursor: null }; diff --git a/apps/local/tests/correction-study-service.test.ts b/apps/local/tests/correction-study-service.test.ts index 867738ab..ef2c8b36 100644 --- a/apps/local/tests/correction-study-service.test.ts +++ b/apps/local/tests/correction-study-service.test.ts @@ -3,9 +3,12 @@ import { createHash } from "node:crypto"; import { getCorrectionStudy, openDb } from "@selftune/local-store"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { CorrectionStudyServiceFailure, + ExplicitCorrectionStudyRequest, + ManagedCorrectionStudyRequest, captureManagedCorrectionStudy, captureExplicitCorrectionStudy, lookupCorrectionStudy, @@ -29,7 +32,7 @@ afterEach(() => { for (const sqlite of databases.splice(0)) sqlite.close(); }); -function requestPayload() { +function requestPayload(): typeof ExplicitCorrectionStudyRequest.Encoded { return { episode: { skill_id: "release-checklist", @@ -65,7 +68,7 @@ function requestPayload() { }; } -function managedRequest() { +function managedRequest(): typeof ManagedCorrectionStudyRequest.Encoded { const base = requestPayload(); const taskPayload = "Confirm the release portal shows the uploaded asset."; return { @@ -89,14 +92,16 @@ function managedRequest() { success_contract: "Require portal status before declaring upload success.", check_description: "Checks the claimed portal state.", }, - evidence: ["known_failure", "known_good", "boundary", "adversarial"].map((label) => ({ - evidence_id: `control-${label}`, - label, - expected_decision: label === "known_failure" ? "reject" : "accept", - observed_decision: label === "known_failure" ? "reject" : "accept", - partition: "verifier_calibration", - candidate_strategy_reference: null, - })), + evidence: (["known_failure", "known_good", "boundary", "adversarial"] as const).map( + (label) => ({ + evidence_id: `control-${label}`, + label, + expected_decision: label === "known_failure" ? "reject" : "accept", + observed_decision: label === "known_failure" ? "reject" : "accept", + partition: "verifier_calibration", + candidate_strategy_reference: null, + }), + ), }), required_scored_repetitions: 3, max_attempts_per_arm: 3, @@ -123,13 +128,13 @@ function managedExecutor(outcome: "success" | "infra" | "cancelled" | "mismatch" }; } -function routeServiceError(error: unknown): CorrectionStudyServiceError { - if (error instanceof CorrectionStudyServiceFailure) { - return new CorrectionStudyServiceError(error.code, error.message, error.status); +function routeServiceError(cause: unknown): CorrectionStudyServiceError { + if (cause instanceof CorrectionStudyServiceFailure) { + return new CorrectionStudyServiceError(cause.code, cause.message, cause.status); } return new CorrectionStudyServiceError( "CORRECTION_STUDY_PERSISTENCE_FAILED", - error instanceof Error ? error.message : "Correction study operation failed.", + cause instanceof Error ? cause.message : "Correction study operation failed.", 503, ); } @@ -139,12 +144,12 @@ describe("explicit correction study service", () => { const sqlite = database(); const routes = createCorrectionStudyRoutes({ captureExplicitCorrection: (input) => - Effect.runPromise(captureExplicitCorrectionStudy(sqlite, input)).catch((error: unknown) => { - throw routeServiceError(error); + Effect.runPromise(captureExplicitCorrectionStudy(sqlite, input)).catch((cause: unknown) => { + throw routeServiceError(cause); }), lookup: (episodeId) => - Effect.runPromise(lookupCorrectionStudy(sqlite, episodeId)).catch((error: unknown) => { - throw routeServiceError(error); + Effect.runPromise(lookupCorrectionStudy(sqlite, episodeId)).catch((cause: unknown) => { + throw routeServiceError(cause); }), }); const payload = requestPayload(); @@ -175,33 +180,28 @@ describe("explicit correction study service", () => { }, applies_change: false, }); - if ( - typeof captured !== "object" || - captured === null || - !("episode_id" in captured) || - typeof captured.episode_id !== "string" - ) { - throw new Error("Expected a correction episode id."); - } + const { episode_id } = Schema.decodeUnknownSync(Schema.Struct({ episode_id: Schema.String }))( + captured, + ); const lookup = await routes.handle( - new Request(`${origin}/api/v2/correction-studies/${captured.episode_id}`, { + new Request(`${origin}/api/v2/correction-studies/${episode_id}`, { headers: { origin }, }), - new URL(`${origin}/api/v2/correction-studies/${captured.episode_id}`), + new URL(`${origin}/api/v2/correction-studies/${episode_id}`), new Set([origin]), ); expect(lookup?.status).toBe(200); if (!lookup) throw new Error("Expected correction lookup response."); expect(await lookup.json()).toMatchObject({ - episode_id: captured.episode_id, + episode_id, evidence_level: "E1", status: "promoted", regression_case: { status: "active" }, applies_change: false, }); - const persisted = Effect.runSync(getCorrectionStudy(sqlite, captured.episode_id)); + const persisted = Effect.runSync(getCorrectionStudy(sqlite, episode_id)); expect(persisted?.evidence_entries).toHaveLength(1); expect(persisted?.promoted_case?.status).toBe("active"); expect(persisted?.episode.trace_payload_json).toContain("[redacted]"); diff --git a/apps/local/tests/dashboard-http.test.ts b/apps/local/tests/dashboard-http.test.ts new file mode 100644 index 00000000..7ef59529 --- /dev/null +++ b/apps/local/tests/dashboard-http.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { CatalogSkillResolutionProgress } from "@selftune/runtime/skill-sets/catalog-resolution"; +import { DashboardOperationError } from "../src/dashboard-operation-errors.js"; +import { + dashboardOperationErrorResponse, + sameOriginFailure, + withDashboardCors, +} from "../src/dashboard-http.js"; + +const failure = { + operation: "private operation", + code: "CATALOG_SKILL_RESOLUTION_FAILED", + message: "Cannot download skill", + status: 422, + retryable: true, +}; + +test.each([undefined, ""])( + "omits absent diagnostic fields and suggestion %j", + async (suggestion) => { + const response = dashboardOperationErrorResponse( + DashboardOperationError.make({ ...failure, suggestion }), + ); + expect(response.status).toBe(422); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(await response.json()).toEqual({ + error: { code: failure.code, message: failure.message, retryable: true }, + }); + }, +); + +test("retains explicitly empty diagnostic collections", async () => { + const response = dashboardOperationErrorResponse( + DashboardOperationError.make({ ...failure, failures: [], progress: [] }), + ); + expect(await response.json()).toEqual({ + error: { + code: failure.code, + message: failure.message, + retryable: true, + failures: [], + progress: [], + }, + }); +}); + +test("returns complete public failure progress without the private operation or stack", async () => { + const detail = { + skill_name: "research", + catalog_id: "catalog-research", + phase: "downloading", + code: "UNAVAILABLE", + message: "Try later", + retryable: true, + } as const; + const progress = CatalogSkillResolutionProgress.make({ + skill_name: "research", + catalog_id: "catalog-research", + phase: "downloading", + message: "Retry download", + }); + const response = dashboardOperationErrorResponse( + DashboardOperationError.make({ + ...failure, + suggestion: "Retry the skill", + failures: [detail], + progress: [progress], + }), + ); + expect(await response.json()).toEqual({ + error: { + code: failure.code, + message: failure.message, + retryable: true, + suggestion: "Retry the skill", + failures: [detail], + progress: [{ ...progress }], + }, + }); +}); + +test("adds CORS without losing response status, headers, or body", async () => { + const response = withDashboardCors( + new Response("conflict", { + status: 409, + statusText: "Conflict", + headers: { "X-Test": "retained" }, + }), + ); + expect(response.status).toBe(409); + expect(response.statusText).toBe("Conflict"); + expect(response.headers.get("X-Test")).toBe("retained"); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Authorization, Content-Type"); + expect(await response.text()).toBe("conflict"); +}); + +test("requires an explicitly allowed request origin", () => { + const allowed = new Set(["http://localhost:3141"]); + expect( + sameOriginFailure( + new Request("http://localhost:3141/api", { headers: { Origin: "http://localhost:3141" } }), + allowed, + ), + ).toBeNull(); + expect(sameOriginFailure(new Request("http://localhost:3141/api"), allowed)?.status).toBe(403); + expect( + sameOriginFailure( + new Request("http://localhost:3141/api", { headers: { Origin: "https://other.invalid" } }), + allowed, + )?.status, + ).toBe(403); +}); diff --git a/apps/local/tests/dashboard-operations.test.ts b/apps/local/tests/dashboard-operations.test.ts index c090aed4..ca0188bf 100644 --- a/apps/local/tests/dashboard-operations.test.ts +++ b/apps/local/tests/dashboard-operations.test.ts @@ -31,6 +31,10 @@ const library: LibrarySnapshot = { skillId: "research", name: "research", lifecycle: "active", + lastUsedAt: null, + lastModifiedAt: "2026-07-15T08:00:00.000Z", + origins: [], + updateStatus: "untracked", revisions: [], locations: [ { @@ -42,6 +46,9 @@ const library: LibrarySnapshot = { projectRoot: null, active: true, modifiedAt: "2026-07-15T08:00:00.000Z", + lastUsedAt: null, + origin: null, + updateStatus: "untracked", }, ], }, @@ -376,7 +383,7 @@ test("license routes preview and revise a Set with no installed Library location skill_id: "marketing-social", terms: { copyright_holder: "Daniel Petro", licensed_organization: "Ithraa Center", year: 2026 }, }; - async function request(action: string, input: unknown, status = 200) { + async function request(action: string, input: Schema.Json, status = 200) { const req = new Request(`${origin}/api/v2/library/license/${action}`, { method: "POST", headers: { Origin: origin, "Content-Type": "application/json" }, diff --git a/apps/local/tests/evaluation-draft-submissions-route.test.ts b/apps/local/tests/evaluation-draft-submissions-route.test.ts deleted file mode 100644 index 39efde71..00000000 --- a/apps/local/tests/evaluation-draft-submissions-route.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "bun:test"; - -import { createEvaluationDraftSubmissionRoutes } from "../src/routes/evaluation-draft-submissions.js"; - -const origin = "http://127.0.0.1:3141"; -const target = { - source_id: "source-1", - snapshot_id: "snapshot-1", - skill_id: "skill-1", - suite_id: "suite-1", - manifest_digest: `sha256:${"a".repeat(64)}`, -}; - -describe("evaluation draft submission routes", () => { - it("requires same origin and passes only an exact selected target", async () => { - let submitted = 0; - const routes = createEvaluationDraftSubmissionRoutes({ - discover: async () => ({ - draft_id: "draft-1", - lifecycle: "prepared", - run_id: null, - targets: [], - blockers: [], - }), - submit: async (draftId, selection) => { - submitted++; - expect(draftId).toBe("draft-1"); - expect(selection).toEqual(target); - return { run_id: "run-1", status: "scheduled", dispatch: "scheduled" }; - }, - }); - const denied = await routes.handle( - new Request(`${origin}/api/v2/trace-candidates/draft-1/submit`, { - method: "POST", - headers: { origin: "https://evil.example", "content-type": "application/json" }, - body: JSON.stringify(target), - }), - new URL(`${origin}/api/v2/trace-candidates/draft-1/submit`), - new Set([origin]), - ); - expect(denied?.status).toBe(403); - const accepted = await routes.handle( - new Request(`${origin}/api/v2/trace-candidates/draft-1/submit`, { - method: "POST", - headers: { origin, "content-type": "application/json" }, - body: JSON.stringify(target), - }), - new URL(`${origin}/api/v2/trace-candidates/draft-1/submit`), - new Set([origin]), - ); - expect(accepted?.status).toBe(202); - expect(submitted).toBe(1); - }); - - it("bounds selected target payload before the submission callback", async () => { - let submitted = false; - const routes = createEvaluationDraftSubmissionRoutes({ - discover: async () => ({ - draft_id: "draft-1", - lifecycle: "prepared", - run_id: null, - targets: [], - blockers: [], - }), - submit: async () => { - submitted = true; - return { run_id: "run-1", status: "scheduled", dispatch: "scheduled" }; - }, - }); - const response = await routes.handle( - new Request(`${origin}/api/v2/trace-candidates/draft-1/submit`, { - method: "POST", - headers: { origin, "content-type": "application/json" }, - body: JSON.stringify({ value: "x".repeat(9_000) }), - }), - new URL(`${origin}/api/v2/trace-candidates/draft-1/submit`), - new Set([origin]), - ); - expect(response?.status).toBe(413); - expect(submitted).toBeFalse(); - }); - - it("rejects malformed encoded draft ids before discovery", async () => { - let discovered = false; - const routes = createEvaluationDraftSubmissionRoutes({ - discover: async () => { - discovered = true; - return { - draft_id: "draft-1", - lifecycle: "prepared", - run_id: null, - targets: [], - blockers: [], - }; - }, - submit: async () => ({ run_id: "run-1", status: "scheduled", dispatch: "scheduled" }), - }); - const response = await routes.handle( - new Request(`${origin}/api/v2/trace-candidates/%E0%A4%A/targets`, { headers: { origin } }), - new URL(`${origin}/api/v2/trace-candidates/%E0%A4%A/targets`), - new Set([origin]), - ); - expect(response?.status).toBe(400); - expect(discovered).toBeFalse(); - }); -}); diff --git a/apps/local/tests/fixtures/report-worker.ts b/apps/local/tests/fixtures/report-worker.ts new file mode 100644 index 00000000..df22eb09 --- /dev/null +++ b/apps/local/tests/fixtures/report-worker.ts @@ -0,0 +1,28 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { buildPortfolioAudit } from "@selftune/runtime/skill-portfolio"; + +const [, , mode, , , outputPath] = process.argv; +if (!outputPath) throw new Error("Expected report output path"); +mkdirSync(dirname(outputPath), { recursive: true }); +switch (mode) { + case "exit": + process.stderr.write("x".repeat(2048)); + process.exit(7); + break; + case "timeout": + writeFileSync(outputPath, "unfinished"); + await Bun.sleep(15_000); + break; + case "invalid-json": + writeFileSync(outputPath, "{"); + break; + case "invalid-report": + writeFileSync(outputPath, JSON.stringify({ installed_count: 99 })); + break; + case "valid": + writeFileSync(outputPath, JSON.stringify(buildPortfolioAudit([], [], []))); + break; + default: + throw new Error("Unknown worker fixture mode"); +} diff --git a/apps/local/tests/harness-registry.test.ts b/apps/local/tests/harness-registry.test.ts index c27d498c..0741abbd 100644 --- a/apps/local/tests/harness-registry.test.ts +++ b/apps/local/tests/harness-registry.test.ts @@ -62,7 +62,8 @@ describe("local harness registry", () => { import_available: false, source_merge: null, }); - expect(cline?.icon.src).toStartWith("data:image/svg+xml,"); + expect(cline?.icon).toEqual(localHarnessRegistry.get("cline")?.presentation.icon); + expect(cline?.icon.src).toStartWith("data:image/"); expect(JSON.stringify(cline)).not.toContain(homeDir); } finally { rmSync(homeDir, { recursive: true, force: true }); diff --git a/apps/local/tests/helpers/team-distribution-tracer-child.ts b/apps/local/tests/helpers/team-distribution-tracer-child.ts index 16e0974a..bf45f902 100644 --- a/apps/local/tests/helpers/team-distribution-tracer-child.ts +++ b/apps/local/tests/helpers/team-distribution-tracer-child.ts @@ -1,9 +1,10 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; +import * as Schema from "effect/Schema"; -import type { +import { HostedSkillSetAssignment, - HostedSkillSetInstallationReceiptRequest, + type HostedSkillSetInstallationReceiptRequest, } from "@selftune/control-plane"; import { openDb } from "@selftune/local-store"; import { @@ -16,15 +17,17 @@ import { makeTeamSkillSetAssignmentRuntime, } from "@selftune/runtime/team-assignment"; -interface TracerInput { - readonly mode: "install" | "rollback"; - readonly root: string; - readonly assignment: HostedSkillSetAssignment; - readonly packageBase64: string; - readonly receiptId?: string; -} +const TracerInput = Schema.Struct({ + mode: Schema.Literals(["install", "rollback"]), + root: Schema.String, + assignment: HostedSkillSetAssignment, + packageBase64: Schema.String, + receiptId: Schema.optionalKey(Schema.String), +}); -const input = JSON.parse(await Bun.file(process.argv[2]!).text()) as TracerInput; +const input = Schema.decodeUnknownSync(Schema.fromJsonString(TracerInput))( + await Bun.file(process.argv[2]!).text(), +); const projectRoot = join(input.root, "project"); const configRoot = join(input.root, "config"); const homeRoot = join(input.root, "home"); diff --git a/apps/local/tests/historical-skill-improvement-service.test.ts b/apps/local/tests/historical-skill-improvement-service.test.ts index 2bc8aa5e..0b0eebe3 100644 --- a/apps/local/tests/historical-skill-improvement-service.test.ts +++ b/apps/local/tests/historical-skill-improvement-service.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { createOrGetPreparedEvaluationSubmissionDraft, + createOrGetCorrectionStudy, listLatestCorrectionCandidateEvaluations, openDb, } from "@selftune/local-store"; @@ -37,208 +38,351 @@ function verifier() { success_contract: "The task-specific deterministic check passes.", check_description: "Runs the frozen task check.", }, - evidence: ["known_failure", "known_good", "boundary", "adversarial"].map((label) => ({ - evidence_id: `control-${label}`, - label: label as "known_failure" | "known_good" | "boundary" | "adversarial", - expected_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), - observed_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), - partition: "verifier_calibration" as const, - candidate_strategy_reference: null, - })), + evidence: (["known_failure", "known_good", "boundary", "adversarial"] as const).map( + (label) => ({ + evidence_id: `control-${label}`, + label, + expected_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), + observed_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), + partition: "verifier_calibration" as const, + candidate_strategy_reference: null, + }), + ), }); } describe("historical skill improvement", () => { - test("composes a prepared trace cohort into a persisted E2 review without applying it", async () => { - const database = openDb(":memory:"); - const root = mkdtempSync(join(tmpdir(), "selftune-historical-improvement-")); - directories.push(root); - const skillDirectory = join(root, "skills", "release-checklist"); - mkdirSync(skillDirectory, { recursive: true }); - const skillPath = join(skillDirectory, "SKILL.md"); - const installedContent = - "---\nname: release-checklist\ndescription: Verify releases.\n---\n\n# Release checklist\n\nClaim success after selecting an asset.\n"; - const proposedBody = "Claim success only after the portal confirms the upload."; - writeFileSync(skillPath, installedContent); - const installedRevision = computeSkillVersionHash(skillPath); - if (!installedRevision) throw new Error("fixture revision missing"); + test.each([ + "none", + "task-case", + "legacy-episode", + "malformed-manifest", + "malformed-verifier", + "malformed-oversized-task", + "malformed-empty-task", + "malformed-task-shape", + "wrong-verifier", + ])( + "composes a review-only historical evaluation with %s regression evidence", + async (regression) => { + const database = openDb(":memory:"); + const root = mkdtempSync(join(tmpdir(), "selftune-historical-improvement-")); + directories.push(root); + const skillDirectory = join(root, "skills", "release-checklist"); + mkdirSync(skillDirectory, { recursive: true }); + const skillPath = join(skillDirectory, "SKILL.md"); + const installedContent = + "---\nname: release-checklist\ndescription: Verify releases.\n---\n\n# Release checklist\n\nClaim success after selecting an asset.\n"; + const proposedBody = "Claim success only after the portal confirms the upload."; + writeFileSync(skillPath, installedContent); + const installedRevision = computeSkillVersionHash(skillPath); + if (!installedRevision) throw new Error("fixture revision missing"); - const roles = [ - "calibration_failure", - "calibration_success", - "heldout_failure", - "heldout_success", - ] as const; - const entries = roles.map((role, index) => ({ - role, - source: { - source_id: "codex", - source_revision: `source-${index}`, - trace_id: `${index + 1}`.repeat(32), - span_id: `${index + 1}`.repeat(16), - skill_invocation_id: `invocation-${index}`, - }, - duration_ms: 10, - input_tokens: 10, - output_tokens: 10, - error_count: role.includes("failure") ? 1 : 0, - tool_call_count: 1, - })); - const queries = [ - "calibration failure task", - "calibration success task", - "selection failure task", - "audit success task", - ]; - const payload = { - schema_version: 1 as const, - cohort: { - schema_version: "1.0.0" as const, - selector_version: "test/v1", - pattern: { - pattern_id: "execution-pattern-test", - kind: "repeated_correlated_errors" as const, - skill_id: "release-checklist", - skill_name: "release-checklist", + const roles = [ + "calibration_failure", + "calibration_success", + "heldout_failure", + "heldout_success", + ] as const; + const entries = roles.map((role, index) => ({ + role, + source: { + source_id: "codex", + source_revision: `source-${index}`, + trace_id: `${index + 1}`.repeat(32), + span_id: `${index + 1}`.repeat(16), + skill_invocation_id: `invocation-${index}`, }, - target_skill: { - skill_id: "release-checklist", - skill_name: "release-checklist", - revision: installedRevision, + duration_ms: 10, + input_tokens: 10, + output_tokens: 10, + error_count: role.includes("failure") ? 1 : 0, + tool_call_count: 1, + })); + const queries = [ + "calibration failure task", + "calibration success task", + "selection failure task", + "audit success task", + ]; + const payload = { + schema_version: 1 as const, + cohort: { + schema_version: "1.0.0" as const, + selector_version: "test/v1", + pattern: { + pattern_id: "execution-pattern-test", + kind: "repeated_correlated_errors" as const, + skill_id: "release-checklist", + skill_name: "release-checklist", + }, + target_skill: { + skill_id: "release-checklist", + skill_name: "release-checklist", + revision: installedRevision, + }, + excerpt_limit_bytes: 512, + request_limit_bytes: 8_192, + entries, + fingerprint: fingerprint("cohort"), }, - excerpt_limit_bytes: 512, - request_limit_bytes: 8_192, - entries, - fingerprint: fingerprint("cohort"), - }, - candidate: { - proposal_id: "proposal-1", - target_revision: installedRevision, - proposed_body: proposedBody, - rationale: "Require actual portal confirmation.", - }, - resolved_evidence: entries.map((entry, index) => ({ - ...entry.source, - skill_revision: installedRevision, - query: queries[index], - should_trigger: true, - })), - }; - const draftId = "eval-draft-test"; - await Effect.runPromise( - createOrGetPreparedEvaluationSubmissionDraft(database, { - draft_id: draftId, - pattern_id: payload.cohort.pattern.pattern_id, - cohort_fingerprint: payload.cohort.fingerprint, - skill_name: "release-checklist", - skill_revision: installedRevision, - payload_json: JSON.stringify(payload), - prepared_at: "2026-08-06T10:00:00.000Z", - }), - ); + candidate: { + proposal_id: "proposal-1", + target_revision: installedRevision, + proposed_body: proposedBody, + rationale: "Require actual portal confirmation.", + }, + resolved_evidence: entries.map((entry, index) => ({ + ...entry.source, + skill_revision: installedRevision, + query: queries[index], + should_trigger: true, + })), + }; + const draftId = "eval-draft-test"; + await Effect.runPromise( + createOrGetPreparedEvaluationSubmissionDraft(database, { + draft_id: draftId, + pattern_id: payload.cohort.pattern.pattern_id, + cohort_fingerprint: payload.cohort.fingerprint, + skill_name: "release-checklist", + skill_revision: installedRevision, + payload_json: JSON.stringify(payload), + prepared_at: "2026-08-06T10:00:00.000Z", + }), + ); - const preparation = Layer.succeed( - TraceCandidatePreparation, - TraceCandidatePreparation.of({ - prepare: () => - Effect.succeed({ - draft_id: draftId, - pattern_id: payload.cohort.pattern.pattern_id, - cohort_fingerprint: payload.cohort.fingerprint, - target_revision: installedRevision, - readiness: "review_ready" as const, - failure_reason: null, - evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, - candidate: { - body: proposedBody, - rationale: payload.candidate.rationale, - diff: { changed_lines: 1, target_section: "body" }, - uncertainty: [], + if (regression !== "none") { + const manifest = JSON.stringify( + regression === "legacy-episode" + ? { episode: { task: "Confirm the portal reports an uploaded asset." } } + : { + task_case: { + task_payload: "Confirm the portal reports an uploaded asset.", + task_fingerprint: fingerprint("Confirm the portal reports an uploaded asset."), + }, + }, + ); + const verifierPayload = JSON.stringify({ instrument: verifier().instrument }); + const owner = `skill-${hash("release-checklist").slice(0, 32)}`; + const timestamp = "2026-08-06T10:00:00.000Z"; + const shared = { + skill_id: owner, + skill_name: "release-checklist", + pre_revision: "a".repeat(64), + post_revision: "b".repeat(64), + manifest_json: manifest, + evidence_level: "E1", + reason: null, + }; + await Effect.runPromise( + createOrGetCorrectionStudy(database, { + episode: { + ...shared, + episode_id: "regression-episode", + capture_key: "regression-capture", + skill_path: skillPath, + harness: "codex", + source_session_id: "regression-session", + correction_intent_json: "{}", + trace_payload_json: "{}", + status: "promoted", + captured_at: timestamp, + created_at: timestamp, + updated_at: timestamp, + }, + evidence: { + skill_id: owner, + episode_id: "regression-episode", + evidence_id: "regression-evidence", + evidence_key: "regression-key", + evidence_level: "E1", + status: "qualified", + reason: null, + manifest_json: manifest, + verifier_payload_json: verifierPayload, + trial_payload_json: "{}", + recorded_at: timestamp, + }, + promoted_case: { + ...shared, + case_id: "regression-case", + episode_id: "regression-episode", + evidence_id: "regression-evidence", + verifier_payload_json: verifierPayload, + trial_payload_json: "{}", + status: "active", + promoted_at: timestamp, + created_at: timestamp, }, }), - }), - ); - const executor = { - execute: (request: { - readonly arm: "no_skill" | "current_skill" | "candidate_skill"; - readonly revision: string | null; - }) => - Effect.succeed({ - kind: "scored" as const, - passed: request.arm === "candidate_skill", - executed_revision: request.revision, + ); + if (regression === "malformed-manifest") + database.run("UPDATE promoted_study_cases SET manifest_json = ? WHERE case_id = ?", [ + "{", + "regression-case", + ]); + if (regression === "malformed-verifier") + database.run( + "UPDATE promoted_study_cases SET verifier_payload_json = ? WHERE case_id = ?", + ["[]", "regression-case"], + ); + if (regression === "malformed-oversized-task") + database.run("UPDATE promoted_study_cases SET manifest_json = ? WHERE case_id = ?", [ + JSON.stringify({ task_case: { task_payload: "x".repeat(8_001) } }), + "regression-case", + ]); + if (regression === "malformed-empty-task") + database.run("UPDATE promoted_study_cases SET manifest_json = ? WHERE case_id = ?", [ + JSON.stringify({ + task_case: { task_payload: "" }, + episode: { task: "Do not fall back from an explicit empty task." }, + }), + "regression-case", + ]); + if (regression === "malformed-task-shape") + database.run("UPDATE promoted_study_cases SET manifest_json = ? WHERE case_id = ?", [ + JSON.stringify({ task_case: { task_payload: 42 } }), + "regression-case", + ]); + if (regression === "wrong-verifier") + database.run( + "UPDATE promoted_study_cases SET verifier_payload_json = ? WHERE case_id = ?", + [ + JSON.stringify({ instrument: { ...verifier().instrument, version: "different" } }), + "regression-case", + ], + ); + } + + const preparation = Layer.succeed( + TraceCandidatePreparation, + TraceCandidatePreparation.of({ + prepare: () => + Effect.succeed({ + draft_id: draftId, + pattern_id: payload.cohort.pattern.pattern_id, + cohort_fingerprint: payload.cohort.fingerprint, + target_revision: installedRevision, + readiness: "review_ready" as const, + failure_reason: null, + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: { + body: proposedBody, + rationale: payload.candidate.rationale, + diff: { changed_lines: 1, target_section: "body" }, + uncertainty: [], + }, + }), }), - }; - let replayContext: - | { - readonly skillName: string; - readonly currentRevision: string; - readonly candidateRevision: string; - } - | undefined; - const layer = Layer.provide( - makeHistoricalSkillImprovementLayer({ - sqlite: database, - executorFactory: { - create: (context) => { - replayContext = context; - return Effect.succeed(executor); - }, - }, - searchDirs: [join(root, "skills")], - }), - preparation, - ); - const result = await Effect.runPromise( - Effect.gen(function* () { - const improvement = yield* HistoricalSkillImprovement; - return yield* improvement.evaluate({ - pattern_id: payload.cohort.pattern.pattern_id, - qualified_verifier: verifier(), - runtime: { - harness: "codex", - model: "gpt-5", - config_digest: fingerprint("runtime"), + ); + const executor = { + execute: (request: { + readonly arm: "no_skill" | "current_skill" | "candidate_skill"; + readonly revision: string | null; + }) => + Effect.succeed({ + kind: "scored" as const, + passed: request.arm === "candidate_skill", + executed_revision: request.revision, + }), + }; + let replayContext: + | { + readonly skillName: string; + readonly currentRevision: string; + readonly candidateRevision: string; + } + | undefined; + const layer = Layer.provide( + makeHistoricalSkillImprovementLayer({ + sqlite: database, + executorFactory: { + create: (context) => { + replayContext = context; + return Effect.succeed(executor); + }, }, - required_scored_repetitions: 3, - max_attempts_per_arm: 3, - controls: { - entitlement_proactive_managed: true, - proactive_generation_enabled: true, - managed_execution_enabled: true, - kill_switch_enabled: false, - active_runs: 0, - max_concurrency: 1, - budget_remaining_usd: 1, - estimated_cost_usd: 0.1, + searchDirs: [join(root, "skills")], + }), + preparation, + ); + const outcome = await Effect.runPromise( + Effect.gen(function* () { + const improvement = yield* HistoricalSkillImprovement; + return yield* improvement.evaluate({ + pattern_id: payload.cohort.pattern.pattern_id, + qualified_verifier: verifier(), + runtime: { + harness: "codex", + model: "gpt-5", + config_digest: fingerprint("runtime"), + }, + required_scored_repetitions: 3, + max_attempts_per_arm: 3, + controls: { + entitlement_proactive_managed: true, + proactive_generation_enabled: true, + managed_execution_enabled: true, + kill_switch_enabled: false, + active_runs: 0, + max_concurrency: 1, + budget_remaining_usd: 1, + estimated_cost_usd: 0.1, + }, + recorded_at: "2026-08-06T10:01:00.000Z", + }); + }).pipe(Effect.provide(layer), Effect.result), + ); + + if (regression.startsWith("malformed-") || regression === "wrong-verifier") { + expect(outcome).toMatchObject({ + _tag: "Failure", + failure: { + code: + regression === "wrong-verifier" + ? "INCOMPATIBLE_REGRESSION_VERIFIER" + : "INVALID_EVIDENCE", }, - recorded_at: "2026-08-06T10:01:00.000Z", }); - }).pipe(Effect.provide(layer)), - ); + expect(replayContext).toBeUndefined(); + expect(listLatestCorrectionCandidateEvaluations(database, draftId)).toHaveLength(0); + expect(await Bun.file(skillPath).text()).toBe(installedContent); + database.close(); + return; + } + if (outcome._tag === "Failure") throw outcome.failure; + const result = outcome.success; - expect(result).toMatchObject({ - draft_id: draftId, - candidate_id: draftId, - status: "review_ready", - evidence_level: "E2", - reason: "selected", - cases: { calibration: 2, selection: 1, audit_holdout: 1, active_regressions: 0 }, - applies_change: false, - }); - expect(replayContext).toMatchObject({ - skillName: "release-checklist", - currentRevision: installedRevision, - }); - expect(replayContext?.candidateRevision).not.toBe(installedRevision); - const evaluations = listLatestCorrectionCandidateEvaluations(database, draftId); - expect(evaluations).toHaveLength(1); - const candidateRevision = String(evaluations[0]?.candidate_revision); - writeFileSync(skillPath, replaceBody(installedContent, proposedBody)); - expect(computeSkillVersionHash(skillPath)).toBe(candidateRevision); - expect(await Bun.file(skillPath).text()).toContain("portal confirms the upload"); - database.close(); - }); + expect(result).toMatchObject({ + draft_id: draftId, + candidate_id: draftId, + status: "review_ready", + evidence_level: "E2", + reason: "selected", + cases: { + calibration: 2, + selection: regression === "none" ? 1 : 2, + audit_holdout: 1, + active_regressions: regression === "none" ? 0 : 1, + }, + applies_change: false, + }); + expect(replayContext).toMatchObject({ + skillName: "release-checklist", + currentRevision: installedRevision, + }); + expect(replayContext?.candidateRevision).not.toBe(installedRevision); + expect(await Bun.file(skillPath).text()).toBe(installedContent); + const evaluations = listLatestCorrectionCandidateEvaluations(database, draftId); + expect(evaluations).toHaveLength(1); + const candidateRevision = String(evaluations[0]?.candidate_revision); + writeFileSync(skillPath, replaceBody(installedContent, proposedBody)); + expect(computeSkillVersionHash(skillPath)).toBe(candidateRevision); + expect(await Bun.file(skillPath).text()).toContain("portal confirms the upload"); + database.close(); + }, + ); test("evaluates a neutral historical-task draft and returns a concrete before/after", async () => { const database = openDb(":memory:"); diff --git a/apps/local/tests/historical-skill-replay-executor.test.ts b/apps/local/tests/historical-skill-replay-executor.test.ts index 49849e75..8a927502 100644 --- a/apps/local/tests/historical-skill-replay-executor.test.ts +++ b/apps/local/tests/historical-skill-replay-executor.test.ts @@ -270,14 +270,16 @@ describe("historical host replay executor", () => { success_contract: "An unrelated check passes.", check_description: "Checks something else.", }, - evidence: ["known_failure", "known_good", "boundary", "adversarial"].map((label) => ({ - evidence_id: `unrelated-${label}`, - label: label as "known_failure" | "known_good" | "boundary" | "adversarial", - expected_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), - observed_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), - partition: "verifier_calibration" as const, - candidate_strategy_reference: null, - })), + evidence: (["known_failure", "known_good", "boundary", "adversarial"] as const).map( + (label) => ({ + evidence_id: `unrelated-${label}`, + label, + expected_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), + observed_decision: label === "known_failure" ? ("reject" as const) : ("accept" as const), + partition: "verifier_calibration" as const, + candidate_strategy_reference: null, + }), + ), }); const result = await Effect.runPromiseExit( makeHostHistoricalSkillReplayExecutorFactory({ diff --git a/apps/local/tests/historical-task-candidate.test.ts b/apps/local/tests/historical-task-candidate.test.ts index 1f3c5933..bca39f16 100644 --- a/apps/local/tests/historical-task-candidate.test.ts +++ b/apps/local/tests/historical-task-candidate.test.ts @@ -245,7 +245,6 @@ describe("historical task-quality candidate", () => { }).pipe(Effect.provide(losingLayer)), ); const noWinnerDraftId = noWinner.draft_id; - expect(typeof noWinnerDraftId).toBe("string"); expect(noWinner).toMatchObject({ readiness: "not_ready", draft_id: expect.any(String), diff --git a/apps/local/tests/hooks-route.test.ts b/apps/local/tests/hooks-route.test.ts index 689618e1..41462977 100644 --- a/apps/local/tests/hooks-route.test.ts +++ b/apps/local/tests/hooks-route.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { Schema } from "effect"; import { startDashboardServer, type DashboardServerOptions } from "../src/dashboard-server.js"; -import type { HookRunner } from "../src/routes/hooks.js"; +import { createHookRoutes, type HookRunner } from "../src/routes/hooks.js"; const AUTH_TOKEN = "PLACEHOLDER_HOOK_ROUTE_TOKEN"; const servers: Array>> = []; @@ -65,7 +66,9 @@ describe("POST /api/hooks/:name", () => { const finished: string[] = []; const releases = new Map void>(); const runner: HookRunner = async (rawStdin) => { - const payload = JSON.parse(rawStdin) as { event: string }; + const payload = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ event: Schema.String })), + )(rawStdin); started.push(payload.event); await new Promise((resolve) => releases.set(payload.event, resolve)); finished.push(payload.event); @@ -103,6 +106,39 @@ describe("POST /api/hooks/:name", () => { expect(response.status).toBe(401); }); + test("serializes malformed session identities in the shared fail-open queue", async () => { + const bodies = ["not-json", "[]", "null", '{"session_id":42}', '{"session_id":""}']; + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const started: string[] = []; + const routes = createHookRoutes({ + runners: { + "prompt-log": async (body) => { + started.push(body); + if (body === bodies[0]) { + firstStarted.resolve(); + await releaseFirst.promise; + } + return { exit_code: 0, stdout: "", stderr: "" }; + }, + }, + }); + const url = new URL("http://localhost/api/hooks/prompt-log"); + try { + for (const body of bodies) { + expect((await routes.handle(new Request(url, { method: "POST", body }), url))?.status).toBe( + 202, + ); + } + await firstStarted.promise; + expect(started).toEqual([bodies[0]]); + } finally { + releaseFirst.resolve(); + await routes.waitForIdle(); + } + expect(started).toEqual(bodies); + }); + test("returns 404 for an unknown hook", async () => { const baseUrl = await startWithRunners({}); const response = await postHook(baseUrl, "not-a-hook", "{}"); diff --git a/apps/local/tests/hosted-skill-set-assignments.test.ts b/apps/local/tests/hosted-skill-set-assignments.test.ts index f3ce2cfb..e93b7023 100644 --- a/apps/local/tests/hosted-skill-set-assignments.test.ts +++ b/apps/local/tests/hosted-skill-set-assignments.test.ts @@ -3,12 +3,22 @@ import { describe, expect, test } from "bun:test"; import type { HostedSkillSetAssignment, HostedSkillSetInstallationReceiptRequest, + LibrarySnapshot, } from "@selftune/control-plane"; import { makeHostedStateOperations } from "../src/hosted-state.js"; +import { defaultSyncPreferences } from "@selftune/control-plane"; +import type { RemoteLibraryConfig } from "@selftune/library/remote/config"; const revision = "1".repeat(64); const envelope = "2".repeat(64); +const connection: RemoteLibraryConfig = { + version: 2, + url: "https://cloud.selftune.dev", + apiKey: "device_token", + credentialProvider: "file", + preferences: defaultSyncPreferences, +}; const assignment: HostedSkillSetAssignment = { assignment_id: "assignment_01", @@ -37,11 +47,11 @@ const assignment: HostedSkillSetAssignment = { }, }; -const library = { +const library: LibrarySnapshot = { skills: [], - skillSets: [], + counts: { total: 0, active: 0, library: 0, draft: 0, archived: 0 }, generatedAt: "2026-08-31T10:00:00.000Z", -} as never; +}; describe("Desktop hosted Skill Set assignments adapter", () => { test("publishes only the bounded revalidation lifecycle contract", async () => { @@ -55,7 +65,7 @@ describe("Desktop hosted Skill Set assignments adapter", () => { observed_at: 10, }; const operations = makeHostedStateOperations("/config", () => library, { - loadConfig: () => ({ url: "https://cloud.selftune.dev", apiKey: "device_token" }), + loadConfig: () => connection, fetch: async (_input, init) => { bodies.push(JSON.parse(String(init?.body))); return Response.json({ @@ -79,10 +89,7 @@ describe("Desktop hosted Skill Set assignments adapter", () => { const requests: Array<{ url: string; init: RequestInit | undefined }> = []; const packageBytes = new TextEncoder().encode("package bytes!"); const operations = makeHostedStateOperations("/config", () => library, { - loadConfig: () => ({ - url: "https://cloud.selftune.dev", - apiKey: "device_token", - }), + loadConfig: () => connection, fetch: async (input, init) => { const url = String(input); requests.push({ url, init }); @@ -148,10 +155,7 @@ describe("Desktop hosted Skill Set assignments adapter", () => { failure_code: null, }; const operations = makeHostedStateOperations("/config", () => library, { - loadConfig: () => ({ - url: "https://cloud.selftune.dev", - apiKey: "device_token", - }), + loadConfig: () => connection, fetch: async (_input, init) => { bodies.push(JSON.parse(String(init?.body))); return Response.json({ diff --git a/apps/local/tests/local-first-daemon.test.ts b/apps/local/tests/local-first-daemon.test.ts new file mode 100644 index 00000000..c4638e7a --- /dev/null +++ b/apps/local/tests/local-first-daemon.test.ts @@ -0,0 +1,116 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { openDb } from "@selftune/runtime/localdb/db"; +import { startDashboardServer } from "../src/dashboard-server.js"; +import { installFetchSpy } from "../../../tests/helpers/fetch-spy.js"; + +const directories: string[] = []; +const servers: Awaited>[] = []; +let restoreFetch: (() => void) | undefined; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + restoreFetch?.(); + restoreFetch = undefined; + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); + +test("an enrolled daemon serves local requests without sending or pruning legacy uploads", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-local-first-daemon-")); + directories.push(directory); + writeFileSync( + join(directory, "config.json"), + JSON.stringify({ + agent_type: "codex", + llm_mode: "agent", + agent_cli: "codex", + cli_path: "/test/selftune", + hooks_installed: false, + initialized_at: "2026-09-05T00:00:00.000Z", + alpha: { + enrolled: true, + user_id: "local-test", + cloud_user_id: "cloud-test", + credential: { provider: "file", account: "test" }, + }, + }), + ); + writeFileSync( + join(directory, "credential-store.json"), + JSON.stringify({ test: "st_test_not_real" }), + ); + const db = openDb(join(directory, "selftune.db")); + db.run( + "INSERT INTO upload_queue(payload_type, payload_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ["canonical", '{"private":"local history"}', "pending", "2020-01-01", "2020-01-01"], + ); + db.close(); + const originalFetch = globalThis.fetch; + const externalRequests: string[] = []; + restoreFetch = installFetchSpy(async (input, options) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "127.0.0.1") return originalFetch(input, options); + externalRequests.push(url.href); + throw new Error("Unexpected external request during local daemon test"); + }); + const server = await startDashboardServer({ + port: 0, + host: "127.0.0.1", + runtimeMode: "standalone", + openBrowser: false, + manageProcessSignals: false, + skillSetConfigRoot: directory, + }); + servers.push(server); + const health = await fetch(`http://127.0.0.1:${server.port}/api/health`); + expect(health.status).toBe(200); + const retired = await fetch( + `http://127.0.0.1:${server.port}/api/v2/trace-candidates/draft/submit`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + expect(retired.status).toBe(404); + await server.close(); + servers.pop(); + expect(externalRequests).toEqual([]); + const retained = openDb(join(directory, "selftune.db")); + try { + expect( + retained + .query<{ status: string; payload_json: string }, []>( + "SELECT status, payload_json FROM upload_queue", + ) + .all(), + ).toEqual([{ status: "pending", payload_json: '{"private":"local history"}' }]); + } finally { + retained.close(); + } +}); + +test("product entry points cannot reconnect the retired telemetry uploader", async () => { + const root = resolve(import.meta.dir, "../../.."); + expect(existsSync(join(root, "packages/runtime/alpha-upload/index.ts"))).toBe(false); + for (const directory of [ + "packages/runtime", + "packages/orchestration/src", + "apps/local/src", + "apps/cli/src", + ]) { + for await (const file of new Bun.Glob("**/*.ts").scan(join(root, directory))) { + const source = readFileSync(join(root, directory, file), "utf8"); + expect(source, file).not.toMatch( + /from\s+["'][^"']*alpha-upload|import\(["'][^"']*alpha-upload/, + ); + expect(source, file).not.toContain("prepareCompatibilityExport"); + expect(source, file).not.toContain("createCompatibilityExportWorker"); + expect(source, file).not.toContain("CloudEvaluationSubmissionClient"); + expect(source, file).not.toContain("CloudEvaluationTargetClient"); + } + } +}); diff --git a/apps/local/tests/operation-cache.test.ts b/apps/local/tests/operation-cache.test.ts index d5056504..fde1f050 100644 --- a/apps/local/tests/operation-cache.test.ts +++ b/apps/local/tests/operation-cache.test.ts @@ -5,11 +5,11 @@ import { join } from "node:path"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; import { makeMaterializedCache } from "../src/operation-cache.js"; -const run = (program: Effect.Effect): Promise => - Effect.runPromise(program as Effect.Effect); +const run = Effect.runPromise; describe("makeMaterializedCache", () => { test("serves repeated reads from one computation", async () => { @@ -210,11 +210,15 @@ describe("makeMaterializedCache", () => { computed += 1; return computed; }), - { readVersion: () => "v1", artifactPath, refreshIntervalMs: 10 }, + { readVersion: () => "v1", artifactPath, schema: Schema.Number, refreshIntervalMs: 10 }, ); const first = await run(Effect.scoped(Effect.flatMap(makeCache, (cache) => cache.read))); expect(first).toBe(1); - expect((JSON.parse(readFileSync(artifactPath, "utf8")) as { data: number }).data).toBe(1); + expect( + Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Struct({ data: Schema.Number })))( + readFileSync(artifactPath, "utf8"), + ).data, + ).toBe(1); // A fresh cache serves the persisted value immediately, then the refresh loop // recomputes in the background because the artifact counts as stale at boot. @@ -277,6 +281,7 @@ describe("makeMaterializedCache", () => { }), { artifactPath, + schema: Schema.Number, readVersion: () => version, refreshIntervalMs: 10, refreshTtlMs: 60_000, diff --git a/apps/local/tests/otlp-acceptance-support.ts b/apps/local/tests/otlp-acceptance-support.ts index 91032990..ed75af13 100644 --- a/apps/local/tests/otlp-acceptance-support.ts +++ b/apps/local/tests/otlp-acceptance-support.ts @@ -8,7 +8,10 @@ import * as Layer from "effect/Layer"; import { resolveSelftunePaths } from "@selftune/config"; import { openDb } from "@selftune/local-store"; -import { LocalTraceImporter } from "@selftune/observability/local-trace-importer"; +import { + LocalTraceImporter, + type LocalTraceImportRequest, +} from "@selftune/observability/local-trace-importer"; import { LocalTelemetryBatch, LocalTelemetrySpan } from "@selftune/observability/trace-batch"; import { makeDuckDbNodeApiAnalyticalStoreLive } from "@selftune/observability/duckdb-node-api"; import { normalizeOtlpExport } from "@selftune/observability/otlp"; @@ -130,7 +133,9 @@ export function checkpoints(directory: string, sourceKind = "otlp") { try { return Number( database - .query("SELECT count(*) AS count FROM analytical_import_checkpoints WHERE source_kind = ?") + .query<{ count: number }, [string]>( + "SELECT count(*) AS count FROM analytical_import_checkpoints WHERE source_kind = ?", + ) .get(sourceKind)?.count, ); } finally { @@ -165,7 +170,7 @@ const importer = (directory: string) => { makeLocalTraceImporterLive(database), makeDuckDbNodeApiAnalyticalStoreLive(paths.localAnalyticsPath), ); - const run = (request: object) => + const run = (request: LocalTraceImportRequest) => Effect.runPromise( Effect.gen(function* () { return yield* (yield* LocalTraceImporter).importTrace(request); @@ -230,6 +235,7 @@ export async function importNativeCodex(directory: string, schemaVersion = "1.0. source_kind: "codex", source_revision: "codex-native-1", normalizer_version: "native-v1", + // @ts-expect-error Deliberately unsupported wire version verifies runtime rejection before writes. batch: schemaVersion === "1.0.0" ? batch : { ...batch, schema_version: schemaVersion }, }); } finally { diff --git a/apps/local/tests/otlp-dashboard-runtime.test.ts b/apps/local/tests/otlp-dashboard-runtime.test.ts index 478b4346..47aa7a96 100644 --- a/apps/local/tests/otlp-dashboard-runtime.test.ts +++ b/apps/local/tests/otlp-dashboard-runtime.test.ts @@ -148,7 +148,11 @@ test("imports authenticated loopback OTLP trace and log exports through the shar }); expect(existsSync(paths.localAnalyticsPath)).toBe(true); const origin = `http://127.0.0.1:${server.port}`; - const request = (path: string, payload: object, token = AUTH_TOKEN) => + const request = ( + path: string, + payload: ReturnType | ReturnType, + token = AUTH_TOKEN, + ) => fetch(`${origin}${path}`, { method: "POST", headers: { diff --git a/apps/local/tests/plugin-inventory.test.ts b/apps/local/tests/plugin-inventory.test.ts index 88b6db51..2ed6805e 100644 --- a/apps/local/tests/plugin-inventory.test.ts +++ b/apps/local/tests/plugin-inventory.test.ts @@ -65,6 +65,77 @@ function fixtureRuntime(calls: string[]): PluginInventoryRuntime { } describe("plugin inventory", () => { + test("malformed host envelopes report an error instead of a successful empty inventory", () => { + const runtime: PluginInventoryRuntime = { + which: (command) => `/tools/${command}`, + now: () => new Date("2026-09-06T00:00:00Z"), + run: (command) => ({ + exitCode: 0, + stderr: "", + stdout: command.endsWith("claude") ? "{}" : "[]", + }), + }; + const inventory = discoverPluginInventory({}, runtime); + expect(inventory.hosts.map((host) => host.status)).toEqual(["error", "error"]); + expect(inventory.totalPlugins).toBe(0); + expect(() => + managePluginInstallation( + { host: "claude", pluginId: "missing", action: "remove" }, + {}, + runtime, + ), + ).toThrow("not installed"); + }); + + test("keeps valid plugins and receipt ownership beside malformed records", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-boundary-")); + try { + mkdirSync(join(root, "plugin-installs")); + writeFileSync( + join(root, "plugin-installs", "receipt.json"), + JSON.stringify({ hosts: [null, 42, { pluginId: {} }, { pluginId: "review@team" }] }), + ); + const runtime: PluginInventoryRuntime = { + which: (command) => `/tools/${command}`, + now: () => new Date("2026-09-06T00:00:00Z"), + run: (command) => ({ + exitCode: 0, + stderr: "", + stdout: JSON.stringify( + command.endsWith("claude") + ? [ + null, + { id: 42 }, + { id: "review@team", scope: "managed", enabled: false, version: {} }, + ] + : { + installed: [ + null, + { pluginId: [] }, + { pluginId: "review@team", version: "1", source: { source: "local" } }, + ], + }, + ), + }), + }; + const inventory = discoverPluginInventory({ configRoot: root }, runtime); + expect(inventory.hosts.map((host) => host.installedCount)).toEqual([1, 1]); + expect(inventory.managedPlugins).toBe(1); + expect(inventory.plugins[0]?.installations).toMatchObject([ + { + host: "claude", + scope: "managed", + enabled: false, + version: null, + availableActions: ["update"], + }, + { host: "codex", sourceType: "local", version: "1", availableActions: ["remove"] }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("groups the same plugin across hosts and marks receipt-owned installs", () => { const root = mkdtempSync(join(tmpdir(), "selftune-plugin-inventory-")); const calls: string[] = []; diff --git a/apps/local/tests/plugin-routes.test.ts b/apps/local/tests/plugin-routes.test.ts index 3852b7cf..60741ef4 100644 --- a/apps/local/tests/plugin-routes.test.ts +++ b/apps/local/tests/plugin-routes.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import type * as Schema from "effect/Schema"; +import { jsonRequest } from "../../../tests/helpers/json-request.js"; import type { PluginInventoryModel, @@ -18,19 +20,17 @@ const inventory: PluginInventoryModel = { }; function routeRequest( - runtime: ManagedRuntime.ManagedRuntime, + runtime: ManagedRuntime.ManagedRuntime, path: string, - body?: unknown, + body?: typeof Schema.Json.Type, includeOrigin = true, ) { - const request = new Request(`${origin}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: { - ...(includeOrigin ? { Origin: origin } : {}), - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); + const request = jsonRequest( + `${origin}${path}`, + body === undefined ? "GET" : "POST", + body, + includeOrigin ? origin : undefined, + ); return runtime.runPromise( handleDashboardApplicationRoute(request, new URL(request.url), { allowedOrigins: new Set([origin]), @@ -39,6 +39,35 @@ function routeRequest( } describe("Plugin management application routes", () => { + test("keeps CORS on malformed JSON errors without invoking the host", async () => { + let called = false; + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + pluginManager: (input) => { + called = true; + return { ...input, completedAt: "2026-08-11T09:31:00.000Z", inventory }; + }, + }), + ); + const request = new Request(`${origin}/api/v2/plugins/manage`, { + method: "POST", + headers: { Origin: origin, "Content-Type": "application/json" }, + body: "{", + }); + try { + const response = await runtime.runPromise( + handleDashboardApplicationRoute(request, new URL(request.url), { + allowedOrigins: new Set([origin]), + }), + ); + expect(response?.status).toBe(400); + expect(response?.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(called).toBe(false); + } finally { + await runtime.dispose(); + } + }); + test("loads inventory and maps an explicit host action", async () => { const calls: PluginManagementInputModel[] = []; const runtime = ManagedRuntime.make( @@ -65,6 +94,8 @@ describe("Plugin management application routes", () => { expect(await loaded?.json()).toEqual(inventory); expect(managed?.status).toBe(200); + expect(loaded?.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(managed?.headers.get("Access-Control-Allow-Origin")).toBe("*"); expect(calls).toEqual([ { host: "claude", pluginId: "paper-desktop@paper", action: "disable" }, ]); @@ -92,6 +123,7 @@ describe("Plugin management application routes", () => { false, ); expect(response?.status).toBe(403); + expect(response?.headers.get("Access-Control-Allow-Origin")).toBe("*"); expect(called).toBe(false); } finally { await runtime.dispose(); diff --git a/apps/local/tests/report-contract.test.ts b/apps/local/tests/report-contract.test.ts new file mode 100644 index 00000000..577ef4ad --- /dev/null +++ b/apps/local/tests/report-contract.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { emptyLibrarySnapshot, CandidateSnapshot } from "@selftune/control-plane"; +import { analyzeSkillIntelligence } from "@selftune/skill-intelligence"; +import { buildPortfolioAudit } from "@selftune/runtime/skill-portfolio"; +import { decodeReportOutput, ReportComputeError } from "../src/report-contract"; +import { + computeReportInSubprocess, + computeReportInWorker, + resolveReportComputeOptions, +} from "../src/report-compute"; +import { makeMaterializedCache } from "../src/operation-cache"; + +const directories = new Set(); +function temporaryDirectory() { + const dir = mkdtempSync(join(tmpdir(), "selftune-report-contract-")); + directories.add(dir); + return dir; +} +afterEach(() => { + for (const dir of directories) rmSync(dir, { recursive: true, force: true }); + directories.clear(); +}); + +const installed = { + name: "testing", + skill_path: "/workspace/testing/SKILL.md", + package_path: "/workspace/testing", + registry_dir: "/workspace", + modified_at: "2026-09-01T00:00:00Z", + skill_scope: "project", + content: "Test software", + harness: "codex", +} satisfies Parameters[0]["installedSkills"][number]; +const portfolio = buildPortfolioAudit([installed], [], [], { + now: new Date("2026-09-06T00:00:00Z"), +}); +const intelligence = analyzeSkillIntelligence({ + installedSkills: [installed], + sessions: [], + traceSignals: [ + { + skill_name: "testing", + invocation_count: 4, + trace_count: 4, + error_trace_count: 3, + duration_ms: 10, + input_tokens: 100, + output_tokens: 10, + error_count: 3, + tool_call_count: 4, + }, + ], + now: new Date("2026-09-06T00:00:00Z"), +}); + +describe("report payload contracts", () => { + test("round-trips portfolio and intelligence evidence from the real producers", () => { + expect(decodeReportOutput("portfolio-audit", JSON.stringify(portfolio))).toEqual(portfolio); + expect(intelligence.classifications).toHaveLength(1); + expect(intelligence.execution_patterns).toHaveLength(1); + expect(decodeReportOutput("skill-intelligence", JSON.stringify(intelligence))).toEqual( + intelligence, + ); + }); + test("reuses the library and synthesis contracts and preserves local extensions", () => { + const insights = { + snapshot: CandidateSnapshot.make({ + snapshotId: "test", + evidenceVersion: 1, + generatedAt: "2026-09-06", + candidates: [], + }), + portfolio_reviews: portfolio.skills, + counts: { + pending: 0, + accepted: 0, + drafted: 0, + snoozed: 0, + completed: 0, + stale_reviews: 0, + routing_reviews: 0, + }, + }; + expect(decodeReportOutput("insights", JSON.stringify(insights))).toEqual(insights); + expect(decodeReportOutput("library", JSON.stringify(emptyLibrarySnapshot))).toEqual( + emptyLibrarySnapshot, + ); + const extended = { + ...intelligence, + future_metadata: { measured: false }, + classifications: intelligence.classifications.map((row) => ({ + ...row, + future_metadata: { measured: false }, + })), + }; + expect(decodeReportOutput("skill-intelligence", JSON.stringify(extended))).toEqual(extended); + }); + test("rejects invalid nested evidence instead of reporting it as measured", () => { + const invalid = { + ...intelligence, + execution_patterns: intelligence.execution_patterns.map((row) => ({ + ...row, + causal_claim: true, + })), + }; + expect(() => decodeReportOutput("skill-intelligence", JSON.stringify(invalid))).toThrow(); + expect(() => + decodeReportOutput( + "portfolio-audit", + JSON.stringify({ ...portfolio, skills: [{ ...portfolio.skills[0], evidence: null }] }), + ), + ).toThrow(); + }); + test.each(["portfolio-audit", "skill-intelligence", "insights", "library"] as const)( + "rejects malformed %s envelopes", + (report) => { + for (const text of ["{", "null", "[]", "{}", '{"generated_at":123}']) + expect(() => decodeReportOutput(report, text)).toThrow(); + }, + ); +}); + +describe("report subprocess transport", () => { + const fixture = join(import.meta.dir, "fixtures", "report-worker.ts"); + test("decodes real subprocess output and removes the temporary artifact", async () => { + const dir = temporaryDirectory(); + const report = await Effect.runPromise( + computeReportInWorker("portfolio-audit", { configRoot: dir, searchDirs: [] }, dir, { + command: [process.execPath, fixture, "valid"], + }), + ); + expect(report.installed_count).toBe(0); + expect(readdirSync(dir)).toEqual([]); + }); + test.each(["invalid-json", "invalid-report"])( + "rejects %s output and still cleans up", + async (mode) => { + const dir = temporaryDirectory(); + await expect( + Effect.runPromise( + computeReportInWorker("portfolio-audit", { configRoot: dir }, dir, { + command: [process.execPath, fixture, mode], + }), + ), + ).rejects.toMatchObject({ _tag: "ReportComputeError", report: "portfolio-audit" }); + expect(readdirSync(dir)).toEqual([]); + }, + ); + test("retains exit code and bounded stderr in the typed failure", async () => { + const dir = temporaryDirectory(); + const result = await Effect.runPromise( + Effect.result( + computeReportInSubprocess( + "portfolio-audit", + { configRoot: dir }, + join(dir, "output.json"), + { command: [process.execPath, fixture, "exit"] }, + ), + ), + ); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected worker failure"); + expect(result.failure).toBeInstanceOf(ReportComputeError); + expect(result.failure.exitCode).toBe(7); + expect(result.failure.message).toContain("x".repeat(500)); + expect(result.failure.message.length).toBeLessThan(600); + }); + test("kills an overdue worker and removes its partial output", async () => { + const dir = temporaryDirectory(); + await expect( + Effect.runPromise( + computeReportInWorker("portfolio-audit", { configRoot: dir }, dir, { + command: [process.execPath, fixture, "timeout"], + timeoutMs: 500, + }), + ), + ).rejects.toMatchObject({ _tag: "ReportComputeError" }); + expect(readdirSync(dir)).toEqual([]); + }); + test("validates the actual report worker arguments before creating files", () => { + const dir = temporaryDirectory(); + const worker = join(import.meta.dir, "../src/report-worker.ts"); + const out = join(dir, "output.json"); + for (const [report, options] of [ + ["unknown-report", resolveReportComputeOptions({ configRoot: dir })], + ["portfolio-audit", {}], + [ + "portfolio-audit", + { storagePaths: { configRoot: dir, localDatabasePath: 3, localAnalyticsPath: "x" } }, + ], + ]) { + const result = Bun.spawnSync( + [process.execPath, worker, String(report), JSON.stringify(options), out], + { stdout: "ignore", stderr: "pipe" }, + ); + expect(result.exitCode).not.toBe(0); + expect(existsSync(out)).toBe(false); + } + }); + test("computes and decodes a real portfolio report through the worker", async () => { + const dir = temporaryDirectory(); + const report = await Effect.runPromise( + computeReportInWorker("portfolio-audit", { configRoot: dir, searchDirs: [] }, dir), + ); + expect(report.installed_count).toBe(0); + expect(report.skills).toEqual([]); + }); +}); + +describe("persisted report cache decoding", () => { + test.each([ + "{", + "null", + '{"schema_version":2,"generated_at":"today","data":1}', + '{"schema_version":1,"generated_at":"today","data":"not a number"}', + ])("recomputes a malformed artifact: %s", async (text) => { + const dir = temporaryDirectory(); + const artifactPath = join(dir, "report.json"); + writeFileSync(artifactPath, text); + let calls = 0; + const value = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const cache = yield* makeMaterializedCache( + Effect.sync(() => { + calls += 1; + return 42; + }), + { artifactPath, schema: Schema.Number, readVersion: () => "v1" }, + ); + return yield* cache.read; + }), + ), + ); + expect(value).toBe(42); + expect(calls).toBe(1); + expect( + Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Struct({ data: Schema.Number })))( + readFileSync(artifactPath, "utf8"), + ).data, + ).toBe(42); + }); +}); diff --git a/apps/local/tests/report-process-boundary.test.ts b/apps/local/tests/report-process-boundary.test.ts index 5a61b0cd..99535444 100644 --- a/apps/local/tests/report-process-boundary.test.ts +++ b/apps/local/tests/report-process-boundary.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { openDb } from "@selftune/local-store"; +import { decodeReportOutput } from "../src/report-contract"; import { reportWorkerArguments, @@ -58,39 +60,100 @@ describe("report process boundary", () => { }); }); - test("uses serialized storage instead of the subprocess ambient database", () => { - const root = mkdtempSync(join(tmpdir(), "selftune-report-worker-paths-")); + test.each(["skill-intelligence", "portfolio-audit", "library", "insights"])( + "%s uses serialized storage instead of the subprocess ambient database", + (report) => { + const root = mkdtempSync(join(tmpdir(), "selftune-report-worker-paths-")); + const storagePaths = { + configRoot: join(root, "host-config"), + localDatabasePath: join(root, "host-config", "selftune.db"), + localAnalyticsPath: join(root, "host-config", "observability.duckdb"), + }; + const ambientConfigRoot = join(root, "ambient-config"); + const outputPath = join(root, "report.json"); + const workerPath = join(appRoot, "src", "report-worker.ts"); + try { + const result = Bun.spawnSync( + [ + process.execPath, + workerPath, + report, + JSON.stringify(resolveReportComputeOptions({ storagePaths, searchDirs: [] })), + outputPath, + ], + { + env: { ...process.env, SELFTUNE_CONFIG_DIR: ambientConfigRoot }, + stderr: "pipe", + stdout: "ignore", + }, + ); + + expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); + expect(existsSync(storagePaths.localDatabasePath)).toBe(true); + expect(existsSync(storagePaths.localAnalyticsPath)).toBe(report === "skill-intelligence"); + expect(existsSync(join(ambientConfigRoot, "selftune.db"))).toBe(false); + expect(existsSync(join(ambientConfigRoot, "observability.duckdb"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }, + ); + + test("portfolio reads the host's sessions and library honors the selected skill directories", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-report-host-evidence-")); + const configRoot = join(root, "host"); + const localDatabasePath = join(configRoot, "host.sqlite"); const storagePaths = { - configRoot: join(root, "host-config"), - localDatabasePath: join(root, "host-config", "selftune.db"), - localAnalyticsPath: join(root, "host-config", "observability.duckdb"), + configRoot, + localDatabasePath, + localAnalyticsPath: join(configRoot, "host.duckdb"), }; - const ambientConfigRoot = join(root, "ambient-config"); - const outputPath = join(root, "report.json"); - const workerPath = join(appRoot, "src", "report-worker.ts"); + const registry = join(root, "project", ".agents", "skills"); + const skillRoot = join(registry, "report-owner"); + const db = openDb(localDatabasePath); try { - const result = Bun.spawnSync( + db.run("INSERT INTO session_telemetry (session_id, timestamp, cwd) VALUES (?, ?, ?)", [ + "host-session", + "2026-09-06T00:00:00.000Z", + join(root, "project"), + ]); + } finally { + db.close(); + } + mkdirSync(skillRoot, { recursive: true }); + writeFileSync( + join(skillRoot, "SKILL.md"), + "---\nname: report-owner\ndescription: Review a report using host-owned evidence.\n---\n# Report\nReview the evidence.\n", + ); + const options = resolveReportComputeOptions({ storagePaths, searchDirs: [registry] }); + const runWorker = (report: "portfolio-audit" | "library") => { + const output = join(root, `${report}.json`); + const child = Bun.spawnSync( [ process.execPath, - workerPath, - "skill-intelligence", - JSON.stringify(resolveReportComputeOptions({ storagePaths, searchDirs: [] })), - outputPath, + join(appRoot, "src", "report-worker.ts"), + report, + JSON.stringify(options), + output, ], { - env: { ...process.env, SELFTUNE_CONFIG_DIR: ambientConfigRoot }, - stderr: "pipe", + env: { ...process.env, SELFTUNE_CONFIG_DIR: join(root, "ambient") }, stdout: "ignore", + stderr: "pipe", }, ); - - expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); - expect(existsSync(storagePaths.localDatabasePath)).toBe(true); - expect(existsSync(storagePaths.localAnalyticsPath)).toBe(true); - expect(existsSync(join(ambientConfigRoot, "selftune.db"))).toBe(false); - expect(existsSync(join(ambientConfigRoot, "observability.duckdb"))).toBe(false); + expect(child.exitCode, new TextDecoder().decode(child.stderr)).toBe(0); + return readFileSync(output, "utf8"); + }; + try { + const portfolio = decodeReportOutput("portfolio-audit", runWorker("portfolio-audit")); + expect(portfolio.session_count).toBe(1); + expect(portfolio.installed_count).toBe(1); + const library = decodeReportOutput("library", runWorker("library")); + expect(library.skills.map((skill) => skill.name)).toEqual(["report-owner"]); + expect(existsSync(join(root, "ambient", "selftune.db"))).toBe(false); } finally { - rmSync(root, { force: true, recursive: true }); + rmSync(root, { recursive: true, force: true }); } }); }); diff --git a/apps/local/tests/skill-set-assignment-routes.test.ts b/apps/local/tests/skill-set-assignment-routes.test.ts index 110fcbee..8d7064e2 100644 --- a/apps/local/tests/skill-set-assignment-routes.test.ts +++ b/apps/local/tests/skill-set-assignment-routes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import type { Json } from "effect/Schema"; import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; import { handleDashboardApplicationRoute } from "../src/routes/application.js"; @@ -8,10 +9,10 @@ const origin = "http://127.0.0.1:3141"; const revision = "1".repeat(64); const envelope = "2".repeat(64); -function request( - runtime: ManagedRuntime.ManagedRuntime, +async function request( + runtime: ManagedRuntime.ManagedRuntime, path: string, - body?: unknown, + body?: Json, ) { const current = new Request(`${origin}${path}`, { method: body === undefined ? "GET" : "POST", @@ -21,11 +22,13 @@ function request( : { Origin: origin, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); - return runtime.runPromise( + const response = await runtime.runPromise( handleDashboardApplicationRoute(current, new URL(current.url), { allowedOrigins: new Set([origin]), }), ); + if (response === null) throw new Error(`No application route for ${path}`); + return response; } describe("assigned Skill Set application routes", () => { @@ -70,7 +73,7 @@ describe("assigned Skill Set application routes", () => { }, teamContributionSubmitter: (input) => { calls.push(`submit:${input.previewToken}`); - return { syncStatus: "pending" }; + return { requestId: "request_01", contributionId: null, syncStatus: "pending" }; }, teamContributionSyncer: () => { calls.push("sync"); diff --git a/apps/local/tests/skill-set-plugin-install.test.ts b/apps/local/tests/skill-set-plugin-install.test.ts index 742f7b3e..c6cdafa7 100644 --- a/apps/local/tests/skill-set-plugin-install.test.ts +++ b/apps/local/tests/skill-set-plugin-install.test.ts @@ -87,6 +87,174 @@ function runtime( } describe("Skill Set native plugin installation", () => { + test.each([true, false])( + "retains current/update decisions for both host formats: current=%s", + (current) => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-version-state-")); + try { + const { configRoot, manifest } = fixture(root); + const initial = previewSkillSetPluginInstall(manifest.set_id, { configRoot }, runtime([])); + const calls: string[] = []; + const base = runtime( + calls, + {}, + join(configRoot, "plugin-marketplaces", initial.marketplaceName), + ); + const version = current ? initial.pluginVersion : "0.0.0-selftune.previous"; + const id = `${initial.pluginName}@${initial.marketplaceName}`; + const installed: PluginInstallRuntime = { + ...base, + run: (command, args) => { + if (args.join(" ") === "plugin list --json") { + calls.push([command, ...args].join(" ")); + return { + exitCode: 0, + stderr: "", + stdout: command.endsWith("claude") + ? JSON.stringify([{ id, version }]) + : JSON.stringify({ installed: [{ pluginId: id, version }] }), + }; + } + return base.run(command, args); + }, + }; + const preview = previewSkillSetPluginInstall(manifest.set_id, { configRoot }, installed); + expect(preview.hosts.map((host) => host.status)).toEqual( + current + ? ["already_current", "already_current"] + : ["update_available", "update_available"], + ); + const receipt = installSkillSetPlugin( + { + setId: manifest.set_id, + expectedRevisionHash: manifest.revision_hash, + hosts: ["claude", "codex"], + }, + { configRoot }, + installed, + ); + expect(receipt.hosts.map((host) => host.result)).toEqual( + current ? ["already_current", "already_current"] : ["updated", "updated"], + ); + if (current) expect(calls.every((call) => call.endsWith("list --json"))).toBe(true); + else { + expect(calls).toContain(`/tools/claude plugin update ${id} --scope user`); + expect(calls).toContain(`/tools/codex plugin remove ${id} --json`); + expect(calls).toContain(`/tools/codex plugin add ${id} --json`); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + test.each(["claude", "codex"] as const)( + "blocks writes when %s inventory cannot be inspected", + (host) => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-invalid-inventory-")); + try { + const { configRoot, manifest } = fixture(root); + for (const stdout of ["{", "null", "{}", '[{"version":"1.0.0"}]']) { + const calls: string[] = []; + const base = runtime(calls); + const invalid: PluginInstallRuntime = { + ...base, + run: (command, args) => { + if (command.endsWith(host) && args.join(" ") === "plugin list --json") { + calls.push([command, ...args].join(" ")); + return { exitCode: 0, stdout, stderr: "" }; + } + return base.run(command, args); + }, + }; + expect(() => + installSkillSetPlugin( + { + setId: manifest.set_id, + expectedRevisionHash: manifest.revision_hash, + hosts: [host], + }, + { configRoot }, + invalid, + ), + ).toThrow("inventory response is invalid"); + expect(existsSync(join(configRoot, "plugin-marketplaces"))).toBe(false); + expect(existsSync(join(configRoot, "plugin-installs"))).toBe(false); + expect(calls.every((call) => call.endsWith("list --json"))).toBe(true); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + test.each(["claude", "codex"] as const)( + "preserves existing marketplace files when %s preflight fails", + (host) => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-marketplace-preflight-")); + try { + const { configRoot, manifest } = fixture(root); + const preview = previewSkillSetPluginInstall(manifest.set_id, { configRoot }, runtime([])); + const marketRoot = join(configRoot, "plugin-marketplaces", preview.marketplaceName); + mkdirSync(marketRoot, { recursive: true }); + const sentinel = join(marketRoot, "previous.txt"); + writeFileSync(sentinel, "keep previous marketplace"); + const failures = [ + { exitCode: 1, stdout: "", stderr: "host offline" }, + { exitCode: 0, stdout: "null", stderr: "" }, + { + exitCode: 0, + stdout: + host === "claude" + ? JSON.stringify([{ name: preview.marketplaceName }]) + : JSON.stringify({ marketplaces: [{ name: preview.marketplaceName }] }), + stderr: "", + }, + { + exitCode: 0, + stdout: + host === "claude" + ? JSON.stringify([{ name: preview.marketplaceName, path: join(root, "foreign") }]) + : JSON.stringify({ + marketplaces: [{ name: preview.marketplaceName, root: join(root, "foreign") }], + }), + stderr: "", + }, + ]; + for (const failure of failures) { + const calls: string[] = []; + const base = runtime(calls); + const invalid: PluginInstallRuntime = { + ...base, + run: (command, args) => { + if (command.endsWith(host) && args.join(" ") === "plugin marketplace list --json") { + calls.push([command, ...args].join(" ")); + return failure; + } + return base.run(command, args); + }, + }; + expect(() => + installSkillSetPlugin( + { + setId: manifest.set_id, + expectedRevisionHash: manifest.revision_hash, + hosts: [host], + }, + { configRoot }, + invalid, + ), + ).toThrow(); + expect(readFileSync(sentinel, "utf8")).toBe("keep previous marketplace"); + expect(existsSync(join(configRoot, "plugin-installs"))).toBe(false); + expect(calls.every((call) => call.endsWith("list --json"))).toBe(true); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + test("materializes one local marketplace and delegates installation to both host CLIs", () => { const root = mkdtempSync(join(tmpdir(), "selftune-plugin-install-")); const calls: string[] = []; diff --git a/apps/local/tests/skill-set-publish-routes.test.ts b/apps/local/tests/skill-set-publish-routes.test.ts index 5cb4592e..03ddf45e 100644 --- a/apps/local/tests/skill-set-publish-routes.test.ts +++ b/apps/local/tests/skill-set-publish-routes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import type { Json } from "effect/Schema"; import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; import { handleDashboardApplicationRoute } from "../src/routes/application.js"; @@ -34,9 +35,9 @@ const dependencyLock = { }; function routeRequest( - runtime: ManagedRuntime.ManagedRuntime, + runtime: ManagedRuntime.ManagedRuntime, path: string, - body: unknown, + body: Json, ) { const request = new Request(`${origin}${path}`, { method: "POST", @@ -61,6 +62,7 @@ describe("Skill Set publish application routes", () => { skillSetRevisionSha256: revisionSha256, envelopeSha256, byteLength: 1_024, + dependencyInput: dependencyResolution, contents: [{ name: "review", revisionSha256, license: "MIT" }], dependencies: { lock: dependencyLock, diff --git a/apps/local/tests/team-collaboration-routes.test.ts b/apps/local/tests/team-collaboration-routes.test.ts index c9d1357f..197887fc 100644 --- a/apps/local/tests/team-collaboration-routes.test.ts +++ b/apps/local/tests/team-collaboration-routes.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import type * as Schema from "effect/Schema"; +import { jsonRequest } from "../../../tests/helpers/json-request.js"; import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; import { handleDashboardApplicationRoute } from "../src/routes/application.js"; @@ -8,20 +10,13 @@ const origin = "http://127.0.0.1:3141"; const snapshot = { entries: [], contributions: [], installations: [] }; function routeRequest( - runtime: ManagedRuntime.ManagedRuntime, + runtime: ManagedRuntime.ManagedRuntime, path: string, method: "GET" | "PATCH" | "POST" = "GET", - body?: unknown, + body?: typeof Schema.Json.Type, includeOrigin = true, ) { - const request = new Request(`${origin}${path}`, { - method, - headers: { - ...(includeOrigin ? { Origin: origin } : {}), - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); + const request = jsonRequest(`${origin}${path}`, method, body, includeOrigin ? origin : undefined); return runtime.runPromise( handleDashboardApplicationRoute(request, new URL(request.url), { allowedOrigins: new Set([origin]), diff --git a/apps/local/tests/trace-candidate-contract.test.ts b/apps/local/tests/trace-candidate-contract.test.ts new file mode 100644 index 00000000..2138f952 --- /dev/null +++ b/apps/local/tests/trace-candidate-contract.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { Effect, Schema } from "effect"; + +import { TraceCandidateRequest } from "../src/trace-candidate-contract.js"; +import { + HostHistoricalTaskCalibration, + makeHostHistoricalTaskCalibrationLayer, +} from "../src/historical-skill-replay-executor.js"; + +describe("trace candidate input contract", () => { + const decode = Schema.decodeUnknownSync(TraceCandidateRequest); + + test.each([ + { input: null }, + { input: [] }, + { input: {} }, + { input: { pattern_id: "" } }, + { input: { pattern_id: 42 } }, + { input: { pattern_id: "pattern", candidate_count: 1 } }, + { input: { pattern_id: "pattern", candidate_count: 9 } }, + { input: { pattern_id: "pattern", candidate_count: 2.5 } }, + { input: { pattern_id: "pattern", candidate_count: "3" } }, + { input: { pattern_id: "pattern", calibration_repetitions: 0 } }, + { input: { pattern_id: "pattern", calibration_repetitions: 6 } }, + { input: { pattern_id: "pattern", calibration_repetitions: 1.5 } }, + ])("rejects malformed requests: %j", ({ input }) => { + expect(() => decode(input)).toThrow(); + }); + + test("retains omitted defaults and both inclusive limits", () => { + expect(decode({ pattern_id: "pattern" })).toEqual({ pattern_id: "pattern" }); + for (const input of [ + { pattern_id: "pattern", candidate_count: 2, calibration_repetitions: 1 }, + { pattern_id: "pattern", candidate_count: 8, calibration_repetitions: 5 }, + ]) { + expect(decode(input)).toEqual(input); + } + }); + + test("the calibration layer rejects an unsupported harness before replay", async () => { + const calibrate = await Effect.runPromise( + HostHistoricalTaskCalibration.pipe( + Effect.provide( + makeHostHistoricalTaskCalibrationLayer({ + agent: "claude", + model: "configured-default", + }), + ), + ), + ); + await expect( + calibrate({ + task: "Create release issues", + body: "Draft issues from the plan.", + skillName: "to-issues", + skillPath: "/nonexistent/selftune-test/SKILL.md", + }), + ).rejects.toThrow("Historical task calibration currently requires the Codex harness."); + }); +}); diff --git a/apps/local/tests/trace-candidate-route.test.ts b/apps/local/tests/trace-candidate-route.test.ts index 6c38fbe0..a5e59a6a 100644 --- a/apps/local/tests/trace-candidate-route.test.ts +++ b/apps/local/tests/trace-candidate-route.test.ts @@ -1,9 +1,73 @@ import { describe, expect, test } from "bun:test"; import { createTraceCandidateRoutes } from "../src/routes/trace-candidates.js"; +import type { TraceCandidateReview } from "../src/trace-candidate-contract.js"; +import type { + HistoricalSkillImprovementRequest, + HistoricalSkillImprovementResponse, +} from "../src/historical-skill-improvement-service.js"; +import { qualifyVerifierInstrument } from "@selftune/skill-intelligence/verifier-instruments"; const origin = "http://127.0.0.1:3141"; const allowedOrigins = new Set([origin]); +const prepared = { + draft_id: null, + pattern_id: "pattern", + cohort_fingerprint: null, + target_revision: null, + readiness: "not_ready", + failure_reason: "Insufficient evidence", + evidence: { cohort_entries: 0, resolved_entries: 0 }, + candidate: null, +} satisfies TraceCandidateReview; +const evaluated = { + pattern_id: "execution-pattern-test", + draft_id: null, + candidate_id: null, + evaluation_id: null, + status: "not_ready", + evidence_level: "E0", + reason: "Insufficient evidence", + cohort_fingerprint: null, + cases: { calibration: 0, selection: 0, audit_holdout: 0, active_regressions: 0 }, + applies_change: false, +} satisfies HistoricalSkillImprovementResponse; +const evaluationRequest = { + pattern_id: "execution-pattern-test", + qualified_verifier: qualifyVerifierInstrument({ + instrument: { + verifier_id: "portal-check", + version: "v1", + kind: "deterministic", + success_contract: "Portal confirms upload", + check_description: "Checks portal confirmation", + }, + evidence: (["known_failure", "known_good", "boundary", "adversarial"] as const).map( + (label) => ({ + evidence_id: `control-${label}`, + label, + expected_decision: label === "known_failure" ? "reject" : "accept", + observed_decision: label === "known_failure" ? "reject" : "accept", + partition: "verifier_calibration", + candidate_strategy_reference: null, + }), + ), + }), + runtime: { harness: "codex", model: "gpt-5", config_digest: `sha256:${"a".repeat(64)}` }, + required_scored_repetitions: 3, + max_attempts_per_arm: 3, + controls: { + entitlement_proactive_managed: true, + proactive_generation_enabled: true, + managed_execution_enabled: true, + kill_switch_enabled: false, + active_runs: 0, + max_concurrency: 1, + budget_remaining_usd: 1, + estimated_cost_usd: 0.1, + }, + recorded_at: "2026-09-06T10:00:00Z", +} satisfies HistoricalSkillImprovementRequest; function request(body: string, headers: Record = {}): Request { return new Request(`${origin}/api/v2/trace-candidates/prepare`, { @@ -19,7 +83,7 @@ describe("trace candidate preparation route", () => { const routes = createTraceCandidateRoutes({ prepare: async () => { called = true; - return {}; + return prepared; }, }); const response = await routes.handle( @@ -39,7 +103,7 @@ describe("trace candidate preparation route", () => { }); test("bounds streamed request bodies at 8 KiB", async () => { - const routes = createTraceCandidateRoutes({ prepare: async () => ({}) }); + const routes = createTraceCandidateRoutes({ prepare: async () => prepared }); const response = await routes.handle( request(JSON.stringify({ pattern_id: "x", padding: "a".repeat(8 * 1024) })), new URL(`${origin}/api/v2/trace-candidates/prepare`), @@ -55,7 +119,7 @@ describe("trace candidate preparation route", () => { }); test("fails closed when historical evaluation has no managed replay harness", async () => { - const routes = createTraceCandidateRoutes({ prepare: async () => ({}) }); + const routes = createTraceCandidateRoutes({ prepare: async () => prepared }); const url = new URL(`${origin}/api/v2/trace-candidates/evaluate`); const response = await routes.handle( new Request(url, { @@ -77,23 +141,96 @@ describe("trace candidate preparation route", () => { test("delegates historical evaluation to the registered product service", async () => { const routes = createTraceCandidateRoutes({ - prepare: async () => ({}), - evaluate: async (input) => ({ status: "review_ready", input }), + prepare: async () => prepared, + evaluate: async (input) => { + expect(input).toEqual(evaluationRequest); + return evaluated; + }, }); const url = new URL(`${origin}/api/v2/trace-candidates/evaluate`); const response = await routes.handle( new Request(url, { method: "POST", headers: { "content-type": "application/json", origin }, - body: JSON.stringify({ pattern_id: "execution-pattern-test" }), + body: JSON.stringify(evaluationRequest), }), url, allowedOrigins, ); expect(response?.status).toBe(200); - expect(await response?.json()).toEqual({ - status: "review_ready", - input: { pattern_id: "execution-pattern-test" }, + expect(await response?.json()).toEqual(evaluated); + }); + + test("forwards a validated preparation request and complete review", async () => { + const input = { pattern_id: "pattern", candidate_count: 3, calibration_repetitions: 2 }; + const routes = createTraceCandidateRoutes({ + prepare: async (received) => { + expect(received).toEqual(input); + return prepared; + }, + }); + const response = await routes.handle( + request(JSON.stringify(input)), + new URL(`${origin}/api/v2/trace-candidates/prepare`), + allowedOrigins, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual(prepared); + }); + + test.each([ + "{broken", + "null", + "{}", + JSON.stringify({ pattern_id: "" }), + JSON.stringify({ pattern_id: "pattern", candidate_count: 9 }), + JSON.stringify({ pattern_id: "pattern", calibration_repetitions: "2" }), + ])("rejects invalid preparation before service invocation: %s", async (body) => { + let called = false; + const routes = createTraceCandidateRoutes({ + prepare: async () => { + called = true; + return prepared; + }, + }); + const response = await routes.handle( + request(body), + new URL(`${origin}/api/v2/trace-candidates/prepare`), + allowedOrigins, + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + error: { code: "INVALID_TRACE_CANDIDATE_REQUEST" }, + }); + expect(called).toBeFalse(); + }); + + test.each([ + { pattern_id: "execution-pattern-test" }, + { ...evaluationRequest, runtime: { ...evaluationRequest.runtime, config_digest: "invalid" } }, + { + ...evaluationRequest, + controls: { ...evaluationRequest.controls, kill_switch_enabled: "false" }, + }, + ])("rejects incomplete or malformed evaluation contracts before replay: %j", async (input) => { + let called = false; + const routes = createTraceCandidateRoutes({ + prepare: async () => prepared, + evaluate: async () => { + called = true; + return evaluated; + }, + }); + const url = new URL(`${origin}/api/v2/trace-candidates/evaluate`); + const response = await routes.handle( + new Request(url, { method: "POST", headers: { origin }, body: JSON.stringify(input) }), + url, + allowedOrigins, + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + error: { code: "INVALID_TRACE_CANDIDATE_REQUEST" }, }); + expect(called).toBeFalse(); }); }); diff --git a/apps/selfhost/src/config.test.ts b/apps/selfhost/src/config.test.ts index 71479000..38f06285 100644 --- a/apps/selfhost/src/config.test.ts +++ b/apps/selfhost/src/config.test.ts @@ -26,6 +26,47 @@ async function expectConfigFailure(input: NodeJS.ProcessEnv): Promise { + test.each([ + "{broken", + "null", + "{}", + JSON.stringify([ + { email: "member@example.com", token: "PRIVATE_TOKEN_MARKER", role: "unsupported" }, + ]), + ])("rejects malformed account JSON without echoing credentials: %s", async (value) => { + const error = await expectConfigFailure(environment({ SELFTUNE_SELFHOST_USERS_JSON: value })); + expect(error.message).toBe( + "SELFTUNE_SELFHOST_USERS_JSON must be valid JSON containing configured accounts.", + ); + expect(error.message).not.toContain("PRIVATE_TOKEN_MARKER"); + }); + + test("retains and normalizes valid additional accounts", async () => { + const config = await Effect.runPromise( + loadSelfHostConfig( + environment({ + SELFTUNE_SELFHOST_USERS_JSON: JSON.stringify([ + { + email: " MEMBER@example.com ", + token: "SELFHOST_EXAMPLE_MEMBER_TOKEN_FOR_TESTS_0002", + name: " Member ", + org_name: " Team ", + role: "viewer", + }, + ]), + }), + ), + ); + expect(config.accounts[1]).toEqual({ + email: "member@example.com", + token: "SELFHOST_EXAMPLE_MEMBER_TOKEN_FOR_TESTS_0002", + name: "Member", + orgId: null, + orgName: "Team", + role: "viewer", + }); + }); + test("accepts a generated administrator token", async () => { const config = await Effect.runPromise(loadSelfHostConfig(environment())); expect(config.adminToken).toBe(VALID_TOKEN); diff --git a/apps/selfhost/src/config.ts b/apps/selfhost/src/config.ts index a2c74f23..f4b66779 100644 --- a/apps/selfhost/src/config.ts +++ b/apps/selfhost/src/config.ts @@ -118,7 +118,9 @@ function configuredUser( }; } -const decodeConfiguredUsers = Schema.decodeUnknownEffect(ConfiguredUsersInput); +const decodeConfiguredUsers = Schema.decodeUnknownEffect( + Schema.fromJsonString(ConfiguredUsersInput), +); export const loadSelfHostConfig = Effect.fn("SelfHostConfig.load")(function* ( environment: NodeJS.ProcessEnv = process.env, @@ -130,15 +132,13 @@ export const loadSelfHostConfig = Effect.fn("SelfHostConfig.load")(function* ( ); } - const parsedUsers = yield* Effect.try({ - try: (): unknown => JSON.parse(environment.SELFTUNE_SELFHOST_USERS_JSON ?? "[]"), - catch: () => configFailure("SELFTUNE_SELFHOST_USERS_JSON must be valid JSON."), - }).pipe( - Effect.flatMap(decodeConfiguredUsers), - Effect.mapError((error) => - error instanceof SelfHostConfigFailure - ? error - : configFailure(`SELFTUNE_SELFHOST_USERS_JSON is invalid: ${error.message}`), + const parsedUsers = yield* decodeConfiguredUsers( + environment.SELFTUNE_SELFHOST_USERS_JSON ?? "[]", + ).pipe( + Effect.mapError(() => + configFailure( + "SELFTUNE_SELFHOST_USERS_JSON must be valid JSON containing configured accounts.", + ), ), ); diff --git a/apps/selfhost/src/remote-api.test.ts b/apps/selfhost/src/remote-api.test.ts index faab3341..97142a4b 100644 --- a/apps/selfhost/src/remote-api.test.ts +++ b/apps/selfhost/src/remote-api.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { encodeCanonicalSkillSetSourceManifest, encodePortablePackageBundle, @@ -61,16 +62,9 @@ function config(dataDir: string): SelfHostConfig { }; } -function field(value: unknown, key: string): unknown { - if (typeof value !== "object" || value === null || !(key in value)) return undefined; - return Reflect.get(value, key); -} - -function stringField(value: unknown, key: string): string { - const result = field(value, key); - if (typeof result !== "string") throw new TypeError(`Expected ${key} to be a string.`); - return result; -} +const SnapshotReceipt = Schema.Struct({ snapshot: Schema.Struct({ id: Schema.String }) }); +const PackReceipt = Schema.Struct({ packId: Schema.String, packUrl: Schema.String }); +const ShareReceipt = Schema.Struct({ share: Schema.Struct({ id: Schema.String }) }); async function request( handle: RemoteApiHandle, @@ -78,14 +72,13 @@ async function request( token?: string, init: RequestInit = {}, ): Promise { + const headers = new Headers({ Origin: ORIGIN }); + if (token) headers.set("Authorization", `Bearer ${token}`); + new Headers(init.headers).forEach((value, name) => headers.set(name, value)); const response = await handle.handle( new Request(`http://localhost${path}`, { ...init, - headers: { - Origin: ORIGIN, - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, + headers, }), ); if (!response) throw new TypeError(`Self-host API did not handle ${path}.`); @@ -153,8 +146,8 @@ describe("self-hosted Remote Library API", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - expect(field(await (await relay()).json(), "status")).toBe("accepted"); - expect(field(await (await relay()).json(), "status")).toBe("duplicate"); + expect(await (await relay()).json()).toMatchObject({ status: "accepted" }); + expect(await (await relay()).json()).toMatchObject({ status: "duplicate" }); const aggregate = await request( handle, `/api/v1/contributions/aggregates/${payload.skill_hash}`, @@ -247,7 +240,9 @@ describe("self-hosted Remote Library API", () => { ], }), }); - const snapshotId = stringField(field(await committed.json(), "snapshot"), "id"); + const { + snapshot: { id: snapshotId }, + } = Schema.decodeUnknownSync(SnapshotReceipt)(await committed.json()); const issued = await request(handle, "/api/v1/remote-library/packs", ADMIN_TOKEN, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -258,9 +253,9 @@ describe("self-hosted Remote Library API", () => { }), }); expect(issued.status).toBe(201); - const issuedBody = await issued.json(); - const packId = stringField(issuedBody, "packId"); - const packUrl = new URL(stringField(issuedBody, "packUrl")); + const issuedBody = Schema.decodeUnknownSync(PackReceipt)(await issued.json()); + const packId = issuedBody.packId; + const packUrl = new URL(issuedBody.packUrl); expect(packUrl.origin).toBe(ORIGIN); expect(packUrl.pathname).toMatch(/^\/p\/[A-Za-z0-9_-]{43}$/); const token = packUrl.pathname.split("/").at(-1)!; @@ -274,14 +269,11 @@ describe("self-hosted Remote Library API", () => { const listed = await request(handle, "/api/v1/remote-library/packs", ADMIN_TOKEN); expect(listed.status).toBe(200); - const listedPacks = field(await listed.json(), "packs"); - expect(Array.isArray(listedPacks)).toBeTrue(); - if (!Array.isArray(listedPacks)) throw new TypeError("Expected Pack list."); - expect(field(listedPacks[0], "packUrl")).toBe(packUrl.href); + expect(await listed.json()).toMatchObject({ packs: [{ packUrl: packUrl.href }] }); const preview = await request(handle, `/api/v1/public/packs/${token}`); expect(preview.status).toBe(200); - expect(field(await preview.json(), "objectSha256")).toBe(objectSha256); + expect(await preview.json()).toMatchObject({ objectSha256 }); const content = await request(handle, `/api/v1/public/packs/${token}/content`); expect(content.status).toBe(200); expect(content.headers.get("x-selftune-content-sha256")).toBe(objectSha256); @@ -302,9 +294,9 @@ describe("self-hosted Remote Library API", () => { temporaryDirectories.push(parent); const dataDir = join(parent, "not-a-directory"); writeFileSync(dataDir, "blocks repository initialization"); - const unhandledRejections: unknown[] = []; - const recordUnhandledRejection = (reason: unknown): void => { - unhandledRejections.push(reason); + let unhandledRejectionCount = 0; + const recordUnhandledRejection = (): void => { + unhandledRejectionCount++; }; process.on("unhandledRejection", recordUnhandledRejection); @@ -322,17 +314,15 @@ describe("self-hosted Remote Library API", () => { const health = await request(handle, "/healthz"); expect(health.status).toBe(200); - expect(field(await health.json(), "check")).toBe("liveness"); + expect(await health.json()).toMatchObject({ check: "liveness" }); const readiness = await request(handle, "/readyz"); expect(readiness.status).toBe(503); - expect(field(field(await readiness.json(), "error"), "code")).toBe( - "RemoteLibraryUnavailable", - ); + expect(await readiness.json()).toMatchObject({ error: { code: "RemoteLibraryUnavailable" } }); const api = await request(handle, "/api/v1/remote-library/capabilities", ADMIN_TOKEN); expect(api.status).toBe(503); - expect(field(field(await api.json(), "error"), "code")).toBe("RemoteLibraryUnavailable"); + expect(await api.json()).toMatchObject({ error: { code: "RemoteLibraryUnavailable" } }); const preflight = await request(handle, "/api/v1/remote-library/objects/hash", undefined, { method: "OPTIONS", @@ -341,7 +331,7 @@ describe("self-hosted Remote Library API", () => { expect(await preflight.text()).toBe(""); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(unhandledRejections).toEqual([]); + expect(unhandledRejectionCount).toBe(0); } finally { process.off("unhandledRejection", recordUnhandledRejection); } @@ -355,7 +345,7 @@ describe("self-hosted Remote Library API", () => { const initialReadiness = await request(handle, "/readyz"); expect(initialReadiness.status).toBe(200); - expect(field(await initialReadiness.json(), "check")).toBe("readiness"); + expect(await initialReadiness.json()).toMatchObject({ check: "readiness" }); const unauthenticated = await request(handle, "/api/v1/remote-library/capabilities"); expect(unauthenticated.status).toBe(401); @@ -369,7 +359,7 @@ describe("self-hosted Remote Library API", () => { const capabilities = await request(handle, "/api/v1/remote-library/capabilities", ADMIN_TOKEN); expect(capabilities.status).toBe(200); - expect(field(await capabilities.json(), "protocol")).toBe("selftune.remote-library.v1"); + expect(await capabilities.json()).toMatchObject({ protocol: "selftune.remote-library.v1" }); const bytes = new TextEncoder().encode("name: durable-skill\nversion: 1\n"); const objectSha256 = createHash("sha256").update(bytes).digest("hex"); @@ -419,7 +409,9 @@ describe("self-hosted Remote Library API", () => { }), }); expect(commit.status).toBe(201); - const snapshotId = stringField(field(await commit.json(), "snapshot"), "id"); + const { + snapshot: { id: snapshotId }, + } = Schema.decodeUnknownSync(SnapshotReceipt)(await commit.json()); const adminObjectPath = join(dataDir, "objects", ADMIN_ORG_ID, objectSha256); writeFileSync(adminObjectPath, "corrupt object bytes"); @@ -430,17 +422,16 @@ describe("self-hosted Remote Library API", () => { ); expect(degradedDiagnostics.status).toBe(200); const degradedPayload = await degradedDiagnostics.json(); - expect(field(degradedPayload, "status")).toBe("degraded"); - expect(field(degradedPayload, "missing_objects")).toEqual([objectSha256]); + expect(degradedPayload).toMatchObject({ status: "degraded", missing_objects: [objectSha256] }); const livenessWhileDegraded = await request(handle, "/healthz"); expect(livenessWhileDegraded.status).toBe(200); - expect(field(await livenessWhileDegraded.json(), "check")).toBe("liveness"); + expect(await livenessWhileDegraded.json()).toMatchObject({ check: "liveness" }); const degradedReadiness = await request(handle, "/readyz"); expect(degradedReadiness.status).toBe(503); - expect(field(field(await degradedReadiness.json(), "error"), "code")).toBe( - "RemoteLibraryIntegrityDegraded", - ); + expect(await degradedReadiness.json()).toMatchObject({ + error: { code: "RemoteLibraryIntegrityDegraded" }, + }); const corruptCommit = await request(handle, "/api/v1/remote-library/snapshots", ADMIN_TOKEN, { method: "POST", @@ -452,9 +443,9 @@ describe("self-hosted Remote Library API", () => { }), }); expect(corruptCommit.status).toBe(422); - expect(field(field(await corruptCommit.json(), "error"), "code")).toBe( - "RemoteLibraryHashMismatch", - ); + expect(await corruptCommit.json()).toMatchObject({ + error: { code: "RemoteLibraryHashMismatch" }, + }); const repair = await request( handle, @@ -467,7 +458,7 @@ describe("self-hosted Remote Library API", () => { }, ); expect(repair.status).toBe(200); - expect(field(await repair.json(), "created")).toBe(false); + expect(await repair.json()).toMatchObject({ created: false }); expect(readdirSync(dirname(adminObjectPath)).filter((name) => name.includes(".tmp-"))).toEqual( [], ); @@ -477,10 +468,10 @@ describe("self-hosted Remote Library API", () => { "/api/v1/remote-library/diagnostics", ADMIN_TOKEN, ); - expect(field(await healthyDiagnostics.json(), "status")).toBe("ok"); + expect(await healthyDiagnostics.json()).toMatchObject({ status: "ok" }); const repairedReadiness = await request(handle, "/readyz"); expect(repairedReadiness.status).toBe(200); - expect(field(await repairedReadiness.json(), "check")).toBe("readiness"); + expect(await repairedReadiness.json()).toMatchObject({ check: "readiness" }); const conflict = await request(handle, "/api/v1/remote-library/snapshots", ADMIN_TOKEN, { method: "POST", @@ -492,9 +483,9 @@ describe("self-hosted Remote Library API", () => { }), }); expect(conflict.status).toBe(409); - const conflictError = field(await conflict.json(), "error"); - expect(field(conflictError, "code")).toBe("RemoteLibraryHeadConflict"); - expect(field(conflictError, "current_head_id")).toBe(snapshotId); + expect(await conflict.json()).toMatchObject({ + error: { code: "RemoteLibraryHeadConflict", current_head_id: snapshotId }, + }); const memberObjectBeforeImport = await request( handle, @@ -513,7 +504,9 @@ describe("self-hosted Remote Library API", () => { }), }); expect(createShare.status).toBe(201); - const shareId = stringField(field(await createShare.json(), "share"), "id"); + const { + share: { id: shareId }, + } = Schema.decodeUnknownSync(ShareReceipt)(await createShare.json()); const accept = await request( handle, @@ -522,7 +515,7 @@ describe("self-hosted Remote Library API", () => { { method: "POST" }, ); expect(accept.status).toBe(200); - expect(field(field(await accept.json(), "share"), "status")).toBe("accepted"); + expect(await accept.json()).toMatchObject({ share: { status: "accepted" } }); writeFileSync(adminObjectPath, "corrupt before import"); const memberObjectPath = join(dataDir, "objects", MEMBER_ORG_ID, objectSha256); @@ -536,9 +529,9 @@ describe("self-hosted Remote Library API", () => { { method: "POST" }, ); expect(rejectedImport.status).toBe(422); - expect(field(field(await rejectedImport.json(), "error"), "code")).toBe( - "RemoteLibraryHashMismatch", - ); + expect(await rejectedImport.json()).toMatchObject({ + error: { code: "RemoteLibraryHashMismatch" }, + }); const repairBeforeImport = await request( handle, @@ -556,8 +549,10 @@ describe("self-hosted Remote Library API", () => { ); expect(imported.status).toBe(200); const importedPayload = await imported.json(); - expect(field(field(importedPayload, "share"), "status")).toBe("imported"); - expect(field(importedPayload, "snapshot")).not.toBeNull(); + expect(importedPayload).toMatchObject({ + share: { status: "imported" }, + snapshot: { id: expect.any(String) }, + }); const memberObjectAfterImport = await request( handle, @@ -570,9 +565,9 @@ describe("self-hosted Remote Library API", () => { writeFileSync(memberObjectPath, "corrupt member organization object"); const crossTenantReadiness = await request(handle, "/readyz"); expect(crossTenantReadiness.status).toBe(503); - expect(field(field(await crossTenantReadiness.json(), "error"), "code")).toBe( - "RemoteLibraryIntegrityDegraded", - ); + expect(await crossTenantReadiness.json()).toMatchObject({ + error: { code: "RemoteLibraryIntegrityDegraded" }, + }); writeFileSync(memberObjectPath, bytes); expect((await request(handle, "/readyz")).status).toBe(200); @@ -586,6 +581,6 @@ describe("self-hosted Remote Library API", () => { ADMIN_TOKEN, ); expect(persistedHead.status).toBe(200); - expect(stringField(field(await persistedHead.json(), "snapshot"), "id")).toBe(snapshotId); + expect(await persistedHead.json()).toMatchObject({ snapshot: { id: snapshotId } }); }); }); diff --git a/apps/selfhost/src/remote-api.ts b/apps/selfhost/src/remote-api.ts index 80a679c5..f0ca5a7b 100644 --- a/apps/selfhost/src/remote-api.ts +++ b/apps/selfhost/src/remote-api.ts @@ -50,42 +50,36 @@ function requireRole(user: SelfHostUser, minimum: UserRole): Effect.Effect => request.json(), + try: () => request.text(), catch: () => failure("RemoteLibraryInvalidSnapshot", 400, "Invalid Remote Library snapshot"), }); - return yield* Schema.decodeUnknownEffect(CreateSnapshotRequest)(input).pipe( - Effect.mapError((error) => - failure("RemoteLibraryInvalidSnapshot", 400, "Invalid Remote Library snapshot", { - details: error.message, - }), + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(CreateSnapshotRequest))( + input, + ).pipe( + Effect.mapError(() => + failure("RemoteLibraryInvalidSnapshot", 400, "Invalid Remote Library snapshot"), ), ); }); const decodeShareRequest = Effect.fn("SelfHostApi.decodeShare")(function* (request: Request) { const input = yield* Effect.tryPromise({ - try: (): Promise => request.json(), + try: () => request.text(), catch: () => failure("RemoteLibraryInvalidShare", 400, "Invalid private share"), }); - return yield* Schema.decodeUnknownEffect(CreateShareRequest)(input).pipe( - Effect.mapError((error) => - failure("RemoteLibraryInvalidShare", 400, "Invalid private share", { - details: error.message, - }), - ), + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(CreateShareRequest))(input).pipe( + Effect.mapError(() => failure("RemoteLibraryInvalidShare", 400, "Invalid private share")), ); }); const decodePackRequest = Effect.fn("SelfHostApi.decodePack")(function* (request: Request) { const input = yield* Effect.tryPromise({ - try: (): Promise => request.json(), + try: () => request.text(), catch: () => failure("RemoteLibraryInvalidPack", 400, "Invalid Skill Set Pack request"), }); - return yield* Schema.decodeUnknownEffect(CreatePackRequest)(input).pipe( - Effect.mapError((error) => - failure("RemoteLibraryInvalidPack", 400, "Invalid Skill Set Pack request", { - details: error.message, - }), + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(CreatePackRequest))(input).pipe( + Effect.mapError(() => + failure("RemoteLibraryInvalidPack", 400, "Invalid Skill Set Pack request"), ), ); }); @@ -94,15 +88,13 @@ const decodeContribution = Effect.fn("SelfHostApi.decodeContribution")(function* request: Request, ) { const input = yield* Effect.tryPromise({ - try: (): Promise => request.json(), + try: () => request.text(), catch: () => failure("ContributorSignalInvalid", 400, "Invalid contributor signal"), }); - return yield* Schema.decodeUnknownEffect(ContributorSignalPayload)(input).pipe( - Effect.mapError((error) => - failure("ContributorSignalInvalid", 400, "Invalid contributor signal", { - details: error.message, - }), - ), + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ContributorSignalPayload))( + input, + ).pipe( + Effect.mapError(() => failure("ContributorSignalInvalid", 400, "Invalid contributor signal")), ); }); @@ -110,16 +102,12 @@ const decodeDesktopManifest = Effect.fn("SelfHostApi.decodeDesktopManifest")(fun request: Request, ) { const input = yield* Effect.tryPromise({ - try: (): Promise => request.json(), + try: () => request.text(), catch: () => failure("HostedManifestInvalid", 400, "Invalid Desktop manifest"), }); - return yield* Schema.decodeUnknownEffect(DesktopManifestPayload)(input).pipe( - Effect.mapError((error) => - failure("HostedManifestInvalid", 400, "Invalid Desktop manifest", { - details: error.message, - }), - ), - ); + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(DesktopManifestPayload))( + input, + ).pipe(Effect.mapError(() => failure("HostedManifestInvalid", 400, "Invalid Desktop manifest"))); }); function objectHeaders(object: { diff --git a/apps/selfhost/src/remote-dashboard.test.ts b/apps/selfhost/src/remote-dashboard.test.ts index f06f210b..e36bbfbd 100644 --- a/apps/selfhost/src/remote-dashboard.test.ts +++ b/apps/selfhost/src/remote-dashboard.test.ts @@ -60,11 +60,6 @@ function config(dataDir: string): SelfHostConfig { }; } -function field(value: unknown, key: string): unknown { - if (typeof value !== "object" || value === null || !(key in value)) return undefined; - return Reflect.get(value, key); -} - function sha256(bytes: Uint8Array | string): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -89,13 +84,12 @@ async function apiRequest( init: RequestInit = {}, token = ADMIN_TOKEN, ): Promise { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${token}`); const response = await handle.handle( new Request(`http://selftune.internal${path}`, { ...init, - headers: { - Authorization: `Bearer ${token}`, - ...init.headers, - }, + headers, }), ); if (!response) throw new TypeError(`Self-host API did not handle ${path}.`); @@ -124,7 +118,7 @@ async function putObject( return objectSha256; } -function createHandle(): { readonly config: SelfHostConfig; readonly handle: RemoteApiHandle } { +function createHandle() { const dataDir = mkdtempSync(join(tmpdir(), "selftune-remote-dashboard-")); temporaryDirectories.push(dataDir); const hostConfig = config(dataDir); @@ -134,6 +128,51 @@ function createHandle(): { readonly config: SelfHostConfig; readonly handle: Rem } describe("self-hosted remote dashboard read model", () => { + test.each(["not JSON", '{"snapshot":{"id":42}}'])( + "rejects malformed snapshot responses: %s", + async (body) => { + const loaders = makeRemoteDashboardLoaders(config("unused"), { + handle: async () => new Response(body), + dispose: async () => {}, + ready: Promise.resolve(), + }); + await expect(loaders.libraryLoader()).rejects.toMatchObject({ + operation: "decode_head", + status: 502, + }); + }, + ); + test.each(["not JSON", '{"schema_version":1,"skills":42}'])( + "rejects malformed stored Skill Set bytes: %s", + async (body) => { + const { config: hostConfig, handle } = createHandle(); + const objectSha256 = await putObject(handle, new TextEncoder().encode(body)); + const commit = await apiRequest(handle, "/api/v1/remote-library/snapshots", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_version: "selftune.remote-library.snapshot.v1", + expected_parent_id: null, + artifacts: [ + { + artifact_id: "skill-set/broken/v1", + artifact_type: "skill_set", + object_sha256: objectSha256, + revision: sha256("broken-v1"), + metadata: {}, + }, + ], + }), + }); + expect(commit.status).toBe(201); + await expect( + makeRemoteDashboardLoaders(hostConfig, handle).skillSetsLoader(), + ).rejects.toMatchObject({ + operation: "decode_skill_set", + status: 422, + }); + }, + ); test("returns canonical empty Library and Skill Set views before the first backup", async () => { const { config: hostConfig, handle } = createHandle(); const hiddenBytes = packageBytes("other-org-skill"); @@ -213,14 +252,14 @@ describe("self-hosted remote dashboard read model", () => { artifact_type: "skill_revision", object_sha256: releasedObject, revision: releasedRevision, - metadata: { updated_at: "2026-07-13T10:00:00.000Z" }, + metadata: { skill_name: 42, updated_at: "2026-07-13T10:00:00.000Z" }, }, { artifact_id: `draft/science-workflow/${draftRevision}`, artifact_type: "draft_revision", object_sha256: draftObject, revision: draftRevision, - metadata: { updated_at: "2026-07-14T09:00:00.000Z" }, + metadata: { skill_name: " ", updated_at: "2026-07-14T09:00:00.000Z" }, }, { artifact_id: `draft/draft-only/${draftOnlyRevision}`, @@ -287,11 +326,11 @@ describe("self-hosted remote dashboard read model", () => { const libraryResponse = await fetch(`${baseUrl}/api/v2/library`, { headers }); expect(libraryResponse.status).toBe(200); - expect(field(await libraryResponse.json(), "counts")).toEqual(library.counts); + expect(await libraryResponse.json()).toMatchObject({ counts: library.counts }); const skillSetsResponse = await fetch(`${baseUrl}/api/v2/skill-sets`, { headers }); expect(skillSetsResponse.status).toBe(200); - expect(field(await skillSetsResponse.json(), "sets")).toHaveLength(1); + expect(await skillSetsResponse.json()).toMatchObject({ sets: skillSets.sets }); const readOnlyMutation = await fetch(`${baseUrl}/api/v2/skill-sets`, { method: "POST", @@ -304,6 +343,6 @@ describe("self-hosted remote dashboard read model", () => { }); expect(readOnlyMutation.status).toBe(405); expect(readOnlyMutation.headers.get("allow")).toBe("GET"); - expect(field(field(await readOnlyMutation.json(), "error"), "code")).toBe("READ_ONLY_HOST"); + expect(await readOnlyMutation.json()).toMatchObject({ error: { code: "READ_ONLY_HOST" } }); }); }); diff --git a/apps/selfhost/src/remote-dashboard.ts b/apps/selfhost/src/remote-dashboard.ts index a5dfe190..5430cd9d 100644 --- a/apps/selfhost/src/remote-dashboard.ts +++ b/apps/selfhost/src/remote-dashboard.ts @@ -8,6 +8,7 @@ import { } from "@selftune/control-plane"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import type { SkillSetManifest, SkillSetsResponse } from "@selftune/runtime/dashboard-contract"; import type { SelfHostConfig } from "./config.js"; @@ -77,8 +78,10 @@ function metadataString( metadata: Readonly>, key: string, ): string | null { - const value = metadata[key]; - return typeof value === "string" && value.trim() ? value : null; + return Schema.decodeUnknownOption(Schema.String)(metadata[key]).pipe( + Option.filter((value) => value.trim().length > 0), + Option.getOrNull, + ); } function nameFromArtifactId(artifactId: string, type: "skill_revision" | "draft_revision"): string { @@ -273,11 +276,11 @@ export function makeRemoteDashboardLoaders( const loadHead = Effect.fn("RemoteDashboard.loadHead")(function* () { const response = yield* request("/api/v1/remote-library/snapshots/head"); const input = yield* Effect.tryPromise({ - try: (): Promise => response.json(), + try: () => response.text(), catch: (cause) => failure("decode_head", 502, cause instanceof Error ? cause.message : String(cause)), }); - return yield* Schema.decodeUnknownEffect(SnapshotEnvelope)(input).pipe( + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SnapshotEnvelope))(input).pipe( Effect.mapError((cause) => failure("decode_head", 502, cause.message)), ); }); @@ -313,18 +316,8 @@ export function makeRemoteDashboardLoaders( artifacts.filter((artifact) => artifact.artifact_type === "skill_set"), (artifact) => loadObject(artifact.object_sha256).pipe( - Effect.flatMap((bytes) => - Effect.try({ - try: (): unknown => JSON.parse(new TextDecoder().decode(bytes)), - catch: (cause) => - failure( - "decode_skill_set", - 422, - cause instanceof Error ? cause.message : String(cause), - ), - }), - ), - Effect.flatMap(Schema.decodeUnknownEffect(StoredSkillSet)), + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(StoredSkillSet))), Effect.mapError((cause) => cause instanceof RemoteDashboardFailure ? cause diff --git a/apps/selfhost/src/repository.ts b/apps/selfhost/src/repository.ts index 8dab4e28..cbfce92f 100644 --- a/apps/selfhost/src/repository.ts +++ b/apps/selfhost/src/repository.ts @@ -852,7 +852,7 @@ function audit( user: SelfHostUser, action: string, resourceId: string | null, - metadata: Readonly> = {}, + metadata: Readonly> = {}, ): void { db.run( `INSERT INTO remote_audit diff --git a/apps/selfhost/src/request-contracts.test.ts b/apps/selfhost/src/request-contracts.test.ts new file mode 100644 index 00000000..3de491ca --- /dev/null +++ b/apps/selfhost/src/request-contracts.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { loadSelfHostConfig } from "./config.js"; +import { makeRemoteApi, type RemoteApiHandle } from "./remote-api.js"; + +const token = "SELFHOST_REQUEST_CONTRACT_ADMIN_0001"; +const origin = "https://selftune.example.com"; +const roots: string[] = []; +const handles: RemoteApiHandle[] = []; + +afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.dispose())); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +async function api() { + const dataDir = mkdtempSync(join(tmpdir(), "selftune-selfhost-request-")); + roots.push(dataDir); + const config = await Effect.runPromise( + loadSelfHostConfig({ + SELFTUNE_AUTH_TOKEN: token, + SELFTUNE_PUBLIC_URL: origin, + SELFTUNE_DATA_DIR: dataDir, + }), + ); + const handle = makeRemoteApi(config); + handles.push(handle); + await handle.ready; + return handle; +} + +const routes = [ + { + path: "/api/v1/remote-library/snapshots", + code: "RemoteLibraryInvalidSnapshot", + message: "Invalid Remote Library snapshot", + }, + { + path: "/api/v1/remote-library/shares", + code: "RemoteLibraryInvalidShare", + message: "Invalid private share", + }, + { + path: "/api/v1/remote-library/packs", + code: "RemoteLibraryInvalidPack", + message: "Invalid Skill Set Pack request", + }, + { + path: "/api/v1/contributions/relay", + code: "ContributorSignalInvalid", + message: "Invalid contributor signal", + }, + { + path: "/api/v1/desktop/manifest", + code: "HostedManifestInvalid", + message: "Invalid Desktop manifest", + }, +]; + +describe("self-host request contracts", () => { + test.each( + routes.flatMap((route) => + ["{broken", "null", '{"private":"PRIVATE_PAYLOAD_MARKER"}'].map((body) => ({ + ...route, + body, + })), + ), + )( + "rejects malformed request bodies without reflecting their contents: %j", + async ({ path, code, message, body }) => { + const handle = await api(); + const response = await handle.handle( + new Request(`${origin}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Origin: origin, + "Content-Type": "application/json", + }, + body, + }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ error: { code, message } }); + const diagnostics = await handle.handle( + new Request(`${origin}/api/v1/remote-library/diagnostics`, { + headers: { Authorization: `Bearer ${token}`, Origin: origin }, + }), + ); + expect(diagnostics?.status).toBe(200); + expect(await diagnostics?.json()).toMatchObject({ object_count: 0, snapshot_count: 0 }); + }, + ); + + test.each(routes)("authenticates before decoding %j", async ({ path }) => { + const handle = await api(); + const response = await handle.handle( + new Request(`${origin}${path}`, { + method: "POST", + headers: { Origin: origin }, + body: "{broken", + }), + ); + expect(response?.status).toBe(401); + }); +}); diff --git a/apps/selfhost/src/server.ts b/apps/selfhost/src/server.ts index ae7389ca..37c61656 100644 --- a/apps/selfhost/src/server.ts +++ b/apps/selfhost/src/server.ts @@ -65,13 +65,13 @@ async function start(): Promise { } if (process.argv[2] === "healthcheck") { - await healthcheck().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + await healthcheck().catch((cause: unknown) => { + process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`); process.exit(1); }); } else { - await start().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + await start().catch((cause: unknown) => { + process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`); process.exit(1); }); } diff --git a/apps/use-once-helper/scripts/release-manifest.ts b/apps/use-once-helper/scripts/release-manifest.ts index c6e0f125..3b0ee7fc 100644 --- a/apps/use-once-helper/scripts/release-manifest.ts +++ b/apps/use-once-helper/scripts/release-manifest.ts @@ -1,5 +1,6 @@ import { readFile, rename, writeFile } from "node:fs/promises"; import { basename } from "node:path"; +import * as Schema from "effect/Schema"; import { createSignedHelperReleaseManifest } from "../src/release-manifest"; @@ -16,9 +17,9 @@ const privateKeyPath = option("--private-key"); const keyId = option("--key-id"); const outputPath = `${artifactPath}.manifest.json`; const temporaryOutput = `${outputPath}.tmp`; -const packageJson = (await Bun.file(new URL("../package.json", import.meta.url)).json()) as { - version: string; -}; +const packageJson = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), +)(await Bun.file(new URL("../package.json", import.meta.url)).text()); const manifest = createSignedHelperReleaseManifest({ version: packageJson.version, diff --git a/apps/use-once-helper/src/authority-contract.ts b/apps/use-once-helper/src/authority-contract.ts new file mode 100644 index 00000000..276664f4 --- /dev/null +++ b/apps/use-once-helper/src/authority-contract.ts @@ -0,0 +1,185 @@ +import * as Schema from "effect/Schema"; + +import { SUPPORTED_AGENTS } from "./contracts"; + +export const SupportedAgentSchema = Schema.Literals(SUPPORTED_AGENTS); +const Uuid = Schema.String.check( + Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i), +); +const Sha256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const Instant = Schema.String.check( + Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/), + Schema.makeFilter((value) => Number.isFinite(Date.parse(value)), { + expected: "a valid UTC instant", + }), +); +const boundedText = (maximum: number) => + Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(maximum)); +export const ContributorSignalFieldSchema = Schema.Literals(["trigger", "grade", "miss_category"]); +const AllowedSignals = Schema.Array(ContributorSignalFieldSchema).check(Schema.isMinLength(1)); +const SignalBase = { + signalDisclosureSha256: Sha256, + defaultState: Schema.Literal("off"), +}; +export const ContributorSignalsSchema = Schema.Union([ + Schema.Struct({ + ...SignalBase, + _tag: Schema.Literal("signals_unavailable"), + signalRecipientOrganizationId: Schema.Null, + allowedFields: Schema.Tuple([]), + capability: Schema.Literal("not_capable"), + contributorConsent: Schema.Literal("not_applicable"), + enabled: Schema.Literal(false), + }), + Schema.Struct({ + ...SignalBase, + _tag: Schema.Literal("capable_default_off"), + signalRecipientOrganizationId: Uuid, + allowedFields: AllowedSignals, + capability: Schema.Literal("capable"), + contributorConsent: Schema.Literal("not_granted"), + enabled: Schema.Literal(false), + }), + Schema.Struct({ + ...SignalBase, + _tag: Schema.Literal("capable_consented"), + signalRecipientOrganizationId: Uuid, + allowedFields: AllowedSignals, + capability: Schema.Literal("capable"), + contributorConsent: Schema.Literal("granted"), + enabled: Schema.Literal(true), + }), +]); +export const HelperContributorSignalsSchema = Schema.Union([ + Schema.Struct({ + ...SignalBase, + _tag: Schema.Literal("unavailable"), + allowedFields: Schema.Tuple([]), + trustedTelemetry: Schema.Literal("not_authorized"), + }), + Schema.Struct({ + ...SignalBase, + _tag: Schema.Literal("portable_unverified"), + allowedFields: AllowedSignals, + trustedTelemetry: Schema.Literal("not_authorized"), + }), +]); +const Lifecycle = Schema.Struct({ + _tag: Schema.Literal("used_once_status"), + lifecycleDisclosureSha256: Sha256, + consent: Schema.Literals(["not_granted", "granted"]), + senderVisibleUsedOnceStatus: Schema.Literals(["disabled", "enabled"]), +}).check( + Schema.makeFilter( + (value) => + value.senderVisibleUsedOnceStatus === (value.consent === "granted" ? "enabled" : "disabled"), + { expected: "consent-bound lifecycle reporting" }, + ), +); +const Binding = { + issueId: Uuid, + invitationId: Uuid, + shareId: Uuid, + distributionId: Uuid, + sealedObjectId: Uuid, + packagedSha256: Sha256, +}; +const Policy = { + persistence: Schema.Literal("ephemeral_use_once"), + persistentInstall: Schema.Literal("not_authorized"), + trustedTelemetry: Schema.Literal("not_authorized"), +}; +const License = Schema.Struct({ + expression: boundedText(1024), + kind: Schema.Literals(["spdx", "license_ref", "proprietary"]), + licenseEvidenceSha256: Sha256, + bundledTerms: Schema.NullOr(Schema.Struct({ path: boundedText(1024), sha256: Sha256 })), +}).check( + Schema.makeFilter((value) => value.kind === "spdx" || value.bundledTerms !== null, { + expected: "bundled non-SPDX terms", + }), +); +const ProvenanceText = Schema.NullOr(Schema.String.check(Schema.isMaxLength(2048))); + +export const UseOncePreviewSchema = Schema.Struct({ + ...Binding, + ...Policy, + status: Schema.Literal("preview"), + supportedAgent: SupportedAgentSchema, + issuedAt: Instant, + expiresAt: Instant, + publisher: Schema.Struct({ name: boundedText(512) }), + rightsHolder: Schema.Struct({ + kind: Schema.Literals(["organization", "user", "external"]), + name: boundedText(512), + }), + package: Schema.Struct({ + displayName: boundedText(512), + version: boundedText(128), + format: Schema.Literal("selftune-portable-package-v2"), + }), + license: License, + provenance: Schema.Struct({ + kind: Schema.Literals([ + "github_verified", + "selftune_authored", + "imported_upstream", + "self_attested_upload", + ]), + sourceRepository: ProvenanceText, + sourceRef: ProvenanceText, + sourceTreeHash: ProvenanceText, + }), + terms: Schema.Struct({ + disclosureSha256: Sha256, + summary: boundedText(4096), + issueAcceptance: Schema.Literal("accepted_at_issue"), + }), + contributorSignals: ContributorSignalsSchema, + lifecycleReporting: Lifecycle, + helperContributorSignals: HelperContributorSignalsSchema, + contentRetrieval: Schema.Literal("repeatable_exact_object_before_consume"), + previewMutation: Schema.Literal("none"), + usedOnceReporting: Schema.Literal("not_emitted"), + consumeRequired: Schema.Literal(true), + authorityLimits: Schema.Struct({ + localPath: Schema.Literal("not_provided"), + command: Schema.Literal("not_provided"), + url: Schema.Literal("not_provided"), + bytes: Schema.Literal("not_provided"), + credential: Schema.Literal("not_provided"), + installAuthority: Schema.Literal("not_authorized"), + }), +}); + +export const UseOnceConsumptionSchema = Schema.Struct({ + ...Binding, + ...Policy, + requestId: Uuid, + supportedAgent: SupportedAgentSchema, + termsDisclosureSha256: Sha256, + termsAcceptance: Schema.Literal("accepted"), + executionConsent: Schema.Literal("granted"), + status: Schema.Literal("consumed"), + consumedAt: Instant, + expiresAt: Instant, + lifecycleReporting: Lifecycle, + contributorSignals: ContributorSignalsSchema, + recipientAccess: Schema.Literals(["authenticated", "accountless"]), + accountlessPolicyResult: Schema.Literals(["authenticated_account", "public_allowed"]), +}).check( + Schema.makeFilter( + (value) => + value.accountlessPolicyResult === + (value.recipientAccess === "authenticated" ? "authenticated_account" : "public_allowed"), + { expected: "matching recipient access policy" }, + ), +); + +export const SealedObjectDeliverySchema = Schema.Struct({ + ...Binding, + contentType: Schema.Literal("application/vnd.selftune.portable-package+json"), + contentLength: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + contentSha256: Sha256, + bytes: Schema.Uint8Array, +}); diff --git a/apps/use-once-helper/src/contracts.ts b/apps/use-once-helper/src/contracts.ts index c95843f4..6ac4228e 100644 --- a/apps/use-once-helper/src/contracts.ts +++ b/apps/use-once-helper/src/contracts.ts @@ -1,3 +1,12 @@ +import type { + ContributorSignalFieldSchema, + ContributorSignalsSchema, + HelperContributorSignalsSchema, + UseOncePreviewSchema, + UseOnceConsumptionSchema, + SealedObjectDeliverySchema, +} from "./authority-contract"; + export const SUPPORTED_AGENTS = ["codex", "claude_code", "opencode", "openclaw", "pi"] as const; export type SupportedAgent = (typeof SUPPORTED_AGENTS)[number]; @@ -8,55 +17,9 @@ export const USE_ONCE_AUTHORITY_PATHS = { content: (issueId: string) => `/api/v1/recipient-actions/use-once/${issueId}/content`, } as const; -export type ContributorSignalField = "trigger" | "grade" | "miss_category"; - -export type ContributorSignals = - | { - readonly _tag: "signals_unavailable"; - readonly signalDisclosureSha256: string; - readonly signalRecipientOrganizationId: null; - readonly allowedFields: readonly []; - readonly capability: "not_capable"; - readonly defaultState: "off"; - readonly contributorConsent: "not_applicable"; - readonly enabled: false; - } - | { - readonly _tag: "capable_default_off"; - readonly signalDisclosureSha256: string; - readonly signalRecipientOrganizationId: string; - readonly allowedFields: readonly ContributorSignalField[]; - readonly capability: "capable"; - readonly defaultState: "off"; - readonly contributorConsent: "not_granted"; - readonly enabled: false; - } - | { - readonly _tag: "capable_consented"; - readonly signalDisclosureSha256: string; - readonly signalRecipientOrganizationId: string; - readonly allowedFields: readonly ContributorSignalField[]; - readonly capability: "capable"; - readonly defaultState: "off"; - readonly contributorConsent: "granted"; - readonly enabled: true; - }; - -export type HelperContributorSignals = - | { - readonly _tag: "unavailable"; - readonly signalDisclosureSha256: string; - readonly allowedFields: readonly []; - readonly defaultState: "off"; - readonly trustedTelemetry: "not_authorized"; - } - | { - readonly _tag: "portable_unverified"; - readonly signalDisclosureSha256: string; - readonly allowedFields: readonly ContributorSignalField[]; - readonly defaultState: "off"; - readonly trustedTelemetry: "not_authorized"; - }; +export type ContributorSignalField = typeof ContributorSignalFieldSchema.Type; +export type ContributorSignals = typeof ContributorSignalsSchema.Type; +export type HelperContributorSignals = typeof HelperContributorSignalsSchema.Type; export interface UseOnceBinding { readonly issueId: string; @@ -67,66 +30,7 @@ export interface UseOnceBinding { readonly packagedSha256: string; } -export interface UseOncePreview extends UseOnceBinding { - readonly status: "preview"; - readonly supportedAgent: SupportedAgent; - readonly issuedAt: string; - readonly expiresAt: string; - readonly publisher: { readonly name: string }; - readonly rightsHolder: { - readonly kind: "organization" | "user" | "external"; - readonly name: string; - }; - readonly package: { - readonly displayName: string; - readonly version: string; - readonly format: "selftune-portable-package-v2"; - }; - readonly license: { - readonly expression: string; - readonly kind: "spdx" | "license_ref" | "proprietary"; - readonly licenseEvidenceSha256: string; - readonly bundledTerms: null | { readonly path: string; readonly sha256: string }; - }; - readonly provenance: { - readonly kind: - | "github_verified" - | "selftune_authored" - | "imported_upstream" - | "self_attested_upload"; - readonly sourceRepository: string | null; - readonly sourceRef: string | null; - readonly sourceTreeHash: string | null; - }; - readonly terms: { - readonly disclosureSha256: string; - readonly summary: string; - readonly issueAcceptance: "accepted_at_issue"; - }; - readonly contributorSignals: ContributorSignals; - readonly lifecycleReporting: { - readonly _tag: "used_once_status"; - readonly lifecycleDisclosureSha256: string; - readonly consent: "not_granted" | "granted"; - readonly senderVisibleUsedOnceStatus: "disabled" | "enabled"; - }; - readonly helperContributorSignals: HelperContributorSignals; - readonly persistence: "ephemeral_use_once"; - readonly persistentInstall: "not_authorized"; - readonly trustedTelemetry: "not_authorized"; - readonly contentRetrieval: "repeatable_exact_object_before_consume"; - readonly previewMutation: "none"; - readonly usedOnceReporting: "not_emitted"; - readonly consumeRequired: true; - readonly authorityLimits: { - readonly localPath: "not_provided"; - readonly command: "not_provided"; - readonly url: "not_provided"; - readonly bytes: "not_provided"; - readonly credential: "not_provided"; - readonly installAuthority: "not_authorized"; - }; -} +export type UseOncePreview = typeof UseOncePreviewSchema.Type; export interface UseOnceConfirmation { readonly termsDisclosureSha256: string; @@ -143,30 +47,8 @@ export interface VerifiedUseOnceDisclosure { }; } -export interface UseOnceConsumption extends UseOnceBinding { - readonly requestId: string; - readonly supportedAgent: SupportedAgent; - readonly termsDisclosureSha256: string; - readonly termsAcceptance: "accepted"; - readonly executionConsent: "granted"; - readonly status: "consumed"; - readonly consumedAt: string; - readonly expiresAt: string; - readonly persistence: "ephemeral_use_once"; - readonly persistentInstall: "not_authorized"; - readonly trustedTelemetry: "not_authorized"; - readonly lifecycleReporting: UseOncePreview["lifecycleReporting"]; - readonly contributorSignals: ContributorSignals; - readonly recipientAccess: "authenticated" | "accountless"; - readonly accountlessPolicyResult: "authenticated_account" | "public_allowed"; -} - -export interface SealedObjectDelivery extends UseOnceBinding { - readonly contentType: "application/vnd.selftune.portable-package+json"; - readonly contentLength: number; - readonly contentSha256: string; - readonly bytes: Uint8Array; -} +export type UseOnceConsumption = typeof UseOnceConsumptionSchema.Type; +export type SealedObjectDelivery = typeof SealedObjectDeliverySchema.Type; /** * Required Cloud seam. Implementations must use fixed HTTPS endpoints and opaque diff --git a/apps/use-once-helper/src/http-authority.ts b/apps/use-once-helper/src/http-authority.ts index 93aab1e8..a0c06657 100644 --- a/apps/use-once-helper/src/http-authority.ts +++ b/apps/use-once-helper/src/http-authority.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import * as Schema from "effect/Schema"; import type { SealedObjectDelivery, @@ -121,7 +122,9 @@ async function readBoundedBody(response: Response, maximumBytes: number): Promis return bytes; } -async function readJson(response: Response): Promise { +const decodeResponseJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); + +async function readJson(response: Response): Promise { const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; if (!contentType.startsWith("application/json")) throw new UseOnceHelperError( @@ -130,7 +133,7 @@ async function readJson(response: Response): Promise { ); const bytes = await readBoundedBody(response, MAXIMUM_JSON_RESPONSE_BYTES); try { - return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + return decodeResponseJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch (cause) { throw new UseOnceHelperError( "INVALID_AUTHORITY_RESPONSE", @@ -183,7 +186,7 @@ async function fetchWithTimeout( } } -function jsonRequest(body: object): RequestInit { +function jsonRequest(body: Schema.Json): RequestInit { return { method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, @@ -294,7 +297,7 @@ export function makePinnedUseOnceAuthorityClient( packagedSha256: input.preview.packagedSha256, contentType: PACKAGE_CONTENT_TYPE, contentLength, - contentSha256: response.headers.get("x-selftune-content-sha256"), + contentSha256: response.headers.get("x-selftune-content-sha256") ?? "", bytes, }, input.preview, diff --git a/apps/use-once-helper/src/release-manifest.ts b/apps/use-once-helper/src/release-manifest.ts index 73700e2a..c82c78dd 100644 --- a/apps/use-once-helper/src/release-manifest.ts +++ b/apps/use-once-helper/src/release-manifest.ts @@ -51,7 +51,7 @@ export function createSignedHelperReleaseManifest(input: { readonly keyId: string; readonly privateKeyPem: string; }): SignedHelperReleaseManifest { - if (!(USE_ONCE_HELPER_RELEASE_TARGETS as readonly string[]).includes(input.target)) + if (!USE_ONCE_HELPER_RELEASE_TARGETS.some((target) => target === input.target)) throw new Error("Unsupported helper release target."); if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(input.version)) throw new Error("Helper release version must be SemVer."); @@ -93,7 +93,7 @@ export function verifySignedHelperReleaseManifest(input: { Object.keys(input.manifest.signature).toSorted().join(",") !== "algorithm,keyId,valueBase64url" || input.manifest.schemaVersion !== 1 || - !(USE_ONCE_HELPER_RELEASE_TARGETS as readonly string[]).includes(input.manifest.target) || + !USE_ONCE_HELPER_RELEASE_TARGETS.some((target) => target === input.manifest.target) || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(input.manifest.version) || !/^[A-Za-z0-9._-]+$/.test(input.manifest.artifactName) || !/^[A-Za-z0-9._-]{1,128}$/.test(input.manifest.signature.keyId) || diff --git a/apps/use-once-helper/src/validation.ts b/apps/use-once-helper/src/validation.ts index 53f42ac6..5b621ca0 100644 --- a/apps/use-once-helper/src/validation.ts +++ b/apps/use-once-helper/src/validation.ts @@ -1,29 +1,36 @@ import { createHash, timingSafeEqual } from "node:crypto"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + import { DISTRIBUTION_PACKAGE_BUNDLE_PROFILE } from "@selftune/control-plane/domain"; import type { - ContributorSignals, - HelperContributorSignals, SealedObjectDelivery, SupportedAgent, UseOnceBinding, UseOnceConsumption, UseOncePreview, } from "./contracts"; -import { SUPPORTED_AGENTS } from "./contracts"; +import { + SupportedAgentSchema, + UseOncePreviewSchema, + UseOnceConsumptionSchema, + SealedObjectDeliverySchema, +} from "./authority-contract"; import { UseOnceHelperError } from "./errors"; const TOKEN = /^[A-Za-z0-9_-]{43}$/; -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const SHA256 = /^[0-9a-f]{64}$/; -const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/; export const MAXIMUM_HELPER_PACKAGE_BYTES = DISTRIBUTION_PACKAGE_BUNDLE_PROFILE.maximumEncodedPackageBytes; -export function isSupportedAgent(value: string): value is SupportedAgent { - return (SUPPORTED_AGENTS as readonly string[]).includes(value); -} +export const isSupportedAgent = Schema.is(SupportedAgentSchema); + +const decodePreview = Schema.decodeUnknownOption(UseOncePreviewSchema); +const decodeConsumption = Schema.decodeUnknownOption(UseOnceConsumptionSchema); +const decodeDelivery = Schema.decodeUnknownOption(SealedObjectDeliverySchema); +const sameContributorSignals = Schema.toEquivalence(UseOncePreviewSchema.fields.contributorSignals); +const sameLifecycle = Schema.toEquivalence(UseOncePreviewSchema.fields.lifecycleReporting); export function validateHandoffToken(value: string): string { if (!TOKEN.test(value)) { @@ -35,293 +42,30 @@ export function validateHandoffToken(value: string): string { return value; } -function exactKeys(value: object, keys: readonly string[], subject: string): void { - const actual = Object.keys(value).toSorted(); - const expected = keys.toSorted(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { - throw new UseOnceHelperError( - "INVALID_AUTHORITY_RESPONSE", - `${subject} contains missing or unexpected fields.`, - ); - } -} - -function record(value: unknown, subject: string): Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", `${subject} must be an object.`); - } - return value as Record; -} - -function stringField(value: unknown, subject: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", `${subject} must be non-empty.`); - } - return value; -} - -function boundedStringField(value: unknown, subject: string, maximum: number): string { - const output = stringField(value, subject); - if (output.length > maximum) - throw new UseOnceHelperError( - "INVALID_AUTHORITY_RESPONSE", - `${subject} exceeds ${maximum} characters.`, - ); - return output; -} - -function validateContributorSignals(value: unknown): ContributorSignals { - const input = record(value, "Contributor signal disclosure"); - const keys = [ - "_tag", - "signalDisclosureSha256", - "signalRecipientOrganizationId", - "allowedFields", - "capability", - "defaultState", - "contributorConsent", - "enabled", - ]; - exactKeys(input, keys, "Signal disclosure"); - if (!SHA256.test(String(input.signalDisclosureSha256)) || input.defaultState !== "off") - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid signal disclosure."); - if (input._tag === "signals_unavailable") { - if ( - input.signalRecipientOrganizationId !== null || - input.capability !== "not_capable" || - input.contributorConsent !== "not_applicable" || - input.enabled !== false || - !Array.isArray(input.allowedFields) || - input.allowedFields.length !== 0 - ) { - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid unavailable signals."); - } - return input as unknown as ContributorSignals; - } - const allowed = new Set(["trigger", "grade", "miss_category"]); - if ( - (input._tag !== "capable_default_off" && input._tag !== "capable_consented") || - !UUID.test(String(input.signalRecipientOrganizationId)) || - input.capability !== "capable" || - input.contributorConsent !== (input._tag === "capable_consented" ? "granted" : "not_granted") || - input.enabled !== (input._tag === "capable_consented") || - !Array.isArray(input.allowedFields) || - input.allowedFields.length === 0 || - input.allowedFields.some((field) => !allowed.has(String(field))) - ) { - throw new UseOnceHelperError( - "INVALID_AUTHORITY_RESPONSE", - "Contributor signal disclosure is not consent-bound.", - ); - } - return input as unknown as ContributorSignals; -} - -function validateHelperContributorSignals(value: unknown): HelperContributorSignals { - const input = record(value, "Helper contributor signal disclosure"); - exactKeys( - input, - ["_tag", "signalDisclosureSha256", "allowedFields", "defaultState", "trustedTelemetry"], - "Helper contributor signal disclosure", - ); - const allowed = new Set(["trigger", "grade", "miss_category"]); - if ( - (input._tag !== "unavailable" && input._tag !== "portable_unverified") || - !SHA256.test(String(input.signalDisclosureSha256)) || - input.defaultState !== "off" || - input.trustedTelemetry !== "not_authorized" || - !Array.isArray(input.allowedFields) || - (input._tag === "unavailable" && input.allowedFields.length !== 0) || - (input._tag === "portable_unverified" && input.allowedFields.length === 0) || - input.allowedFields.some((field) => !allowed.has(String(field))) - ) - throw new UseOnceHelperError( - "INVALID_AUTHORITY_RESPONSE", - "Helper signals must be unavailable or portable_unverified.", - ); - return input as unknown as HelperContributorSignals; -} - -function validateBinding(input: Record): UseOnceBinding { - for (const key of ["issueId", "invitationId", "shareId", "distributionId", "sealedObjectId"]) { - if (!UUID.test(stringField(input[key], key))) { - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", `${key} must be a UUID.`); - } - } - if (!SHA256.test(stringField(input.packagedSha256, "packagedSha256"))) { - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "packagedSha256 must be SHA-256."); - } - return input as unknown as UseOnceBinding; -} - -function validateExpiry(value: unknown, now: Date): string { - const timestamp = stringField(value, "expiresAt"); - if (!ISO_INSTANT.test(timestamp) || !Number.isFinite(Date.parse(timestamp))) { - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "expiresAt must be a UTC instant."); - } +function validateExpiry(timestamp: string, now: Date): void { if (Date.parse(timestamp) <= now.getTime()) { throw new UseOnceHelperError("EXPIRED", "The use-once authority has expired."); } - return timestamp; -} - -function validateInstant(value: unknown, subject: string): string { - const timestamp = stringField(value, subject); - if (!ISO_INSTANT.test(timestamp) || !Number.isFinite(Date.parse(timestamp))) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", `${subject} must be a UTC instant.`); - return timestamp; } export function validatePreview( - value: unknown, + value: UseOncePreview | Schema.Json, expectedAgent: SupportedAgent, now: Date, ): UseOncePreview { - const input = record(value, "Use-once preview"); - exactKeys( - input, - [ - "issueId", - "invitationId", - "shareId", - "distributionId", - "sealedObjectId", - "packagedSha256", - "status", - "supportedAgent", - "issuedAt", - "expiresAt", - "publisher", - "rightsHolder", - "package", - "license", - "provenance", - "terms", - "contributorSignals", - "lifecycleReporting", - "helperContributorSignals", - "persistence", - "persistentInstall", - "trustedTelemetry", - "contentRetrieval", - "previewMutation", - "usedOnceReporting", - "consumeRequired", - "authorityLimits", - ], - "Use-once preview", - ); - validateBinding(input); - if (input.status !== "preview" || input.supportedAgent !== expectedAgent) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Agent binding does not match."); - validateInstant(input.issuedAt, "issuedAt"); - validateExpiry(input.expiresAt, now); - if ( - input.persistence !== "ephemeral_use_once" || - input.persistentInstall !== "not_authorized" || - input.trustedTelemetry !== "not_authorized" || - input.contentRetrieval !== "repeatable_exact_object_before_consume" || - input.previewMutation !== "none" || - input.usedOnceReporting !== "not_emitted" || - input.consumeRequired !== true - ) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Use-once policy was broadened."); - - const publisher = record(input.publisher, "Publisher"); - exactKeys(publisher, ["name"], "Publisher"); - boundedStringField(publisher.name, "publisher.name", 512); - - const rightsHolder = record(input.rightsHolder, "Rights holder"); - exactKeys(rightsHolder, ["kind", "name"], "Rights holder"); - if (!new Set(["organization", "user", "external"]).has(String(rightsHolder.kind))) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid rights holder kind."); - boundedStringField(rightsHolder.name, "rightsHolder.name", 512); - - const pkg = record(input.package, "Package"); - exactKeys(pkg, ["displayName", "version", "format"], "Package"); - boundedStringField(pkg.displayName, "package.displayName", 512); - boundedStringField(pkg.version, "package.version", 128); - if (pkg.format !== "selftune-portable-package-v2") - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Unexpected package format."); - - const license = record(input.license, "License"); - exactKeys(license, ["expression", "kind", "licenseEvidenceSha256", "bundledTerms"], "License"); - boundedStringField(license.expression, "license.expression", 1_024); - if ( - !new Set(["spdx", "license_ref", "proprietary"]).has(String(license.kind)) || - !SHA256.test(String(license.licenseEvidenceSha256)) - ) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid license evidence."); - if (license.bundledTerms !== null) { - const bundled = record(license.bundledTerms, "Bundled terms"); - exactKeys(bundled, ["path", "sha256"], "Bundled terms"); - boundedStringField(bundled.path, "license.bundledTerms.path", 1_024); - if (!SHA256.test(String(bundled.sha256))) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid bundled terms hash."); - } - if (license.kind !== "spdx" && license.bundledTerms === null) + const decoded = decodePreview(value, { onExcessProperty: "error" }); + if (Option.isNone(decoded)) { throw new UseOnceHelperError( "INVALID_AUTHORITY_RESPONSE", - "Non-SPDX terms must be bundled for interactive disclosure.", + "Use-once preview does not match the exact authority contract.", ); - - const provenance = record(input.provenance, "Provenance"); - exactKeys(provenance, ["kind", "sourceRepository", "sourceRef", "sourceTreeHash"], "Provenance"); - if ( - !new Set([ - "github_verified", - "selftune_authored", - "imported_upstream", - "self_attested_upload", - ]).has(String(provenance.kind)) || - ["sourceRepository", "sourceRef", "sourceTreeHash"].some((key) => { - const field = provenance[key]; - return field !== null && (typeof field !== "string" || field.length > 2_048); - }) - ) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid provenance evidence."); - - const terms = record(input.terms, "Terms"); - exactKeys(terms, ["disclosureSha256", "summary", "issueAcceptance"], "Terms"); - if (!SHA256.test(stringField(terms.disclosureSha256, "terms.disclosureSha256"))) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid terms disclosure hash."); - boundedStringField(terms.summary, "terms.summary", 4_096); - if (terms.issueAcceptance !== "accepted_at_issue") - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Terms were not accepted at issue."); - validateContributorSignals(input.contributorSignals); - validateHelperContributorSignals(input.helperContributorSignals); - - const lifecycle = record(input.lifecycleReporting, "Lifecycle disclosure"); - exactKeys( - lifecycle, - ["_tag", "lifecycleDisclosureSha256", "consent", "senderVisibleUsedOnceStatus"], - "Lifecycle disclosure", - ); - if ( - lifecycle._tag !== "used_once_status" || - !SHA256.test(String(lifecycle.lifecycleDisclosureSha256)) || - !["not_granted", "granted"].includes(String(lifecycle.consent)) || - lifecycle.senderVisibleUsedOnceStatus !== - (lifecycle.consent === "granted" ? "enabled" : "disabled") - ) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid lifecycle disclosure."); - - const limits = record(input.authorityLimits, "Authority limits"); - exactKeys( - limits, - ["localPath", "command", "url", "bytes", "credential", "installAuthority"], - "Authority limits", - ); - if ( - limits.localPath !== "not_provided" || - limits.command !== "not_provided" || - limits.url !== "not_provided" || - limits.bytes !== "not_provided" || - limits.credential !== "not_provided" || - limits.installAuthority !== "not_authorized" - ) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Preview granted extra authority."); - return input as unknown as UseOncePreview; + } + const input = decoded.value; + if (input.supportedAgent !== expectedAgent) { + throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Agent binding does not match."); + } + validateExpiry(input.expiresAt, now); + return input; } function sameBinding(left: UseOnceBinding, right: UseOnceBinding): boolean { @@ -336,106 +80,68 @@ function sameBinding(left: UseOnceBinding, right: UseOnceBinding): boolean { } export function validateConsumption( - value: unknown, + value: UseOnceConsumption | Schema.Json, preview: UseOncePreview, now: Date, ): UseOnceConsumption { - const input = record(value, "Use-once consumption"); - exactKeys( - input, - [ - "requestId", - "issueId", - "invitationId", - "shareId", - "distributionId", - "sealedObjectId", - "packagedSha256", - "supportedAgent", - "termsDisclosureSha256", - "termsAcceptance", - "executionConsent", - "status", - "consumedAt", - "expiresAt", - "persistence", - "persistentInstall", - "trustedTelemetry", - "lifecycleReporting", - "contributorSignals", - "recipientAccess", - "accountlessPolicyResult", - ], - "Use-once consumption", - ); - if (!UUID.test(stringField(input.requestId, "requestId"))) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "requestId must be a UUID."); - const binding = validateBinding(input); - if (!sameBinding(binding, preview)) + const decoded = decodeConsumption(value, { onExcessProperty: "error" }); + if (Option.isNone(decoded)) { + throw new UseOnceHelperError( + "INVALID_AUTHORITY_RESPONSE", + "Use-once consumption does not match the exact authority contract.", + ); + } + const input = decoded.value; + if (!sameBinding(input, preview)) { throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Consumption binding changed."); + } if ( input.supportedAgent !== preview.supportedAgent || - input.termsDisclosureSha256 !== preview.terms.disclosureSha256 || - input.termsAcceptance !== "accepted" || - input.executionConsent !== "granted" || - input.status !== "consumed" || - input.persistence !== "ephemeral_use_once" || - input.persistentInstall !== "not_authorized" || - input.trustedTelemetry !== "not_authorized" - ) + input.termsDisclosureSha256 !== preview.terms.disclosureSha256 + ) { throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Consumption policy changed."); - if ( - (input.recipientAccess !== "authenticated" && input.recipientAccess !== "accountless") || - input.accountlessPolicyResult !== - (input.recipientAccess === "authenticated" ? "authenticated_account" : "public_allowed") - ) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid recipient access result."); + } validateExpiry(input.expiresAt, now); - if (!ISO_INSTANT.test(stringField(input.consumedAt, "consumedAt"))) - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Invalid consumedAt."); - const signals = validateContributorSignals(input.contributorSignals); - if (JSON.stringify(signals) !== JSON.stringify(preview.contributorSignals)) + if (!sameContributorSignals(input.contributorSignals, preview.contributorSignals)) { throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Signal disclosure changed."); - if (JSON.stringify(input.lifecycleReporting) !== JSON.stringify(preview.lifecycleReporting)) + } + if (!sameLifecycle(input.lifecycleReporting, preview.lifecycleReporting)) { throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Lifecycle disclosure changed."); - return input as unknown as UseOnceConsumption; + } + return input; } -export function validateDelivery(value: unknown, preview: UseOncePreview): SealedObjectDelivery { - const input = record(value, "Sealed object delivery"); - exactKeys( - input, - [ - "issueId", - "invitationId", - "shareId", - "distributionId", - "sealedObjectId", - "packagedSha256", - "contentType", - "contentLength", - "contentSha256", - "bytes", - ], - "Sealed object delivery", - ); - const binding = validateBinding(input); - if (!sameBinding(binding, preview)) +export function validateDelivery( + value: SealedObjectDelivery, + preview: UseOncePreview, +): SealedObjectDelivery { + const decoded = decodeDelivery(value, { onExcessProperty: "error" }); + if (Option.isNone(decoded)) { + throw new UseOnceHelperError( + "INVALID_AUTHORITY_RESPONSE", + "Sealed object delivery does not match the exact authority contract.", + ); + } + const input = decoded.value; + if (!sameBinding(input, preview)) { throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Delivery binding changed."); - if (input.contentType !== "application/vnd.selftune.portable-package+json") - throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Unexpected delivery content type."); - if (!(input.bytes instanceof Uint8Array) || input.contentLength !== input.bytes.byteLength) + } + if (input.contentLength !== input.bytes.byteLength) { throw new UseOnceHelperError( "INVALID_AUTHORITY_RESPONSE", "Delivery length does not match bytes.", ); - if (input.bytes.byteLength > MAXIMUM_HELPER_PACKAGE_BYTES) + } + if (input.bytes.byteLength > MAXIMUM_HELPER_PACKAGE_BYTES) { throw new UseOnceHelperError("PACKAGE_INVALID", "Sealed package exceeds the 25 MiB limit."); - if (input.contentSha256 !== preview.packagedSha256) + } + if (input.contentSha256 !== preview.packagedSha256) { throw new UseOnceHelperError("INVALID_AUTHORITY_RESPONSE", "Delivery hash header changed."); + } const actual = createHash("sha256").update(input.bytes).digest(); const expected = Buffer.from(preview.packagedSha256, "hex"); - if (expected.length !== actual.length || !timingSafeEqual(actual, expected)) + if (expected.length !== actual.length || !timingSafeEqual(actual, expected)) { throw new UseOnceHelperError("PACKAGE_HASH_MISMATCH", "Sealed package hash does not match."); - return input as unknown as SealedObjectDelivery; + } + return input; } diff --git a/apps/use-once-helper/src/workspace.ts b/apps/use-once-helper/src/workspace.ts index 545cacfa..36156f1c 100644 --- a/apps/use-once-helper/src/workspace.ts +++ b/apps/use-once-helper/src/workspace.ts @@ -16,6 +16,7 @@ import { import { constants } from "node:fs"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { tmpdir } from "node:os"; +import * as Schema from "effect/Schema"; import type { StagedUseOnceWorkspace, UseOnceWorkspacePort } from "./contracts"; import { UseOnceHelperError } from "./errors"; @@ -32,104 +33,54 @@ export const WORKSPACE_HEARTBEAT_INTERVAL_MS = 10_000; export const WORKSPACE_LEASE_ABANDONMENT_MS = 45_000; export const WORKSPACE_RECOVERY_OBSERVATION_MS = 20_000; -interface Marker { - readonly schemaVersion: 1; - readonly instanceId: string; - readonly createdAt: string; - readonly expiresAt: string; -} - -interface LiveLease { - readonly schemaVersion: 1; - readonly instanceId: string; - readonly leaseId: string; - readonly sequence: number; - readonly heartbeatAt: string; -} - -interface RecoveryClaim { - readonly schemaVersion: 1; - readonly recoveryId: string; - readonly instanceId: string; - readonly leaseId: string; - readonly sequence: number; - readonly heartbeatAt: string; - readonly observedAt: string; - readonly expiresAt: string; -} +const WorkspaceId = Schema.String.check(Schema.isPattern(/^[0-9a-f-]{36}$/i)); +const WorkspaceInstant = Schema.String.check( + Schema.makeFilter((value) => Number.isFinite(Date.parse(value)), { + expected: "a valid workspace timestamp", + }), +); +const MarkerSchema = Schema.Struct({ + schemaVersion: Schema.Literal(MARKER_VERSION), + instanceId: WorkspaceId, + createdAt: WorkspaceInstant, + expiresAt: WorkspaceInstant, +}); +const LiveLeaseSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + instanceId: WorkspaceId, + leaseId: WorkspaceId, + sequence: Schema.Number.check( + Schema.makeFilter((value) => Number.isSafeInteger(value) && value >= 0, { + expected: "a nonnegative safe sequence", + }), + ), + heartbeatAt: WorkspaceInstant, +}); +const RecoveryClaimSchema = Schema.Struct({ + ...LiveLeaseSchema.fields, + recoveryId: WorkspaceId, + observedAt: WorkspaceInstant, + expiresAt: WorkspaceInstant, +}); +type Marker = typeof MarkerSchema.Type; +type LiveLease = typeof LiveLeaseSchema.Type; +type RecoveryClaim = typeof RecoveryClaimSchema.Type; export interface UseOnceWorkspaceTiming { readonly now: () => Date; readonly heartbeatIntervalMs: number; readonly leaseAbandonmentMs: number; readonly recoveryObservationMs: number; - readonly setInterval: (callback: () => void | Promise, milliseconds: number) => unknown; - readonly clearInterval: (handle: unknown) => void; + readonly startInterval: ( + callback: () => void | Promise, + milliseconds: number, + ) => () => void; readonly sleep: (milliseconds: number) => Promise; } -function ownedMarker(value: unknown): value is Marker { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const input = value as Record; - return ( - Object.keys(input).toSorted().join(",") === "createdAt,expiresAt,instanceId,schemaVersion" && - input.schemaVersion === MARKER_VERSION && - typeof input.instanceId === "string" && - /^[0-9a-f-]{36}$/i.test(input.instanceId) && - typeof input.createdAt === "string" && - Number.isFinite(Date.parse(input.createdAt)) && - typeof input.expiresAt === "string" && - Number.isFinite(Date.parse(input.expiresAt)) - ); -} - -function liveLease(value: unknown): value is LiveLease { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const input = value as Record; - return ( - Object.keys(input).toSorted().join(",") === - "heartbeatAt,instanceId,leaseId,schemaVersion,sequence" && - input.schemaVersion === 1 && - typeof input.instanceId === "string" && - /^[0-9a-f-]{36}$/i.test(input.instanceId) && - typeof input.leaseId === "string" && - /^[0-9a-f-]{36}$/i.test(input.leaseId) && - typeof input.sequence === "number" && - Number.isSafeInteger(input.sequence) && - input.sequence >= 0 && - typeof input.heartbeatAt === "string" && - Number.isFinite(Date.parse(input.heartbeatAt)) - ); -} - -function recoveryClaim(value: unknown): value is RecoveryClaim { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const input = value as Record; - return ( - Object.keys(input).toSorted().join(",") === - "expiresAt,heartbeatAt,instanceId,leaseId,observedAt,recoveryId,schemaVersion,sequence" && - input.schemaVersion === 1 && - typeof input.recoveryId === "string" && - /^[0-9a-f-]{36}$/i.test(input.recoveryId) && - typeof input.instanceId === "string" && - /^[0-9a-f-]{36}$/i.test(input.instanceId) && - typeof input.leaseId === "string" && - /^[0-9a-f-]{36}$/i.test(input.leaseId) && - typeof input.sequence === "number" && - Number.isSafeInteger(input.sequence) && - input.sequence >= 0 && - typeof input.heartbeatAt === "string" && - Number.isFinite(Date.parse(input.heartbeatAt)) && - typeof input.observedAt === "string" && - Number.isFinite(Date.parse(input.observedAt)) && - typeof input.expiresAt === "string" && - Number.isFinite(Date.parse(input.expiresAt)) - ); -} - async function isCurrentUser(path: string): Promise { - if (typeof process.getuid !== "function") return true; - return (await stat(path)).uid === process.getuid(); + const uid = process.getuid?.(); + return uid === undefined || (await stat(path)).uid === uid; } async function assertContainedDirectory(root: string, candidate: string): Promise { @@ -151,11 +102,13 @@ async function assertContainedDirectory(root: string, candidate: string): Promis return canonicalCandidate; } -async function readOwnedJson(path: string): Promise { +async function readOwnedJson(path: string, schema: Schema.Decoder): Promise { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || !(await isCurrentUser(path))) throw new UseOnceHelperError("WORKSPACE_UNSAFE", "Workspace authority file is unsafe."); - return JSON.parse(await readFile(path, "utf8")) as unknown; + return Schema.decodeUnknownSync(Schema.fromJsonString(schema))(await readFile(path, "utf8"), { + onExcessProperty: "error", + }); } async function safeCleanup( @@ -173,17 +126,18 @@ async function safeCleanup( if (!basename(directory).startsWith(DIRECTORY_PREFIX)) return false; const canonical = await assertContainedDirectory(tempRoot, directory); const markerPath = join(canonical, MARKER); - const marker = await readOwnedJson(markerPath).catch(() => null); - if (!ownedMarker(marker) || marker.instanceId !== instanceId) return false; - const lease = await readOwnedJson(join(canonical, LEASE)).catch(() => null); - if (!liveLease(lease) || lease.instanceId !== instanceId || lease.leaseId !== leaseId) - return false; + const marker = await readOwnedJson(markerPath, MarkerSchema).catch(() => null); + if (marker === null || marker.instanceId !== instanceId) return false; + const lease = await readOwnedJson(join(canonical, LEASE), LiveLeaseSchema).catch(() => null); + if (lease === null || lease.instanceId !== instanceId || lease.leaseId !== leaseId) return false; if (recovery !== undefined) { if (lease.sequence !== recovery.sequence || lease.heartbeatAt !== recovery.heartbeatAt) return false; - const claim = await readOwnedJson(join(canonical, RECOVERY_CLAIM)).catch(() => null); + const claim = await readOwnedJson(join(canonical, RECOVERY_CLAIM), RecoveryClaimSchema).catch( + () => null, + ); if ( - !recoveryClaim(claim) || + claim === null || claim.recoveryId !== recovery.recoveryId || claim.instanceId !== instanceId || claim.leaseId !== leaseId || @@ -233,8 +187,7 @@ export function makeOsUseOnceWorkspace(options?: { readonly heartbeatIntervalMs?: number; readonly leaseAbandonmentMs?: number; readonly recoveryObservationMs?: number; - readonly setInterval?: UseOnceWorkspaceTiming["setInterval"]; - readonly clearInterval?: UseOnceWorkspaceTiming["clearInterval"]; + readonly startInterval?: UseOnceWorkspaceTiming["startInterval"]; readonly sleep?: UseOnceWorkspaceTiming["sleep"]; readonly beforeRecoveryDelete?: () => Promise; }): UseOnceWorkspacePort { @@ -245,16 +198,13 @@ export function makeOsUseOnceWorkspace(options?: { heartbeatIntervalMs: options?.heartbeatIntervalMs ?? WORKSPACE_HEARTBEAT_INTERVAL_MS, leaseAbandonmentMs: options?.leaseAbandonmentMs ?? WORKSPACE_LEASE_ABANDONMENT_MS, recoveryObservationMs: options?.recoveryObservationMs ?? WORKSPACE_RECOVERY_OBSERVATION_MS, - setInterval: - options?.setInterval ?? + startInterval: + options?.startInterval ?? ((callback, milliseconds) => { const handle = globalThis.setInterval(callback, milliseconds); handle.unref(); - return handle; + return () => globalThis.clearInterval(handle); }), - clearInterval: - options?.clearInterval ?? - ((handle) => globalThis.clearInterval(handle as ReturnType)), sleep: options?.sleep ?? ((milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))), @@ -267,17 +217,15 @@ export function makeOsUseOnceWorkspace(options?: { const directory = join(temporaryRoot, entry.name); try { const canonical = await assertContainedDirectory(temporaryRoot, directory); - const marker = await readOwnedJson(join(canonical, MARKER)); - if (!ownedMarker(marker)) continue; + const marker = await readOwnedJson(join(canonical, MARKER), MarkerSchema); const createdAt = Date.parse(marker.createdAt); if ( now().getTime() - createdAt < STALE_WORKSPACE_TTL_MS || Date.parse(marker.expiresAt) > now().getTime() ) continue; - const observedLease = await readOwnedJson(join(canonical, LEASE)); + const observedLease = await readOwnedJson(join(canonical, LEASE), LiveLeaseSchema); if ( - !liveLease(observedLease) || observedLease.instanceId !== marker.instanceId || now().getTime() - Date.parse(observedLease.heartbeatAt) < timing.leaseAbandonmentMs ) @@ -300,9 +248,8 @@ export function makeOsUseOnceWorkspace(options?: { await writeExclusive(claimPath, new TextEncoder().encode(JSON.stringify(claim)), 0o600); try { await timing.sleep(timing.recoveryObservationMs); - const confirmedLease = await readOwnedJson(join(canonical, LEASE)); + const confirmedLease = await readOwnedJson(join(canonical, LEASE), LiveLeaseSchema); if ( - !liveLease(confirmedLease) || confirmedLease.instanceId !== observedLease.instanceId || confirmedLease.leaseId !== observedLease.leaseId || confirmedLease.sequence !== observedLease.sequence || @@ -338,7 +285,7 @@ export function makeOsUseOnceWorkspace(options?: { expiresAt: new Date(createdAt.getTime() + STALE_WORKSPACE_TTL_MS).toISOString(), }; let sequence = 0; - let heartbeatHandle: unknown; + let stopHeartbeat: (() => void) | undefined; let heartbeatPromise: Promise | null = null; const leasePath = join(directory, LEASE); const leaseBytes = (): Uint8Array => @@ -378,7 +325,7 @@ export function makeOsUseOnceWorkspace(options?: { 0o600, ); await writeExclusive(leasePath, leaseBytes(), 0o600); - heartbeatHandle = timing.setInterval(requestHeartbeat, timing.heartbeatIntervalMs); + stopHeartbeat = timing.startInterval(requestHeartbeat, timing.heartbeatIntervalMs); const skillDirectory = join(directory, "skill"); await mkdir(skillDirectory, { mode: 0o700 }); for (const file of input.files) { @@ -399,7 +346,7 @@ export function makeOsUseOnceWorkspace(options?: { skillDirectory, async cleanup() { if (cleaned) return; - timing.clearInterval(heartbeatHandle); + stopHeartbeat?.(); await heartbeatPromise; await safeCleanup(temporaryRoot, directory, instanceId, leaseId); cleaned = true; @@ -407,7 +354,7 @@ export function makeOsUseOnceWorkspace(options?: { }; return staged; } catch (cause) { - if (heartbeatHandle !== undefined) timing.clearInterval(heartbeatHandle); + stopHeartbeat?.(); await heartbeatPromise; await safeCleanup(temporaryRoot, directory, instanceId, leaseId).catch(() => undefined); if (cause instanceof UseOnceHelperError) throw cause; diff --git a/apps/use-once-helper/tests/http-authority.test.ts b/apps/use-once-helper/tests/http-authority.test.ts index 115a312c..116e4bf1 100644 --- a/apps/use-once-helper/tests/http-authority.test.ts +++ b/apps/use-once-helper/tests/http-authority.test.ts @@ -158,6 +158,100 @@ function client(fetch: NonNullable } describe("pinned HTTPS use-once authority", () => { + test("rejects malformed nested fields and authority expansion before preview", async () => { + const { preview } = fixture(); + for (const value of [ + null, + [], + { ...preview, publisher: { ...preview.publisher, command: "run me" } }, + { ...preview, rightsHolder: { ...preview.rightsHolder, name: 42 } }, + { ...preview, license: { ...preview.license, kind: "proprietary", bundledTerms: null } }, + { ...preview, contributorSignals: { ...preview.contributorSignals, enabled: true } }, + { + ...preview, + helperContributorSignals: { + ...preview.helperContributorSignals, + allowedFields: ["trigger"], + }, + }, + { + ...preview, + lifecycleReporting: { + ...preview.lifecycleReporting, + senderVisibleUsedOnceStatus: "enabled", + }, + }, + { ...preview, authorityLimits: { ...preview.authorityLimits, command: "allowed" } }, + { ...preview, provenance: { ...preview.provenance, sourceRef: 42 } }, + ]) { + const authority = client(async () => Response.json(value)); + await expect( + authority.preview({ handoffToken: TOKEN, supportedAgent: "codex" }), + ).rejects.toMatchObject({ code: "INVALID_AUTHORITY_RESPONSE" }); + } + }); + + test("compares disclosure fields independently of JSON property order", async () => { + const { preview, consumption } = fixture(); + const authority = client(async () => + Response.json({ + ...consumption, + contributorSignals: Object.fromEntries( + Object.entries(consumption.contributorSignals).toReversed(), + ), + lifecycleReporting: Object.fromEntries( + Object.entries(consumption.lifecycleReporting).toReversed(), + ), + }), + ); + await expect( + authority.consume({ + handoffToken: TOKEN, + preview, + confirmation: { + termsDisclosureSha256: preview.terms.disclosureSha256, + termsAcceptance: "accepted", + executionConsent: "granted", + }, + }), + ).resolves.toEqual(consumption); + }); + + test("rejects invalid consumption instants and changed disclosure fields", async () => { + const { preview, consumption } = fixture(); + for (const value of [ + { ...consumption, consumedAt: "2026-99-99T00:00:00Z" }, + { + ...consumption, + contributorSignals: { + ...consumption.contributorSignals, + signalDisclosureSha256: "0".repeat(64), + }, + }, + { + ...consumption, + lifecycleReporting: { + ...consumption.lifecycleReporting, + lifecycleDisclosureSha256: "0".repeat(64), + }, + }, + { ...consumption, lifecycleReporting: { ...consumption.lifecycleReporting, extra: true } }, + ]) { + const authority = client(async () => Response.json(value)); + await expect( + authority.consume({ + handoffToken: TOKEN, + preview, + confirmation: { + termsDisclosureSha256: preview.terms.disclosureSha256, + termsAcceptance: "accepted", + executionConsent: "granted", + }, + }), + ).rejects.toMatchObject({ code: "INVALID_AUTHORITY_RESPONSE" }); + } + }); + test("uses only fixed requests and exact preview-derived consume fields", async () => { const { bytes, preview, consumption } = fixture(); const requests: Array<{ url: string; init: RequestInit }> = []; diff --git a/apps/use-once-helper/tests/release-manifest.test.ts b/apps/use-once-helper/tests/release-manifest.test.ts index 055c33f4..e2cc8258 100644 --- a/apps/use-once-helper/tests/release-manifest.test.ts +++ b/apps/use-once-helper/tests/release-manifest.test.ts @@ -1,42 +1,113 @@ -import { generateKeyPairSync } from "node:crypto"; +import { createHash, generateKeyPairSync } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as Schema from "effect/Schema"; import { describe, expect, test } from "bun:test"; import { createSignedHelperReleaseManifest, USE_ONCE_HELPER_PACKAGE_ID, + USE_ONCE_HELPER_RELEASE_TARGETS, verifySignedHelperReleaseManifest, } from "../src"; describe("pinnable helper release manifest", () => { - test("binds package identity, target, checksum, and an Ed25519 signature", () => { + test("the release script writes a manifest beside the artifact with no temporary file left", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-helper-manifest-")); + const artifactPath = join(root, "helper"); + const privateKeyPath = join(root, "test-key.pem"); const keys = generateKeyPairSync("ed25519"); - const privateKeyPem = keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); - const publicKeyPem = keys.publicKey.export({ type: "spki", format: "pem" }).toString(); - const artifact = new TextEncoder().encode("compiled helper"); - const manifest = createSignedHelperReleaseManifest({ - version: "0.1.0", - target: "bun-darwin-arm64", - artifactName: "selftune-use-once", - artifact, - keyId: "release-2026-01", - privateKeyPem, + const artifact = "compiled test helper"; + writeFileSync(artifactPath, artifact); + writeFileSync(privateKeyPath, keys.privateKey.export({ type: "pkcs8", format: "pem" }), { + mode: 0o600, }); - expect(manifest.packageId).toBe(USE_ONCE_HELPER_PACKAGE_ID); - expect(verifySignedHelperReleaseManifest({ manifest, artifact, publicKeyPem })).toBe(true); - expect( - verifySignedHelperReleaseManifest({ - manifest, - artifact: new TextEncoder().encode("tampered"), - publicKeyPem, - }), - ).toBe(false); - expect( - verifySignedHelperReleaseManifest({ - manifest: { ...manifest, url: "https://attacker.test/helper" } as typeof manifest, - artifact, - publicKeyPem, - }), - ).toBe(false); + try { + const result = Bun.spawnSync( + [ + process.execPath, + fileURLToPath(new URL("../scripts/release-manifest.ts", import.meta.url)), + "--artifact", + artifactPath, + "--target", + "bun-darwin-arm64", + "--private-key", + privateKeyPath, + "--key-id", + "test-release", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); + expect(existsSync(`${artifactPath}.manifest.json.tmp`)).toBe(false); + const manifest = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))( + readFileSync(`${artifactPath}.manifest.json`, "utf8"), + ); + expect(manifest).toMatchObject({ + schemaVersion: 1, + packageId: USE_ONCE_HELPER_PACKAGE_ID, + target: "bun-darwin-arm64", + artifactName: "helper", + artifactBytes: Buffer.byteLength(artifact), + artifactSha256: createHash("sha256").update(artifact).digest("hex"), + signature: { algorithm: "Ed25519", keyId: "test-release" }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); + test.each([...USE_ONCE_HELPER_RELEASE_TARGETS])( + "binds package identity, checksum, and an Ed25519 signature for %s", + (target) => { + const keys = generateKeyPairSync("ed25519"); + const privateKeyPem = keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyPem = keys.publicKey.export({ type: "spki", format: "pem" }).toString(); + const artifact = new TextEncoder().encode("compiled helper"); + const manifest = createSignedHelperReleaseManifest({ + version: "0.1.0", + target, + artifactName: "selftune-use-once", + artifact, + keyId: "release-2026-01", + privateKeyPem, + }); + expect(manifest.packageId).toBe(USE_ONCE_HELPER_PACKAGE_ID); + expect(verifySignedHelperReleaseManifest({ manifest, artifact, publicKeyPem })).toBe(true); + expect( + verifySignedHelperReleaseManifest({ + manifest, + artifact: new TextEncoder().encode("tampered"), + publicKeyPem, + }), + ).toBe(false); + const extraFieldManifest = { ...manifest, url: "https://attacker.test/helper" }; + expect( + verifySignedHelperReleaseManifest({ + manifest: extraFieldManifest, + artifact, + publicKeyPem, + }), + ).toBe(false); + expect( + verifySignedHelperReleaseManifest({ + manifest: { ...manifest, target: "unsupported" }, + artifact, + publicKeyPem, + }), + ).toBe(false); + expect(() => + createSignedHelperReleaseManifest({ + version: "0.1.0", + target: "unsupported", + artifactName: "selftune-use-once", + artifact, + keyId: "release-2026-01", + privateKeyPem, + }), + ).toThrow("Unsupported helper release target"); + }, + ); }); diff --git a/apps/use-once-helper/tests/workflow.test.ts b/apps/use-once-helper/tests/workflow.test.ts index 91e5b83f..71a53517 100644 --- a/apps/use-once-helper/tests/workflow.test.ts +++ b/apps/use-once-helper/tests/workflow.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { Json } from "effect/Schema"; import { describe, expect, test } from "bun:test"; import { @@ -10,13 +11,18 @@ import type { AgentExecutionPort, DisclosurePort, StagedUseOnceWorkspace, - UseOnceAuthorityClient, UseOnceBinding, UseOnceConsumption, UseOncePreview, UseOnceWorkspacePort, } from "../src"; -import { MAXIMUM_HELPER_PACKAGE_BYTES, runUseOnce, UseOnceHelperError } from "../src"; +import { + MAXIMUM_HELPER_PACKAGE_BYTES, + makePinnedUseOnceAuthorityClient, + runUseOnce, + USE_ONCE_AUTHORITY_PATHS, + UseOnceHelperError, +} from "../src"; const TOKEN = "u".repeat(43); const NOW = new Date("2026-07-21T00:00:00.000Z"); @@ -133,39 +139,57 @@ function fixture() { } function harness(overrides?: { - readonly preview?: unknown; - readonly consumption?: unknown; + readonly preview?: Json; + readonly consumption?: Json; + readonly contentType?: string; readonly bytes?: Uint8Array; readonly confirmation?: null | "accepted"; readonly execute?: AgentExecutionPort["execute"]; }) { const data = fixture(); const events: string[] = []; - const authority: UseOnceAuthorityClient = { - async preview() { - events.push("preview"); - return (overrides?.preview ?? data.preview) as UseOncePreview; - }, - async consume() { - events.push("consume"); - return (overrides?.consumption ?? data.consumption) as UseOnceConsumption; - }, - async retrievePreviewObject(input) { + let inspectedPreview = data.preview; + const authority = makePinnedUseOnceAuthorityClient({ + now: () => NOW, + requestId: () => data.consumption.requestId, + async fetch(input) { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (pathname === USE_ONCE_AUTHORITY_PATHS.preview) { + events.push("preview"); + return Response.json(overrides?.preview ?? data.preview); + } + if (pathname === USE_ONCE_AUTHORITY_PATHS.consume) { + events.push("consume"); + return Response.json(overrides?.consumption ?? data.consumption); + } + if (pathname !== USE_ONCE_AUTHORITY_PATHS.content(inspectedPreview.issueId)) { + throw new Error(`Unexpected authority request: ${pathname}`); + } events.push("retrieve"); const bytes = overrides?.bytes ?? data.bytes; - return { - issueId: input.preview.issueId, - invitationId: input.preview.invitationId, - shareId: input.preview.shareId, - distributionId: input.preview.distributionId, - sealedObjectId: input.preview.sealedObjectId, - packagedSha256: input.preview.packagedSha256, - contentType: "application/vnd.selftune.portable-package+json", - contentLength: bytes.byteLength, - contentSha256: input.preview.packagedSha256, - bytes, - }; + return new Response(Uint8Array.from(bytes).buffer, { + headers: { + "cache-control": "no-store, private", + pragma: "no-cache", + "content-type": + overrides?.contentType ?? "application/vnd.selftune.portable-package+json", + "content-length": String(bytes.byteLength), + etag: `"${inspectedPreview.packagedSha256}"`, + "x-selftune-content-sha256": inspectedPreview.packagedSha256, + "x-selftune-use-once-issue-id": inspectedPreview.issueId, + "x-selftune-invitation-id": inspectedPreview.invitationId, + "x-selftune-share-id": inspectedPreview.shareId, + "x-selftune-distribution-id": inspectedPreview.distributionId, + "x-selftune-sealed-object-id": inspectedPreview.sealedObjectId, + "x-selftune-supported-agent": inspectedPreview.supportedAgent, + }, + }); }, + }); + const requestPreview = authority.preview.bind(authority); + authority.preview = async (input) => { + inspectedPreview = await requestPreview(input); + return inspectedPreview; }; const disclosure: DisclosurePort = { async show({ preview, bundledTerms }) { @@ -263,13 +287,7 @@ describe("use-once workflow", () => { }); test("requires the canonical portable-package media type while V2 stays decoder-enforced", async () => { - const h = harness(); - const retrieve = h.authority.retrievePreviewObject.bind(h.authority); - h.authority.retrievePreviewObject = async (input) => - ({ - ...(await retrieve(input)), - contentType: "application/vnd.selftune.portable-package-v2+json", - }) as unknown as Awaited>; + const h = harness({ contentType: "application/vnd.selftune.portable-package-v2+json" }); await expect( runUseOnce({ handoffToken: TOKEN, supportedAgent: "codex" }, { ...h, now: () => NOW }), ).rejects.toMatchObject({ code: "INVALID_AUTHORITY_RESPONSE" }); diff --git a/apps/use-once-helper/tests/workspace.test.ts b/apps/use-once-helper/tests/workspace.test.ts index d25133c7..493e1c65 100644 --- a/apps/use-once-helper/tests/workspace.test.ts +++ b/apps/use-once-helper/tests/workspace.test.ts @@ -16,11 +16,12 @@ function manualTiming(current: () => Date) { heartbeatIntervalMs: 10, leaseAbandonmentMs: 30, recoveryObservationMs: 0, - setInterval(callback: () => void | Promise) { + startInterval(callback: () => void | Promise) { heartbeat = callback; - return callback; + return () => { + heartbeat = undefined; + }; }, - clearInterval: () => undefined, sleep: async () => undefined, }, heartbeat: async () => { @@ -34,6 +35,80 @@ afterEach(async () => { }); describe("owned temporary use-once workspace", () => { + test("refuses recovery when ownership or lease fields are malformed or expanded", async () => { + const cases = [ + { + name: ".selftune-use-once-owned.json", + alter: (json: string) => json.replace(/}$/, ',"unexpected":true}'), + }, + { + name: ".selftune-use-once-live-lease.json", + alter: (json: string) => json.replace(/}$/, ',"unexpected":true}'), + }, + { + name: ".selftune-use-once-live-lease.json", + alter: (json: string) => json.replace('"sequence":0', '"sequence":"0"'), + }, + { name: ".selftune-use-once-owned.json", alter: () => "[]" }, + ]; + for (const entry of cases) { + const root = await mkdtemp(join(tmpdir(), "selftune-helper-test-")); + roots.push(root); + let current = new Date("2026-07-21T00:00:00.000Z"); + const timing = manualTiming(() => current); + const workspace = makeOsUseOnceWorkspace({ temporaryRoot: root, ...timing.options }); + const staged = await workspace.stage({ + files: [{ path: "SKILL.md", content: new TextEncoder().encode("keep me") }], + }); + const authorityPath = join(staged.rootDirectory, entry.name); + await writeFile(authorityPath, entry.alter(await readFile(authorityPath, "utf8"))); + current = new Date(current.getTime() + STALE_WORKSPACE_TTL_MS + 31); + await workspace.recoverStale(); + expect(await readFile(join(staged.skillDirectory, "SKILL.md"), "utf8")).toBe("keep me"); + } + }); + + test("rechecks exact recovery claim fields before deleting", async () => { + const root = await mkdtemp(join(tmpdir(), "selftune-helper-test-")); + roots.push(root); + let current = new Date("2026-07-21T00:00:00.000Z"); + const timing = manualTiming(() => current); + let claimPath = ""; + const workspace = makeOsUseOnceWorkspace({ + temporaryRoot: root, + ...timing.options, + beforeRecoveryDelete: async () => { + const original = await readFile(claimPath, "utf8"); + await writeFile(claimPath, original.replace(/}$/, ',"unexpected":true}')); + }, + }); + const staged = await workspace.stage({ + files: [{ path: "SKILL.md", content: new TextEncoder().encode("keep me") }], + }); + claimPath = join(staged.rootDirectory, ".selftune-use-once-recovery-claim.json"); + current = new Date(current.getTime() + STALE_WORKSPACE_TTL_MS + 31); + await workspace.recoverStale(); + expect((await stat(staged.rootDirectory)).isDirectory()).toBe(true); + }); + + test("disposes the heartbeat exactly once during idempotent cleanup", async () => { + const root = await mkdtemp(join(tmpdir(), "selftune-helper-test-")); + roots.push(root); + let stopped = 0; + const workspace = makeOsUseOnceWorkspace({ + temporaryRoot: root, + startInterval: () => () => { + stopped += 1; + }, + }); + const staged = await workspace.stage({ + files: [{ path: "SKILL.md", content: new TextEncoder().encode("# Skill") }], + }); + await staged.cleanup(); + await staged.cleanup(); + expect(stopped).toBe(1); + }); + test("stages only user-readable regular files and removes them idempotently", async () => { const root = await mkdtemp(join(tmpdir(), "selftune-helper-test-")); roots.push(root); diff --git a/bin/desktop-runtime.cjs b/bin/desktop-runtime.cjs index fa366a75..e84add49 100644 --- a/bin/desktop-runtime.cjs +++ b/bin/desktop-runtime.cjs @@ -43,6 +43,8 @@ function resolveDesktopRuntime(installedVersion, options = {}) { try { const runtimeRoot = realpathSync(join(dataRoot, "runtime")); const pointer = JSON.parse(readFileSync(join(runtimeRoot, "current.json"), "utf8")); + // SAFETY-TYPEOF: The Desktop pointer is untrusted JSON; string validation is required before + // version comparison and path resolution enforce runtime confinement. if ( !pointer || typeof pointer.version !== "string" || diff --git a/bin/run-hook.cjs b/bin/run-hook.cjs index a68ea5ad..49b40815 100644 --- a/bin/run-hook.cjs +++ b/bin/run-hook.cjs @@ -50,6 +50,8 @@ function daemonTarget() { const controlDir = join(resolveConfigDir(), "server-control"); const manifest = JSON.parse(readFileSync(join(controlDir, "server.json"), "utf8")); const auth = JSON.parse(readFileSync(join(controlDir, "auth.json"), "utf8")); + // SAFETY-TYPEOF: These JSON files are an untrusted process boundary; exact primitive checks + // gate URL construction, process signaling, and bearer-token forwarding. if ( !Number.isSafeInteger(manifest.pid) || manifest.pid <= 1 || @@ -100,6 +102,8 @@ async function forwardToDaemon(hookName, rawStdin) { if (response.status === 202) return { exit_code: 0, stdout: "", stderr: "" }; if (response.status !== 200) return null; const result = await response.json(); + // SAFETY-TYPEOF: The daemon response crosses an HTTP boundary; validate the exact output + // primitives before writing them to the hook process streams. if ( !Number.isInteger(result.exit_code) || typeof result.stdout !== "string" || diff --git a/bun.lock b/bun.lock index 6b1d5a20..0fe21dea 100644 --- a/bun.lock +++ b/bun.lock @@ -42,12 +42,15 @@ "@effect/tsgo": "^0.24.1", "@effect/vitest": "4.0.0-beta.66", "@evilmartians/lefthook": "^1.13.6", + "@oxlint/plugins": "1.78.0", "@types/bun": "^1.3.11", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", "@typescript/native": "npm:typescript@^7.0.2", "bun-types": "^1.3.11", "drizzle-kit": "^0.31.10", "oxfmt": "^0.41.0", - "oxlint": "^1.56.0", + "oxlint": "1.78.0", "playwright": "1.61.1", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -62,6 +65,7 @@ "dependencies": { "@effect/platform-bun": "4.0.0-beta.66", "@effect/platform-node-shared": "4.0.0-beta.66", + "@selftune/control-plane": "workspace:*", "@selftune/harness-claude-code": "workspace:*", "@selftune/harness-cline": "workspace:*", "@selftune/harness-codex": "workspace:*", @@ -82,7 +86,7 @@ }, "apps/desktop": { "name": "@selftune/desktop", - "version": "0.4.11", + "version": "0.4.12", "dependencies": { "@selftune/config": "workspace:*", "@sentry/electron": "^7.8.0", @@ -151,6 +155,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "effect": "4.0.0-beta.66", "lucide-react": "^0.577.0", "next-themes": "^0.4.6", "react": "^19.1.0", @@ -267,11 +272,14 @@ "@selftune/ui": "workspace:*", "lucide-react": "^0.577.0", "sonner": "^2.0.7", + "zod": "^4.3.6", }, "devDependencies": { + "@storybook/react-vite": "10.5.7", "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "storybook": "10.5.7", }, "peerDependencies": { "react": "^19.0.0", @@ -532,6 +540,7 @@ "@base-ui/react": "^1.3.0", "@pierre/diffs": "1.1.19", "@pierre/trees": "1.0.0-beta.3", + "@selftune/control-plane": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", @@ -539,8 +548,11 @@ "tailwind-merge": "^3.5.0", }, "devDependencies": { + "@storybook/react-vite": "10.5.7", + "@testing-library/react": "16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "storybook": "10.5.7", }, "peerDependencies": { "@dnd-kit/core": "^6.0.0", @@ -565,6 +577,8 @@ }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.15.0", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww=="], "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.5.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ=="], @@ -743,11 +757,11 @@ "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -823,6 +837,8 @@ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -887,7 +903,85 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.127.0", "", { "os": "android", "cpu": "arm" }, "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.127.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.127.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.127.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.127.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.127.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.127.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.127.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.127.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.127.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.127.0", "", { "os": "linux", "cpu": "none" }, "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.127.0", "", { "os": "linux", "cpu": "none" }, "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.127.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.127.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.127.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.127.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ=="], + + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.127.0", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.127.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.127.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], + + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.24.2", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="], @@ -927,43 +1021,45 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.41.0", "", { "os": "win32", "cpu": "x64" }, "sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.74.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.74.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.74.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.74.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.74.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.74.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.74.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.74.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + "@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="], "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], @@ -1063,6 +1159,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@selftune/app-core": ["@selftune/app-core@workspace:packages/app-core"], @@ -1167,6 +1265,20 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@storybook/builder-vite": ["@storybook/builder-vite@10.5.7", "", { "dependencies": { "@storybook/csf-plugin": "10.5.7", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.5.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg=="], + + "@storybook/csf-plugin": ["@storybook/csf-plugin@10.5.7", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.5.7", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag=="], + + "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], + + "@storybook/icons": ["@storybook/icons@2.1.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg=="], + + "@storybook/react": ["@storybook/react@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.5.7", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7", "typescript": ">= 4.9.x" }, "optionalPeers": ["@types/react", "@types/react-dom", "typescript"] }, "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g=="], + + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.7", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA=="], + + "@storybook/react-vite": ["@storybook/react-vite@10.5.7", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.5.7", "@storybook/react": "10.5.7", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.7", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-eEo3eVa2pvqrzQukKxAzx7YvswDAA1s6k/y+tdMxmRvWyHX6QEOsb9Tda6wcVaa7c8BeJM7Ggq+289cRMTH6Iw=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -1219,8 +1331,12 @@ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.7", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ENvPwy3x5yS7MwNYHeWjqOBXkwIMp39Pd+/zXC6PoiNzF8EIvvLZOZZ+ny6L9x4WgS5vxUii2LM5gM+zjPdnWw=="], @@ -1239,6 +1355,14 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -1267,6 +1391,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/doctrine": ["@types/doctrine@0.0.9", "", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1285,10 +1411,12 @@ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -1361,6 +1489,8 @@ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "@webcontainer/env": ["@webcontainer/env@1.1.1", "", {}, "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], "@zip.js/zip.js": ["@zip.js/zip.js@2.8.31", "", {}, "sha512-2NLmow9ax/5IdBbJKxNQp3Ur9mNxmgLfN2Yp/FKNh1ZIGs3OYkiC3AIUfIZouTPSgeW5+F1bzTAZAyD2Y4ZfFA=="], @@ -1369,6 +1499,8 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentation": ["agentation@3.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA=="], @@ -1481,6 +1613,8 @@ "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], @@ -1545,6 +1679,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -1585,6 +1721,8 @@ "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -1623,6 +1761,8 @@ "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], @@ -1665,6 +1805,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -1711,6 +1853,8 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -1797,7 +1941,7 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1861,6 +2005,8 @@ "import-in-the-middle": ["import-in-the-middle@3.3.1", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1881,6 +2027,8 @@ "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -1951,6 +2099,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -2003,6 +2153,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2101,6 +2253,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2181,9 +2335,13 @@ "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], + "oxc-parser": ["oxc-parser@0.127.0", "", { "dependencies": { "@oxc-project/types": "^0.127.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.127.0", "@oxc-parser/binding-android-arm64": "0.127.0", "@oxc-parser/binding-darwin-arm64": "0.127.0", "@oxc-parser/binding-darwin-x64": "0.127.0", "@oxc-parser/binding-freebsd-x64": "0.127.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", "@oxc-parser/binding-linux-arm64-musl": "0.127.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", "@oxc-parser/binding-linux-x64-gnu": "0.127.0", "@oxc-parser/binding-linux-x64-musl": "0.127.0", "@oxc-parser/binding-openharmony-arm64": "0.127.0", "@oxc-parser/binding-wasm32-wasi": "0.127.0", "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", "@oxc-parser/binding-win32-x64-msvc": "0.127.0" } }, "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA=="], + + "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], + "oxfmt": ["oxfmt@0.41.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.41.0", "@oxfmt/binding-android-arm64": "0.41.0", "@oxfmt/binding-darwin-arm64": "0.41.0", "@oxfmt/binding-darwin-x64": "0.41.0", "@oxfmt/binding-freebsd-x64": "0.41.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", "@oxfmt/binding-linux-arm64-gnu": "0.41.0", "@oxfmt/binding-linux-arm64-musl": "0.41.0", "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-musl": "0.41.0", "@oxfmt/binding-linux-s390x-gnu": "0.41.0", "@oxfmt/binding-linux-x64-gnu": "0.41.0", "@oxfmt/binding-linux-x64-musl": "0.41.0", "@oxfmt/binding-openharmony-arm64": "0.41.0", "@oxfmt/binding-win32-arm64-msvc": "0.41.0", "@oxfmt/binding-win32-ia32-msvc": "0.41.0", "@oxfmt/binding-win32-x64-msvc": "0.41.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg=="], - "oxlint": ["oxlint@1.74.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.74.0", "@oxlint/binding-android-arm64": "1.74.0", "@oxlint/binding-darwin-arm64": "1.74.0", "@oxlint/binding-darwin-x64": "1.74.0", "@oxlint/binding-freebsd-x64": "1.74.0", "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", "@oxlint/binding-linux-arm-musleabihf": "1.74.0", "@oxlint/binding-linux-arm64-gnu": "1.74.0", "@oxlint/binding-linux-arm64-musl": "1.74.0", "@oxlint/binding-linux-ppc64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-musl": "1.74.0", "@oxlint/binding-linux-s390x-gnu": "1.74.0", "@oxlint/binding-linux-x64-gnu": "1.74.0", "@oxlint/binding-linux-x64-musl": "1.74.0", "@oxlint/binding-openharmony-arm64": "1.74.0", "@oxlint/binding-win32-arm64-msvc": "1.74.0", "@oxlint/binding-win32-ia32-msvc": "1.74.0", "@oxlint/binding-win32-x64-msvc": "1.74.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA=="], + "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], @@ -2217,12 +2375,18 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -2303,6 +2467,10 @@ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], + + "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], @@ -2331,6 +2499,8 @@ "recharts": ["recharts@3.9.2", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], @@ -2355,6 +2525,8 @@ "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -2465,6 +2637,8 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + "storybook": ["storybook@10.5.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -2479,6 +2653,8 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -2487,6 +2663,8 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "systeminformation": ["systeminformation@5.32.0", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-7gfXs43T91miPxxTTtrYitotR/8MPsI2gy3XgUMs6kmOE/JCVqZp6nJpx4XkSutoSqDh6+Y2ovvb2A3RQCR+8w=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -2519,6 +2697,8 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyspy": ["tinyspy@4.0.6", "", {}, "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], @@ -2539,6 +2719,8 @@ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -2579,6 +2761,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -2615,6 +2799,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], @@ -2671,6 +2857,8 @@ "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -2709,8 +2897,30 @@ "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@selftune/app-core/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@selftune/dashboard-core/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@selftune/local-dashboard/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@selftune/observability/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@selftune/ui/@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@selftune/ui/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -2725,6 +2935,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "@types/cacheable-request/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@types/fs-extra/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], @@ -2793,8 +3005,6 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -2825,6 +3035,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], @@ -2843,12 +3055,26 @@ "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "shadcn/fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "temp-file/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -2955,6 +3181,10 @@ "@malept/flatpak-bundler/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], @@ -3017,8 +3247,6 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], - "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], @@ -3031,10 +3259,72 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "shadcn/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "shadcn/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "storybook/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "storybook/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "temp-file/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -3109,14 +3399,20 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "read-yaml-file/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], + + "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "storybook/open/wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/cli/selftune/index.ts b/cli/selftune/index.ts index dfc3f23f..10093590 100644 --- a/cli/selftune/index.ts +++ b/cli/selftune/index.ts @@ -1,4 +1,4 @@ #!/usr/bin/env bun // Compatibility entrypoint for existing installations and hook configuration. -await import("../../apps/cli/src/main.ts"); +import "../../apps/cli/src/main.ts"; diff --git a/docs/design-docs/system-overview.md b/docs/design-docs/system-overview.md index f4639472..327d3786 100644 --- a/docs/design-docs/system-overview.md +++ b/docs/design-docs/system-overview.md @@ -156,7 +156,7 @@ The dashboard SPA consumes shared presentational components from `packages/ui/` | Artifact | Role | | ------------------------------------- | ----------------------------------------------------------------------------------- | -| `~/.selftune/selftune.db` | SQLite operational store for runtime reads, writes, and alpha-upload staging | +| `~/.selftune/selftune.db` | SQLite operational store for local runtime reads and writes | | `selftune sync` | Replays native source systems into SQLite and refreshes repaired overlays | | `selftune recover` | Explicit legacy/export JSONL recovery path | | `~/.claude/*.jsonl` | Legacy/export snapshots and compatibility overlays | diff --git a/docs/exec-plans/deferred/universal-hooks-multi-agent.md b/docs/exec-plans/deferred/universal-hooks-multi-agent.md index 8a4188c9..db502c30 100644 --- a/docs/exec-plans/deferred/universal-hooks-multi-agent.md +++ b/docs/exec-plans/deferred/universal-hooks-multi-agent.md @@ -1,6 +1,11 @@ # Universal Hooks: Multi-Agent Hook Abstraction -**Status:** Implemented (Phases 1–3) +**Status:** Historical design; active adapters use their own payload contracts. + +The unused generic normalizer and hook-output helpers were removed on 2026-09-06 +after checking repository imports and callers. The shared session-state utility +is still used by skill-edit capture. The architecture below records the original +proposal, not the current dispatch path. **Created:** 2026-03-29 **Priority:** Medium **Domain:** Hooks / Platform Adapters diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 66574be1..af98f550 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -224,7 +224,7 @@ Do not treat early alpha as self-serve. Keep it high-touch until: | Path | Meaning | | --------------------------------------- | -------------------------------------------------------------------- | | `~/.selftune/config.json` | detected agent identity and bootstrap config | -| `~/.selftune/selftune.db` | SQLite operational database and alpha-upload staging source | +| `~/.selftune/selftune.db` | SQLite operational database for local history | | `~/.claude/skill_usage_repaired.jsonl` | compatibility/export overlay for repaired skill usage | | `~/.claude/evolution_audit_log.jsonl` | legacy/export audit trail snapshot | | `~/.claude/orchestrate_runs.jsonl` | legacy/export orchestrate run snapshot | diff --git a/e2e/src/local-target.ts b/e2e/src/local-target.ts index 57b6d4d3..d5a8cf62 100644 --- a/e2e/src/local-target.ts +++ b/e2e/src/local-target.ts @@ -202,6 +202,7 @@ export function localTargetLayer(options: { runDirectory: string; stack: LocalDevStack; fixture: TrackedUpdateFixture | null; + storageState?: string; }) { const api = Layer.succeed(LocalApi, { skillState: (skillName: string) => @@ -233,6 +234,7 @@ export function localTargetLayer(options: { dashboardUrl: manifest.urls.dashboard, skillName, runDirectory: options.runDirectory, + storageState: options.storageState, }); }, catch: (cause) => diff --git a/e2e/src/scenario-runner.ts b/e2e/src/scenario-runner.ts index 1b39416c..1f365f66 100644 --- a/e2e/src/scenario-runner.ts +++ b/e2e/src/scenario-runner.ts @@ -4,6 +4,10 @@ import { basename, join, resolve } from "node:path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import { optionalEvidence } from "@selftune/runtime/utils/transcript-contract"; import type { ScenarioError } from "./services"; @@ -33,7 +37,7 @@ export interface FailedScenarioResult extends ScenarioResultBase { error: string; } -export type ScenarioResult = +export type ScenarioResult = | PassedScenarioResult | SkippedScenarioResult | FailedScenarioResult; @@ -53,11 +57,40 @@ export interface RunScenarioOptions { now?: () => Date; } -function writeJson(path: string, value: unknown): void { - writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} +const ResultBase = { + target: Schema.String, + scenario: Schema.String, + source: Schema.String, + timestamp: Schema.String, + duration_ms: Schema.Number, + run_directory: Schema.String, +}; +const PersistedResult = Schema.Union([ + Schema.Struct({ + ...ResultBase, + status: Schema.Literal("passed"), + observable_outcome: Schema.optionalKey(Schema.Json), + }), + Schema.Struct({ + ...ResultBase, + status: Schema.Literal("skipped"), + missing_capability: Schema.String, + skip_reason: Schema.String, + }), + Schema.Struct({ + ...ResultBase, + status: Schema.Literal("failed"), + failed_step: Schema.String, + error: Schema.String, + }), +]); +const Matrix = Schema.Struct({ results: Schema.Array(Schema.Json) }); +const ParityEvidence = Schema.Struct({ + installed_hash: optionalEvidence(Schema.String), + receipt_status: optionalEvidence(Schema.String), +}); -function parityEntry(result: ScenarioResult): Record { +function parityEntry(result: typeof PersistedResult.Type) { const base = { target: result.target, scenario: result.scenario, status: result.status }; if (result.status === "skipped") { return { ...base, missing_capability: result.missing_capability, reason: result.skip_reason }; @@ -65,45 +98,44 @@ function parityEntry(result: ScenarioResult): Record { if (result.status === "failed") { return { ...base, failed_step: result.failed_step, error: result.error }; } - const outcome = result.observable_outcome; - if (typeof outcome !== "object" || outcome === null) return base; - const installedHash = Reflect.get(outcome, "installed_hash"); - const receiptStatus = Reflect.get(outcome, "receipt_status"); - return { - ...base, - ...(typeof installedHash === "string" ? { installed_hash: installedHash } : {}), - ...(typeof receiptStatus === "string" ? { receipt_status: receiptStatus } : {}), - }; + const evidence = Schema.decodeUnknownOption(ParityEvidence)(result.observable_outcome); + return Option.isNone(evidence) ? base : { ...base, ...evidence.value }; } -function updateMatrix(runsRoot: string, result: ScenarioResult): void { +function updateMatrix(runsRoot: string, result: typeof PersistedResult.Type): void { const path = join(runsRoot, "matrix.json"); - let previous: unknown = null; + let results: (typeof PersistedResult.Type)[] = []; if (existsSync(path)) { try { - previous = JSON.parse(readFileSync(path, "utf8")); + const previous = Schema.decodeUnknownSync(Schema.fromJsonString(Matrix))( + readFileSync(path, "utf8"), + ); + results = previous.results.flatMap((entry) => + Option.toArray(Schema.decodeUnknownOption(PersistedResult)(entry)), + ); } catch { - previous = null; + results = []; } } - const results = - typeof previous === "object" && - previous !== null && - Array.isArray(Reflect.get(previous, "results")) - ? Reflect.get(previous, "results").filter( - (entry: unknown) => - typeof entry !== "object" || - entry === null || - Reflect.get(entry, "target") !== result.target || - Reflect.get(entry, "scenario") !== result.scenario, - ) - : []; - const nextResults = [...results, result]; - writeJson(path, { - generated_at: result.timestamp, - parity: nextResults.map(parityEntry), - results: nextResults, - }); + const nextResults = [ + ...results.filter( + (entry) => entry.target !== result.target || entry.scenario !== result.scenario, + ), + result, + ]; + writeFileSync( + path, + `${JSON.stringify( + { + generated_at: result.timestamp, + parity: nextResults.map(parityEntry), + results: nextResults, + }, + null, + 2, + )}\n`, + "utf8", + ); } export async function runScenario( @@ -120,7 +152,7 @@ export async function runScenario( mkdirSync(join(runDirectory, "screenshots"), { recursive: true }); mkdirSync(join(runDirectory, "logs"), { recursive: true }); copyFileSync(options.source, join(runDirectory, basename(options.source))); - const layer = typeof options.layer === "function" ? options.layer(runDirectory) : options.layer; + const layer = Predicate.isFunction(options.layer) ? options.layer(runDirectory) : options.layer; const outcome = await Effect.runPromise( options.program.pipe( @@ -159,13 +191,18 @@ export async function runScenario( error: outcome.error.message, }; - writeJson(join(runDirectory, "result.json"), result); + const encodedResult = `${JSON.stringify(result, null, 2)}\n`; + writeFileSync(join(runDirectory, "result.json"), encodedResult, "utf8"); writeFileSync( join(runDirectory, "logs", "scenario.log"), `${timestamp} ${result.status} ${options.target}/${options.scenario}\n`, "utf8", ); - if (result.status === "skipped") writeJson(join(runDirectory, "skipped.json"), result); - updateMatrix(resolve(options.runsRoot), result); + if (result.status === "skipped") + writeFileSync(join(runDirectory, "skipped.json"), encodedResult, "utf8"); + updateMatrix( + resolve(options.runsRoot), + Schema.decodeUnknownSync(Schema.fromJsonString(PersistedResult))(encodedResult), + ); return result; } diff --git a/e2e/src/server-target.ts b/e2e/src/server-target.ts index 9e97aa96..6d95871a 100644 --- a/e2e/src/server-target.ts +++ b/e2e/src/server-target.ts @@ -93,10 +93,7 @@ export interface AttachedServerTargetOptions { mutationContract?: "local-v2"; } -function resolveRequestHeaders( - options: AttachedServerTargetOptions, - dashboardUrl: string | null, -): { headers: Record | undefined; error: unknown | null } { +function resolveRequestHeaders(options: AttachedServerTargetOptions, dashboardUrl: string | null) { const headers: Record = {}; if (options.token) headers.Authorization = `Bearer ${options.token}`; if (!options.storageState || !dashboardUrl || !existsSync(options.storageState)) { diff --git a/e2e/tests/scenario-matrix.test.ts b/e2e/tests/scenario-matrix.test.ts new file mode 100644 index 00000000..7a2f8e7a --- /dev/null +++ b/e2e/tests/scenario-matrix.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer, Schema } from "effect"; +import { runScenario } from "../src/scenario-runner"; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); + +test("matrix recovery preserves valid results, replaces one target, and skips malformed neighbors", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-matrix-boundary-")); + try { + const previous = { + target: "previous", + scenario: "library", + source: "fixture.ts", + timestamp: "2026-09-06T00:00:00Z", + duration_ms: 0, + run_directory: "/fixture/previous", + status: "passed", + observable_outcome: { + installed_hash: "original", + receipt_status: "applied", + extension: [1, 2], + }, + }; + writeFileSync( + join(root, "matrix.json"), + JSON.stringify({ results: [null, [], { target: "broken" }, previous] }), + ); + const options = { + target: "current", + scenario: "library", + source: import.meta.filename, + runsRoot: root, + layer: Layer.empty, + }; + await runScenario({ + ...options, + program: Effect.succeed({ installed_hash: "first", receipt_status: "applied" }), + }); + await runScenario({ + ...options, + program: Effect.succeed({ installed_hash: "latest", receipt_status: 42 }), + }); + expect(decodeJson(readFileSync(join(root, "matrix.json"), "utf8"))).toMatchObject({ + results: [ + previous, + { + target: "current", + status: "passed", + observable_outcome: { installed_hash: "latest", receipt_status: 42 }, + }, + ], + parity: [ + { + target: "previous", + scenario: "library", + status: "passed", + installed_hash: "original", + receipt_status: "applied", + }, + { target: "current", scenario: "library", status: "passed", installed_hash: "latest" }, + ], + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("void outcomes and malformed matrix JSON still produce a valid run record", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-matrix-empty-")); + try { + writeFileSync(join(root, "matrix.json"), "not JSON"); + const result = await runScenario({ + target: "fixture", + scenario: "void", + source: import.meta.filename, + runsRoot: root, + layer: () => Layer.empty, + program: Effect.void, + }); + expect(result.status).toBe("passed"); + expect(decodeJson(readFileSync(join(root, "matrix.json"), "utf8"))).toMatchObject({ + results: [{ target: "fixture", status: "passed" }], + parity: [{ target: "fixture", scenario: "void", status: "passed" }], + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/e2e/tests/scenario-runner.test.ts b/e2e/tests/scenario-runner.test.ts index 2de830da..f4d8bdee 100644 --- a/e2e/tests/scenario-runner.test.ts +++ b/e2e/tests/scenario-runner.test.ts @@ -276,6 +276,19 @@ describe("capability-driven E2E scenarios", () => { }; try { + const storageState = join(root, "returning-user.json"); + writeFileSync( + storageState, + JSON.stringify({ + cookies: [], + origins: [ + { + origin: manifest.urls.dashboard, + localStorage: [{ name: "selftune-on-demand-setup-dismissed", value: "true" }], + }, + ], + }), + ); const result = await runScenario({ target: "local", scenario: "library-update-browser", @@ -286,6 +299,7 @@ describe("capability-driven E2E scenarios", () => { worktree: manifest.worktree, runDirectory, stack, + storageState, fixture: { skill_name: "research", installed_revision_hash: "old-content", diff --git a/package.json b/package.json index b9d7cd3a..e1f9a711 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selftune", - "version": "0.4.11", + "version": "0.4.12", "description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically", "keywords": [ "agent", @@ -145,12 +145,15 @@ "@effect/tsgo": "^0.24.1", "@effect/vitest": "4.0.0-beta.66", "@evilmartians/lefthook": "^1.13.6", + "@oxlint/plugins": "1.78.0", "@types/bun": "^1.3.11", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", "@typescript/native": "npm:typescript@^7.0.2", "bun-types": "^1.3.11", "drizzle-kit": "^0.31.10", "oxfmt": "^0.41.0", - "oxlint": "^1.56.0", + "oxlint": "1.78.0", "playwright": "1.61.1", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/packages/app-core/src/evidence-body-evolution-review.tsx b/packages/app-core/src/evidence-body-evolution-review.tsx index 752b7a40..15cd645e 100644 --- a/packages/app-core/src/evidence-body-evolution-review.tsx +++ b/packages/app-core/src/evidence-body-evolution-review.tsx @@ -134,7 +134,7 @@ export interface EvidenceBodyEvolutionReviewSurfaceProps { readonly onAction?: (action: EvidenceBodyEvolutionReviewAction) => void; } -const stateLabels: Record = { +const stateLabels = { loading: "Preparing review", insufficient_evidence: "Insufficient evidence", provider_unavailable: "Provider unavailable", @@ -146,7 +146,7 @@ const stateLabels: Record = { rejected: "Rejected", deferred: "Deferred", rolled_back: "Rolled back", -}; +} satisfies Record; export function EvidenceBodyEvolutionReviewSurface({ review, @@ -337,12 +337,12 @@ function ReviewActions({ readonly actions: EvidenceBodyEvolutionReview["actions"]; readonly onAction: EvidenceBodyEvolutionReviewSurfaceProps["onAction"]; }) { - const labels: Record = { - accept: "Accept candidate", - edit: "Edit candidate", - reject: "Reject candidate", - defer: "Defer review", - }; + const choices = [ + ["accept", "Accept candidate"], + ["edit", "Edit candidate"], + ["reject", "Reject candidate"], + ["defer", "Defer review"], + ] satisfies Array<[EvidenceBodyEvolutionReviewAction, string]>; return (

@@ -350,7 +350,7 @@ function ReviewActions({ install this mutation.

- {(Object.keys(labels) as EvidenceBodyEvolutionReviewAction[]).map((action) => { + {choices.map(([action, label]) => { const capability = actions?.[action]; const enabled = capability?.access === "available" && onAction !== undefined; return ( @@ -362,7 +362,7 @@ function ReviewActions({ className="rounded-md border border-border/60 px-3 py-2 text-sm text-foreground disabled:cursor-not-allowed disabled:opacity-50" onClick={() => onAction?.(action)} > - {labels[action]} + {label} ); })} diff --git a/packages/app-core/src/routes.tsx b/packages/app-core/src/routes.tsx index c22c29ea..ea6315ed 100644 --- a/packages/app-core/src/routes.tsx +++ b/packages/app-core/src/routes.tsx @@ -22,16 +22,23 @@ function tanStackRecipientPath(path: string): string { export function createAppCoreRecipientRoutes( rootRoute: TRootRoute, ) { - return Object.fromEntries( - APP_CORE_RECIPIENT_ROUTE_MANIFEST.map((route) => [ - `${route.id}Route`, - createRoute({ - getParentRoute: () => rootRoute, - path: tanStackRecipientPath(route.path), - component: route.Component, - }), - ]), - ) as Partial>; + const entries = APP_CORE_RECIPIENT_ROUTE_MANIFEST.map( + (route) => + [ + `${route.id}Route`, + createRoute({ + getParentRoute: () => rootRoute, + path: tanStackRecipientPath(route.path), + component: route.Component, + }), + ] as const, + ); + return entries.reduce< + Partial> + >((routes, [key, route]) => { + routes[key] = route; + return routes; + }, {}); } /** @@ -45,16 +52,24 @@ export function createAppCoreRoutes( rootRoute: TRootRoute, composition: AppCoreRouteComposition = {}, ) { - return Object.fromEntries( - resolveAppCoreRouteManifest(composition).map((route) => [ - routeKey(route.id), - createRoute({ - getParentRoute: () => rootRoute, - path: route.path, - component: route.Component, - }), - ]), - ) as Partial>; + const entries = resolveAppCoreRouteManifest(composition).map( + (route) => + [ + routeKey(route.id), + createRoute({ + getParentRoute: () => rootRoute, + path: route.path, + component: route.Component, + }), + ] as const, + ); + return entries.reduce>>( + (routes, [key, route]) => { + routes[key] = route; + return routes; + }, + {}, + ); } /** diff --git a/packages/app-core/tests/composition.test.tsx b/packages/app-core/tests/composition.test.tsx index 83835623..3b515352 100644 --- a/packages/app-core/tests/composition.test.tsx +++ b/packages/app-core/tests/composition.test.tsx @@ -98,6 +98,18 @@ describe("app-core route composition", () => { } }); + it("constructs only selected routes and retains the host replacement component", () => { + const rootRoute = createRootRoute(); + const routes = createAppCoreRoutes(rootRoute, { + exclude: ["projects", "collaboration"], + replace: { skills: ReplacementSkillsScreen }, + }); + expect(Object.keys(routes)).toEqual(["skillsRoute"]); + expect(routes.skillsRoute?.options.component).toBe(ReplacementSkillsScreen); + expect(routes.skillsRoute?.options.getParentRoute?.()).toBe(rootRoute); + expect(createAppCoreRoutes(rootRoute, { exclude: APP_CORE_ROUTE_IDS })).toEqual({}); + }); + it("keeps recipient routes canonical but outside product-shell navigation", () => { expect(APP_CORE_RECIPIENT_ROUTE_MANIFEST).toEqual([ { diff --git a/packages/config/src/config.test.ts b/packages/config/src/config.test.ts index 22b2db2c..edd9cbbd 100644 --- a/packages/config/src/config.test.ts +++ b/packages/config/src/config.test.ts @@ -255,6 +255,43 @@ describe("writeConfig", () => { }); describe("synchronous config compatibility", () => { + it.effect("keeps sync and async rejection aligned without rewriting malformed data", () => + withTemporaryDirectory((directory) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = join(directory, "config.json"); + for (const source of [ + "{", + "null", + "[]", + "42", + "{}", + JSON.stringify({ ...validConfig, hooks_installed: "false" }), + ]) { + yield* fs.writeFileString(path, source); + const failure = yield* Effect.flip(loadConfig(path)); + expect(failure).toBeInstanceOf(ConfigParseError); + if (failure instanceof ConfigParseError) expect(failure.path).toBe(path); + expect(() => loadConfigSync(path)).toThrow(ConfigParseError); + expect(yield* fs.readFileString(path)).toBe(source); + } + }), + ), + ); + + it.effect("distinguishes missing files from filesystem failures in both loaders", () => + withTemporaryDirectory((directory) => + Effect.gen(function* () { + const missing = join(directory, "absent.json"); + expect(loadConfigSync(missing)).toBeNull(); + expect(yield* loadConfig(missing)).toBeNull(); + expect(() => loadConfigSync(directory)).toThrow(); + const failure = yield* Effect.flip(loadConfig(directory)); + expect(failure).not.toBeInstanceOf(ConfigParseError); + }), + ), + ); + it("uses the same validated atomic private writer", () => { const directory = mkdtempSync(join(tmpdir(), "selftune-config-sync-")); const path = join(directory, "nested", "config.json"); diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 87c1b036..01bd152f 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -12,17 +12,6 @@ export class ConfigParseError extends Schema.TaggedErrorClass( }, ) {} -function parseJson(path: string, source: string) { - return Effect.try({ - try: (): unknown => JSON.parse(source), - catch: (cause) => - new ConfigParseError({ - path, - message: cause instanceof Error ? cause.message : String(cause), - }), - }); -} - export const loadConfig = Effect.fn("Config.loadConfig")(function* ( path: string, ): Effect.fn.Return< @@ -34,8 +23,7 @@ export const loadConfig = Effect.fn("Config.loadConfig")(function* ( if (!(yield* fs.exists(path))) return null; const source = yield* fs.readFileString(path); - const parsed = yield* parseJson(path, source); - return yield* Schema.decodeUnknownEffect(SelftuneFileConfig)(parsed).pipe( + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SelftuneFileConfig))(source).pipe( Effect.mapError( (error) => new ConfigParseError({ @@ -51,13 +39,12 @@ export function loadConfigSync(path: string): SelftuneFileConfig | null { try { source = readFileSync(path, "utf8"); } catch (cause) { - if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null; + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return null; throw cause; } try { - const parsed: unknown = JSON.parse(source); - return Schema.decodeUnknownSync(SelftuneFileConfig)(parsed); + return Schema.decodeUnknownSync(Schema.fromJsonString(SelftuneFileConfig))(source); } catch (cause) { throw new ConfigParseError({ path, diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 0dfb25a5..acfc0068 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -9,6 +9,8 @@ "exports": { ".": "./index.ts", "./domain": "./src/domain/index.ts", + "./evidence": "./src/evidence.ts", + "./orchestration": "./src/orchestration.ts", "./library-consolidation": "./src/library-consolidation.ts", "./library-selection": "./src/library-selection.ts", "./services": "./src/services/index.ts", diff --git a/packages/control-plane/src/domain/library.ts b/packages/control-plane/src/domain/library.ts index cd004804..fa73eb88 100644 --- a/packages/control-plane/src/domain/library.ts +++ b/packages/control-plane/src/domain/library.ts @@ -35,6 +35,15 @@ export const LibraryOrigin = Schema.Struct({ }); export type LibraryOrigin = typeof LibraryOrigin.Type; +export const SkillDiscoveryMetadata = Schema.Struct({ + name: Schema.String, + description: Schema.String, + whenToUse: Schema.optionalKey(Schema.String), + disableModelInvocation: Schema.Boolean, + originalSkillPath: Schema.String, +}); +export type SkillDiscoveryMetadata = typeof SkillDiscoveryMetadata.Type; + export const LibraryObservation = Schema.Struct({ skillName: Schema.String, sourceKind: SkillSourceKind, @@ -50,6 +59,8 @@ export const LibraryObservation = Schema.Struct({ lastUsedAt: Schema.NullOr(Schema.String), origin: Schema.NullOr(LibraryOrigin), updateStatus: SkillUpdateStatus, + instructionBytes: Schema.optional(Schema.Number), + discovery: Schema.optionalKey(SkillDiscoveryMetadata), }); export type LibraryObservation = typeof LibraryObservation.Type; @@ -66,6 +77,8 @@ export const LibraryLocation = Schema.Struct({ lastUsedAt: Schema.NullOr(Schema.String), origin: Schema.NullOr(LibraryOrigin), updateStatus: SkillUpdateStatus, + instructionBytes: Schema.optional(Schema.Number), + discovery: Schema.optionalKey(SkillDiscoveryMetadata), }); export type LibraryLocation = typeof LibraryLocation.Type; @@ -85,6 +98,7 @@ export const LibrarySkill = Schema.Struct({ lastModifiedAt: Schema.String, origins: Schema.Array(LibraryOrigin), updateStatus: SkillUpdateStatus, + instructionBytes: Schema.optional(Schema.Number), }); export type LibrarySkill = typeof LibrarySkill.Type; diff --git a/packages/control-plane/src/evidence.ts b/packages/control-plane/src/evidence.ts new file mode 100644 index 00000000..50a158f3 --- /dev/null +++ b/packages/control-plane/src/evidence.ts @@ -0,0 +1,90 @@ +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; + +// Read contract for current and historical local evaluation evidence. Writers +// retain their stricter contracts; aliases here belong to the persisted format. +const optionalText = Schema.optional(Schema.NullOr(Schema.String)); +const optionalFlag = Schema.optional(Schema.NullOr(Schema.Boolean)); +const optionalNumber = Schema.optional(Schema.NullOr(Schema.Number)); + +const EvidenceQuery = Schema.Struct({ + query: optionalText, + should_trigger: optionalFlag, + invocation_type: optionalText, +}); + +export const EvidenceCase = Schema.Struct({ + ...EvidenceQuery.fields, + entry: Schema.optional(Schema.NullOr(EvidenceQuery)), + prompt: optionalText, + input: optionalText, + text: optionalText, + expected: Schema.optional( + Schema.NullOr(Schema.Union([Schema.String, Schema.Boolean, Schema.Number])), + ), + before_pass: optionalFlag, + before: optionalFlag, + original_triggered: optionalFlag, + baseline: optionalFlag, + after_pass: optionalFlag, + after: optionalFlag, + triggered: optionalFlag, + result: optionalFlag, + passed: optionalFlag, + matched: optionalFlag, + source: optionalText, + created_at: optionalText, +}); +export type EvidenceCase = typeof EvidenceCase.Type; + +const TextEvidenceCase = Schema.String.pipe( + Schema.decodeTo(Schema.Struct({ query: Schema.String }), { + decode: SchemaGetter.transform((query) => ({ query })), + encode: SchemaGetter.transform((entry) => entry.query), + }), +); +const HistoricalEvidenceCase = Schema.Union([EvidenceCase, TextEvidenceCase]); +const cases = Schema.optional(Schema.NullOr(Schema.Array(HistoricalEvidenceCase))); + +export const EvidenceValidation = Schema.Struct({ + improved: optionalFlag, + before_pass_rate: optionalNumber, + after_pass_rate: optionalNumber, + net_change: optionalNumber, + regressions: cases, + new_passes: cases, + per_entry_results: cases, + before_entry_results: cases, + gates_passed: optionalNumber, + gates_total: optionalNumber, + gate_results: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + gate: Schema.String, + passed: Schema.Boolean, + reason: Schema.String, + }), + ), + ), + ), + validation_mode: optionalText, + validation_agent: optionalText, + validation_fixture_id: optionalText, + validation_fallback_reason: optionalText, + validation_evidence_ref: optionalText, + total: optionalNumber, + passed: optionalNumber, + failed: optionalNumber, + pass_rate: optionalNumber, +}); +export type EvidenceValidation = typeof EvidenceValidation.Type; + +export const decodeEvidenceValidation = Schema.decodeUnknownResult(EvidenceValidation); +export const decodeEvidenceCases = Schema.decodeUnknownResult(Schema.Array(HistoricalEvidenceCase)); +export const decodeEvidenceValidationJson = Schema.decodeUnknownResult( + Schema.fromJsonString(EvidenceValidation), +); +export const decodeEvidenceCasesJson = Schema.decodeUnknownResult( + Schema.fromJsonString(Schema.Array(HistoricalEvidenceCase)), +); diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index 79c8b170..34b55150 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -4,6 +4,7 @@ export * from "./layers/remote-memory"; export * from "./layers/remote-http"; export * from "./library-consolidation"; export * from "./library-selection"; +export * from "./orchestration"; export * from "./programs"; export * from "./reconcile"; export * from "./science"; diff --git a/packages/control-plane/src/orchestration.test.ts b/packages/control-plane/src/orchestration.test.ts new file mode 100644 index 00000000..54d2667e --- /dev/null +++ b/packages/control-plane/src/orchestration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; +import { OrchestrateRunReport } from "./orchestration"; + +const report: OrchestrateRunReport = { + run_id: "run", + timestamp: "2026-09-05T00:00:00Z", + elapsed_ms: 100, + dry_run: true, + approval_mode: "review", + total_skills: 1, + evaluated: 1, + evolved: 0, + deployed: 0, + watched: 0, + skipped: 0, + skill_actions: [{ skill: "marketing", action: "package-search", reason: "compare variants" }], +}; + +describe("shared orchestration report contract", () => { + it("recognizes package search with optional measured counters", () => { + const measured = { ...report, package_searched: 1, package_improved: 0, auto_graded: 0 }; + expect( + Schema.decodeUnknownSync(Schema.fromJsonString(OrchestrateRunReport))( + JSON.stringify(measured), + ), + ).toEqual(measured); + }); + + it("preserves reports created before optional counters were recorded", () => { + expect(Schema.decodeUnknownSync(OrchestrateRunReport)(report)).toEqual(report); + }); + + it("rejects unknown action names and string counters", () => { + expect(Schema.is(OrchestrateRunReport)({ ...report, package_searched: "1" })).toBe(false); + expect( + Schema.is(OrchestrateRunReport)({ + ...report, + skill_actions: [{ skill: "marketing", action: "unknown", reason: "invalid" }], + }), + ).toBe(false); + }); +}); diff --git a/packages/control-plane/src/orchestration.ts b/packages/control-plane/src/orchestration.ts new file mode 100644 index 00000000..cecda101 --- /dev/null +++ b/packages/control-plane/src/orchestration.ts @@ -0,0 +1,32 @@ +import { Schema } from "effect"; + +export const OrchestrateRunSkillAction = Schema.Struct({ + skill: Schema.mutableKey(Schema.String), + action: Schema.mutableKey(Schema.Literals(["evolve", "package-search", "watch", "skip"])), + reason: Schema.mutableKey(Schema.String), + deployed: Schema.mutableKey(Schema.optionalKey(Schema.Boolean)), + rolledBack: Schema.mutableKey(Schema.optionalKey(Schema.Boolean)), + alert: Schema.mutableKey(Schema.optionalKey(Schema.NullOr(Schema.String))), + elapsed_ms: Schema.mutableKey(Schema.optionalKey(Schema.Number)), + llm_calls: Schema.mutableKey(Schema.optionalKey(Schema.Number)), +}); +export type OrchestrateRunSkillAction = typeof OrchestrateRunSkillAction.Type; + +export const OrchestrateRunReport = Schema.Struct({ + run_id: Schema.mutableKey(Schema.String), + timestamp: Schema.mutableKey(Schema.String), + elapsed_ms: Schema.mutableKey(Schema.Number), + dry_run: Schema.mutableKey(Schema.Boolean), + approval_mode: Schema.mutableKey(Schema.Literals(["auto", "review"])), + total_skills: Schema.mutableKey(Schema.Number), + evaluated: Schema.mutableKey(Schema.Number), + evolved: Schema.mutableKey(Schema.Number), + deployed: Schema.mutableKey(Schema.Number), + watched: Schema.mutableKey(Schema.Number), + skipped: Schema.mutableKey(Schema.Number), + auto_graded: Schema.mutableKey(Schema.optionalKey(Schema.Number)), + package_searched: Schema.mutableKey(Schema.optionalKey(Schema.Number)), + package_improved: Schema.mutableKey(Schema.optionalKey(Schema.Number)), + skill_actions: Schema.mutableKey(Schema.mutable(Schema.Array(OrchestrateRunSkillAction))), +}); +export type OrchestrateRunReport = typeof OrchestrateRunReport.Type; diff --git a/packages/control-plane/src/reconcile.ts b/packages/control-plane/src/reconcile.ts index 0f97f235..b7295448 100644 --- a/packages/control-plane/src/reconcile.ts +++ b/packages/control-plane/src/reconcile.ts @@ -23,8 +23,8 @@ const locationKey = (location: LibraryObservation): string => location.projectRoot ?? "", ].join("\u0000"); -const toLocation = (observation: LibraryObservation) => - LibraryLocation.make({ +const toLocation = (observation: LibraryObservation) => { + const location = LibraryLocation.make({ sourceKind: observation.sourceKind, packagePath: observation.packagePath, skillPath: observation.skillPath, @@ -37,7 +37,10 @@ const toLocation = (observation: LibraryObservation) => lastUsedAt: observation.lastUsedAt, origin: observation.origin, updateStatus: observation.updateStatus, + instructionBytes: observation.instructionBytes, }); + return observation.discovery ? { ...location, discovery: observation.discovery } : location; +}; const latestTimestamp = (values: ReadonlyArray): string | null => values @@ -133,6 +136,10 @@ export const buildLibrarySnapshot = ( "1970-01-01T00:00:00.000Z", origins, updateStatus: updateStatusFor(sortedObservations), + instructionBytes: Math.max( + 0, + ...sortedObservations.map((observation) => observation.instructionBytes ?? 0), + ), }); }); diff --git a/packages/control-plane/tests/evidence.test.ts b/packages/control-plane/tests/evidence.test.ts new file mode 100644 index 00000000..aadf0a05 --- /dev/null +++ b/packages/control-plane/tests/evidence.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import * as Result from "effect/Result"; +import { decodeEvidenceCasesJson, decodeEvidenceValidationJson } from "../src/evidence"; + +describe("local evidence read contract", () => { + it("reads current nested results and historical flat aliases and regression text", () => { + const validation = { + before_pass_rate: 0.5, + after_pass_rate: 0.75, + net_change: 0.25, + regressions: ["historical regression"], + new_passes: [{ query: "flat", should_trigger: false }], + per_entry_results: [ + { entry: { query: "nested", should_trigger: true }, before_pass: false, after_pass: true }, + { prompt: "older prompt", baseline: true, result: false }, + { input: "input alias", passed: true }, + ], + gate_results: [{ gate: "routing", passed: false, reason: "Regression" }], + }; + const decoded = decodeEvidenceValidationJson(JSON.stringify(validation)); + expect(Result.isSuccess(decoded)).toBe(true); + if (Result.isFailure(decoded)) throw new Error("Fixture did not decode"); + expect(decoded.success.regressions).toEqual([{ query: "historical regression" }]); + expect(decoded.success.per_entry_results).toEqual(validation.per_entry_results); + expect(decoded.success.gate_results).toEqual(validation.gate_results); + }); + + it.each([ + "{", + "null", + "[]", + '{"after_pass_rate":"0.9"}', + '{"per_entry_results":[{"passed":"false"}]}', + ])("rejects unreadable validation %s", (json) => + expect(Result.isFailure(decodeEvidenceValidationJson(json))).toBe(true), + ); + + it("accepts historical null optionals without coercing missing results into failures", () => { + const decoded = decodeEvidenceCasesJson( + '[{"query":"not measured","result":null,"expected":false}]', + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (Result.isFailure(decoded)) throw new Error("Fixture did not decode"); + expect(decoded.success).toEqual([{ query: "not measured", result: null, expected: false }]); + expect(Result.isFailure(decodeEvidenceCasesJson('[{"query":12}]'))).toBe(true); + }); +}); diff --git a/packages/dashboard-core/package.json b/packages/dashboard-core/package.json index 5bc54d88..ceb3f493 100644 --- a/packages/dashboard-core/package.json +++ b/packages/dashboard-core/package.json @@ -36,12 +36,15 @@ "@selftune/control-plane": "workspace:*", "@selftune/ui": "workspace:*", "lucide-react": "^0.577.0", - "sonner": "^2.0.7" + "sonner": "^2.0.7", + "zod": "^4.3.6" }, "devDependencies": { + "@storybook/react-vite": "10.5.7", "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" + "@types/react-dom": "^19.0.0", + "storybook": "10.5.7" }, "peerDependencies": { "react": "^19.0.0", diff --git a/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx b/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx index 1660cc9e..e26d0487 100644 --- a/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx +++ b/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; import { RuntimeBadge } from "./RuntimeBadge"; diff --git a/packages/dashboard-core/src/chrome/theme.test.tsx b/packages/dashboard-core/src/chrome/theme.test.tsx new file mode 100644 index 00000000..bd595d6a --- /dev/null +++ b/packages/dashboard-core/src/chrome/theme.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ThemeProvider, useTheme } from "./theme"; + +function ThemeControl() { + const { theme, resolvedTheme, setTheme } = useTheme(); + return ( + + ); +} + +beforeEach(() => { + const entries = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => { + entries.set(key, value); + }, + removeItem: (key: string) => { + entries.delete(key); + }, + clear: () => entries.clear(), + key: (index: number) => [...entries.keys()][index] ?? null, + get length() { + return entries.size; + }, + } satisfies Storage); +}); + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("uses light for system theme without matchMedia and persists explicit changes", () => { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "system:light" })); + expect(screen.getByRole("button", { name: "dark:dark" })).toBeTruthy(); + expect(window.localStorage.getItem("selftune-theme")).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); +}); + +it("ignores invalid stored themes and tolerates inaccessible storage", () => { + window.localStorage.setItem("selftune-theme", "broken"); + const view = render( + + + , + ); + expect(screen.getByRole("button", { name: "light:light" })).toBeTruthy(); + view.unmount(); + vi.spyOn(window.localStorage, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "light:light" })); + expect(screen.getByRole("button", { name: "dark:dark" })).toBeTruthy(); +}); diff --git a/packages/dashboard-core/src/chrome/theme.tsx b/packages/dashboard-core/src/chrome/theme.tsx index 3b35b1ba..12e8229c 100644 --- a/packages/dashboard-core/src/chrome/theme.tsx +++ b/packages/dashboard-core/src/chrome/theme.tsx @@ -1,6 +1,14 @@ "use client"; -import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; export type Theme = "dark" | "light" | "system"; export type ResolvedTheme = Exclude; @@ -16,14 +24,12 @@ const ThemeProviderContext = createContext(undef const STORAGE_KEY = "selftune-theme"; function systemTheme(): ResolvedTheme { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + return globalThis.window?.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function readStoredTheme(defaultTheme: Theme): Theme { - if (typeof window === "undefined") return defaultTheme; try { - const stored = window.localStorage.getItem(STORAGE_KEY); + const stored = globalThis.window?.localStorage.getItem(STORAGE_KEY); return stored === "light" || stored === "dark" || stored === "system" ? stored : defaultTheme; } catch { return defaultTheme; @@ -44,10 +50,7 @@ export function ThemeProvider({ useEffect(() => { const root = window.document.documentElement; - const media = - typeof window.matchMedia === "function" - ? window.matchMedia("(prefers-color-scheme: dark)") - : null; + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); const apply = () => { const resolved: ResolvedTheme = theme === "system" ? systemTheme() : theme; root.classList.toggle("dark", resolved === "dark"); @@ -59,20 +62,20 @@ export function ThemeProvider({ return () => media?.removeEventListener("change", apply); }, [theme]); - const setTheme = (next: Theme) => { + const setTheme = useCallback((next: Theme) => { try { window.localStorage.setItem(STORAGE_KEY, next); } catch { // Private browsing or locked-down storage: keep the in-memory theme. } setThemeState(next); - }; - - return ( - - {children} - + }, []); + const value = useMemo( + () => ({ theme, resolvedTheme, setTheme }), + [theme, resolvedTheme, setTheme], ); + + return {children}; } export function useTheme() { diff --git a/packages/dashboard-core/src/host/adapter.ts b/packages/dashboard-core/src/host/adapter.ts index f97f34e6..1ac0bbf6 100644 --- a/packages/dashboard-core/src/host/adapter.ts +++ b/packages/dashboard-core/src/host/adapter.ts @@ -192,6 +192,10 @@ export interface DashboardLibraryActions { applyMerge: DashboardLibraryAction; archive: DashboardLibraryAction; archiveMany?: DashboardLibraryAction; + moveToLibraryMany?: DashboardLibraryAction< + readonly LibraryArchiveInput[], + LibraryArchiveBatchResult + >; consolidate?: DashboardLibraryAction; remove: DashboardLibraryAction; decideRemoval: DashboardLibraryAction< @@ -317,32 +321,6 @@ export interface DashboardProjectsActions { string, import("../models").ProjectTraceCandidateReviewModel >; - traceCandidateTargets?: DashboardProjectsAction< - string, - { - targets: Array<{ - sourceId: string; - snapshotId: string; - skillId: string; - suiteId: string; - suiteName: string; - manifestDigest: string; - }>; - blockers: Array<{ code: string; message: string }>; - runId: string | null; - } - >; - submitTraceCandidateTarget?: DashboardProjectsAction< - { - draftId: string; - sourceId: string; - snapshotId: string; - skillId: string; - suiteId: string; - manifestDigest: string; - }, - { runId: string } - >; } export type DashboardProjectsContribution = diff --git a/packages/dashboard-core/src/host/browser-server-profiles.test.ts b/packages/dashboard-core/src/host/browser-server-profiles.test.ts index ad414388..7bf5b480 100644 --- a/packages/dashboard-core/src/host/browser-server-profiles.test.ts +++ b/packages/dashboard-core/src/host/browser-server-profiles.test.ts @@ -18,6 +18,57 @@ const capabilities = { }; describe("browser server profiles", () => { + it("discards malformed saved rows without losing valid profiles or capability flags", () => { + const controller = createBrowserServerProfileController({ + origin: "https://app.selftune.dev", + capabilities, + load: () => + JSON.stringify([ + null, + { id: 42, kind: "selfhost" }, + { + id: "selfhost:team", + kind: "selfhost", + name: "Team", + origin: "https://team.example.com", + capabilities: { analytics: false, registry: "invalid", futureFeature: true }, + credential: "must-not-persist", + }, + ]), + persist: () => {}, + clearHostState: () => {}, + navigation: { mode: "same_window", navigate: () => {} }, + fetch: async () => new Response(null, { status: 200 }), + }); + expect(controller.snapshot().profiles).toHaveLength(2); + expect( + controller.snapshot().profiles.find((profile) => profile.id === "selfhost:team") + ?.capabilities, + ).toEqual({ ...capabilities, analytics: false }); + expect(JSON.stringify(controller.snapshot())).not.toContain("must-not-persist"); + controller.reconcileExternal("not json"); + controller.reconcileExternal("{}"); + expect(controller.snapshot().profiles).toHaveLength(2); + }); + + it.each([ + null, + [], + { schema_version: 1 }, + { + schema_version: 1, + host: "cloud", + profile: { + id: "cloud:selftune", + name: " ", + origin: "https://app.selftune.dev", + authentication: "cookie", + }, + }, + ])("rejects a malformed runtime profile", (value) => { + expect(() => decodeServerRuntimeProfile(value)).toThrow(); + }); + it("keeps the built-in SelfTune Cloud profile during multitab reconciliation", () => { const controller = createBrowserServerProfileController({ origin: "https://app.selftune.dev", diff --git a/packages/dashboard-core/src/host/browser-server-profiles.ts b/packages/dashboard-core/src/host/browser-server-profiles.ts index 98c9f9a3..59b46204 100644 --- a/packages/dashboard-core/src/host/browser-server-profiles.ts +++ b/packages/dashboard-core/src/host/browser-server-profiles.ts @@ -1,4 +1,5 @@ -import { FEATURE_KEYS, type DashboardFeatureFlags, type DashboardHostKind } from "./capabilities"; +import { z } from "zod"; +import { FEATURE_KEYS, type DashboardFeatureFlags } from "./capabilities"; import { createManagedServerProfile, createServerProfileController, @@ -15,16 +16,58 @@ export const SERVER_PROFILE_CONTRACT_PATH = "/api/server-profile"; const MAX_HANDOFF_BYTES = 16_384; -export interface ServerRuntimeProfile { - readonly schemaVersion: 1; - readonly host: DashboardHostKind; - readonly profile: { - readonly id: string; - readonly name: string; - readonly origin: string; - readonly authentication: "cookie" | "desktop_local"; - }; -} +const nonEmptyString = z.string().refine((value) => value.trim().length > 0); +const runtimeProfileSchema = z + .object({ + schema_version: z.literal(1), + host: z.enum(["local", "cloud", "selfhost"]), + profile: z.object({ + id: nonEmptyString, + name: nonEmptyString, + origin: z.url().transform((value) => new URL(value).origin), + authentication: z.enum(["cookie", "desktop_local"]), + }), + }) + .refine(({ host, profile }) => { + if (host === "local") + return profile.id === "local:this-mac" && profile.authentication === "desktop_local"; + if (host === "cloud") + return profile.id === "cloud:selftune" && profile.authentication === "cookie"; + return profile.id.startsWith("selfhost:") && profile.authentication === "cookie"; + }, "This server profile identity does not match its host kind.") + .transform(({ schema_version, host, profile }) => ({ + schemaVersion: schema_version, + host, + profile, + })); + +export type ServerRuntimeProfile = z.output; +export const decodeServerRuntimeProfile = runtimeProfileSchema.parse; + +const persistedFlag = z.boolean().optional().catch(undefined); +const persistedProfileSchema = z.object({ + id: z.string(), + kind: z.enum(["cloud", "selfhost"]), + name: z.string(), + origin: z.string(), + capabilities: z + .object({ + analytics: persistedFlag, + registry: persistedFlag, + signals: persistedFlag, + proposals: persistedFlag, + billing: persistedFlag, + teamAdmin: persistedFlag, + runtimeStatus: persistedFlag, + }) + .catch({}), +}); +const persistedProfilesSchema = z.array(persistedProfileSchema.nullable().catch(null)); +const handoffPath = z + .object({ + handoff_path: z.string().startsWith("/api/auth/session/handoff?"), + }) + .transform((response) => response.handoff_path).parse; export type BrowserServerProfileNavigation = | { @@ -36,53 +79,12 @@ export type BrowserServerProfileNavigation = readonly navigate: (url: string) => void | Promise; }; -function property(value: object, key: string): unknown { - return Reflect.get(value, key); -} - -function requiredString(value: object, key: string): string { - const candidate = property(value, key); - if (typeof candidate !== "string" || candidate.trim().length === 0) { - throw new TypeError(`Server profile contract field ${key} must be a non-empty string.`); - } - return candidate; -} - -export function decodeServerRuntimeProfile(value: unknown): ServerRuntimeProfile { - if (typeof value !== "object" || value === null || property(value, "schema_version") !== 1) { - throw new TypeError("This server does not expose the SelfTune profile contract."); - } - const host = property(value, "host"); - if (host !== "local" && host !== "cloud" && host !== "selfhost") { - throw new TypeError("This server reports an unsupported dashboard host."); - } - const rawProfile = property(value, "profile"); - if (typeof rawProfile !== "object" || rawProfile === null) { - throw new TypeError("This server profile contract is missing its profile."); - } - const id = requiredString(rawProfile, "id"); - const name = requiredString(rawProfile, "name"); - const origin = new URL(requiredString(rawProfile, "origin")).origin; - const authentication = property(rawProfile, "authentication"); - if (authentication !== "cookie" && authentication !== "desktop_local") { - throw new TypeError("This server reports an unsupported authentication method."); - } - if ( - (host === "local" && (id !== "local:this-mac" || authentication !== "desktop_local")) || - (host === "cloud" && (id !== "cloud:selftune" || authentication !== "cookie")) || - (host === "selfhost" && (!id.startsWith("selfhost:") || authentication !== "cookie")) - ) { - throw new TypeError("This server profile identity does not match its host kind."); - } - return { - schemaVersion: 1, - host, - profile: { id, name, origin, authentication }, - }; -} +export type BrowserProfileFetch = ( + ...args: Parameters +) => ReturnType; export async function fetchServerRuntimeProfile( - fetchImpl: typeof globalThis.fetch, + fetchImpl: BrowserProfileFetch, origin: string, ): Promise { const response = await fetchImpl(new URL(SERVER_PROFILE_CONTRACT_PATH, origin), { @@ -96,34 +98,19 @@ export async function fetchServerRuntimeProfile( } function parsePersistedProfiles( - value: unknown, + serialized: string, fallbackCapabilities: DashboardFeatureFlags, ): ServerProfile[] { - if (!Array.isArray(value)) return []; + const candidates = persistedProfilesSchema.parse(JSON.parse(serialized)); const profiles: ServerProfile[] = []; - for (const candidate of value) { - if (typeof candidate !== "object" || candidate === null) continue; - const id = property(candidate, "id"); - const kind = property(candidate, "kind"); - const name = property(candidate, "name"); - const origin = property(candidate, "origin"); - if ( - typeof id !== "string" || - (kind !== "cloud" && kind !== "selfhost") || - typeof name !== "string" || - typeof origin !== "string" - ) { - continue; - } - - const persistedCapabilities = property(candidate, "capabilities"); + for (const candidate of candidates) { + if (!candidate) continue; + const { id, kind, name, origin, capabilities: persistedCapabilities } = candidate; const capabilities = { ...fallbackCapabilities }; - if (typeof persistedCapabilities === "object" && persistedCapabilities !== null) { - for (const key of FEATURE_KEYS) { - const flag = property(persistedCapabilities, key); - if (typeof flag === "boolean") capabilities[key] = flag; - } + for (const key of FEATURE_KEYS) { + const flag = persistedCapabilities[key]; + if (flag !== undefined) capabilities[key] = flag; } try { @@ -158,15 +145,13 @@ export function consumeServerProfilesHandoff( if (!handoff) return null; url.searchParams.delete(SERVER_PROFILES_HANDOFF_PARAM); if (new TextEncoder().encode(handoff).byteLength > MAX_HANDOFF_BYTES) return null; - let parsed: unknown; try { - parsed = JSON.parse(handoff); + const profiles = parsePersistedProfiles(handoff, fallbackCapabilities); + if (profiles.length === 0) return null; + return { cleanUrl: url.toString(), serialized: serializeManagedServerProfiles(profiles) }; } catch { return null; } - const profiles = parsePersistedProfiles(parsed, fallbackCapabilities); - if (profiles.length === 0) return null; - return { cleanUrl: url.toString(), serialized: serializeManagedServerProfiles(profiles) }; } function statusForResponse(response: Response): ServerProfileStatus { @@ -201,7 +186,7 @@ function incompatibleProfile(profile: ServerProfile, message: string): ServerPro function profileForCurrentRuntime( runtime: ServerRuntimeProfile | undefined, capabilities: DashboardFeatureFlags, -): { readonly currentServer?: ServerProfile; readonly thisMac?: ServerProfile } { +) { if (!runtime) return {}; if (runtime.host === "local") { return { @@ -223,17 +208,6 @@ function profileForCurrentRuntime( return {}; } -function handoffPath(response: unknown): string { - if (typeof response !== "object" || response === null) { - throw new TypeError("The Self-host session handoff response is invalid."); - } - const value = property(response, "handoff_path"); - if (typeof value !== "string" || !value.startsWith("/api/auth/session/handoff?")) { - throw new TypeError("The Self-host session handoff response is invalid."); - } - return value; -} - export function createBrowserServerProfileController(options: { readonly origin: string; readonly capabilities: DashboardFeatureFlags; @@ -243,12 +217,12 @@ export function createBrowserServerProfileController(options: { readonly clearHostState: () => void | Promise; readonly currentPath?: () => string; readonly navigation: BrowserServerProfileNavigation; - readonly fetch: typeof globalThis.fetch; + readonly fetch: BrowserProfileFetch; }) { const credentials = new Map(); - let persisted: unknown = []; + let persisted: ServerProfile[] = []; try { - persisted = JSON.parse(options.load() ?? "[]"); + persisted = parsePersistedProfiles(options.load() ?? "[]", options.capabilities); } catch { persisted = []; } @@ -270,7 +244,7 @@ export function createBrowserServerProfileController(options: { }); const requiredProfiles = [cloud, ...(currentServer ? [currentServer] : [])]; const initial = normalizeServerProfiles( - [...parsePersistedProfiles(persisted, options.capabilities), ...requiredProfiles].filter( + [...persisted, ...requiredProfiles].filter( (profile, index, profiles) => profiles.findIndex((candidate) => candidate.id === profile.id) === index, ), @@ -358,26 +332,18 @@ export function createBrowserServerProfileController(options: { return { ...controller, reconcileExternal(serialized: string): void { - let parsed: unknown; + let profiles: ServerProfile[]; try { - parsed = JSON.parse(serialized); + profiles = parsePersistedProfiles(serialized, options.capabilities); } catch { - controller.reconcileExternal(serialized); return; } - if (!Array.isArray(parsed)) { - controller.reconcileExternal(serialized); - return; - } - const ids = new Set( - parsed.flatMap((candidate) => { - if (typeof candidate !== "object" || candidate === null) return []; - const id = property(candidate, "id"); - return typeof id === "string" ? [id] : []; - }), - ); + const ids = new Set(profiles.map((profile) => profile.id)); controller.reconcileExternal( - JSON.stringify([...parsed, ...requiredProfiles.filter((profile) => !ids.has(profile.id))]), + JSON.stringify([ + ...profiles, + ...requiredProfiles.filter((profile) => !ids.has(profile.id)), + ]), ); }, }; diff --git a/packages/dashboard-core/src/host/server-profiles.test.ts b/packages/dashboard-core/src/host/server-profiles.test.ts index a54df18d..44080ffa 100644 --- a/packages/dashboard-core/src/host/server-profiles.test.ts +++ b/packages/dashboard-core/src/host/server-profiles.test.ts @@ -22,6 +22,43 @@ const capabilities = { }; describe("server profiles", () => { + it("rejects malformed external rows without losing valid profiles or accepting credentials", () => { + const cloud = createManagedServerProfile({ + id: "cloud:selftune", + kind: "cloud", + name: "Cloud", + origin: "https://app.selftune.dev", + authentication: { kind: "cookie" }, + capabilities, + }); + const controller = createServerProfileController({ + initialProfiles: [cloud], + activeProfileId: cloud.id, + persist: () => {}, + validate: async (profile) => profile, + switchProfile: async () => "activated", + }); + const initial = controller.snapshot(); + for (const malformed of ["{", "null", "{}", "42"]) { + controller.reconcileExternal(malformed); + expect(controller.snapshot()).toBe(initial); + } + controller.reconcileExternal( + JSON.stringify([ + null, + 42, + {}, + { ...cloud, authentication: null }, + { ...cloud, capabilities: { ...capabilities, analytics: "true" } }, + { ...cloud, status: { state: "unreachable" } }, + { ...cloud, token: "secret", authentication: { kind: "cookie", password: "secret" } }, + ]), + ); + expect(controller.snapshot().profiles).toEqual([cloud]); + expect(controller.snapshot().activeProfileId).toBe(cloud.id); + const profileWithCredentials = { ...cloud, token: "secret" }; + expect(serializeManagedServerProfiles([profileWithCredentials])).not.toContain("secret"); + }); it("keeps one authoritative This Mac profile and rejects persisted shadows", () => { const thisMac = createThisMacProfile({ origin: "http://127.0.0.1:3141", capabilities }); const shadow = { ...thisMac, name: "Fake Mac", origin: "https://attacker.invalid" }; diff --git a/packages/dashboard-core/src/host/server-profiles.ts b/packages/dashboard-core/src/host/server-profiles.ts index ca21a8e9..3f6e74f5 100644 --- a/packages/dashboard-core/src/host/server-profiles.ts +++ b/packages/dashboard-core/src/host/server-profiles.ts @@ -1,4 +1,5 @@ import type { DashboardFeatureFlags, DashboardHostKind } from "./capabilities"; +import { z } from "zod"; export type ServerProfileAuthentication = | { readonly kind: "desktop_local" } @@ -25,6 +26,34 @@ export interface ServerProfile { readonly system: boolean; } +const serializedProfileSchema = z.object({ + id: z.string(), + kind: z.enum(["local", "cloud", "selfhost"]), + name: z.string(), + origin: z.string(), + authentication: z.object({ kind: z.enum(["desktop_local", "cookie", "bearer_session"]) }), + capabilities: z.object({ + analytics: z.boolean(), + registry: z.boolean(), + signals: z.boolean(), + proposals: z.boolean(), + billing: z.boolean(), + teamAdmin: z.boolean(), + runtimeStatus: z.boolean(), + }), + status: z.discriminatedUnion("state", [ + z.object({ state: z.literal("ready") }), + z.object({ + state: z.enum(["unreachable", "unauthenticated", "incompatible", "upgrade_required"]), + message: z.string(), + actionLabel: z.string(), + actionHref: z.string().optional(), + }), + ]), + system: z.boolean(), +}) satisfies z.ZodType; +const serializedProfilesSchema = z.array(serializedProfileSchema.nullable().catch(null)); + export interface ManagedServerProfileInput { readonly id: string; readonly kind: Extract; @@ -171,7 +200,9 @@ export function removeServerProfile( } export function serializeManagedServerProfiles(profiles: ReadonlyArray): string { - return JSON.stringify(profiles.filter(isManagedProfile)); + return JSON.stringify( + profiles.filter(isManagedProfile).map((profile) => serializedProfileSchema.parse(profile)), + ); } export interface ServerProfilesSnapshot { @@ -274,17 +305,14 @@ export function createServerProfileController(options: { notify(); }, reconcileExternal(serialized) { - let parsed: unknown; + let parsed: Array; try { - parsed = JSON.parse(serialized); + parsed = serializedProfilesSchema.parse(JSON.parse(serialized)); } catch { return; } - if (!Array.isArray(parsed)) return; profiles = normalizeServerProfiles( - parsed.filter( - (value): value is ServerProfile => typeof value === "object" && value !== null, - ), + parsed.filter((value) => value !== null), options.thisMac, ); if (!profiles.some((profile) => profile.id === activeProfileId)) { diff --git a/packages/dashboard-core/src/models/library.ts b/packages/dashboard-core/src/models/library.ts index e3dce021..fa96258a 100644 --- a/packages/dashboard-core/src/models/library.ts +++ b/packages/dashboard-core/src/models/library.ts @@ -50,11 +50,27 @@ export interface LibraryLocationModel { removable: boolean; } +export interface SkillContextEntry { + harness: string | null; + scope: string; + projectRoot: string | null; + path: string; + state: "active" | "saved"; + metadata?: { + name: string; + description: string; + whenToUse?: string; + disableModelInvocation: boolean; + originalSkillPath: string; + }; +} + export interface LibraryArchiveRecommendationModel { classification: string; reason: string; skillPath: string; packagePath: string; + contentHash?: string | null; } export interface LibraryConsolidationRecommendationModel { @@ -101,8 +117,13 @@ export interface LibrarySkillModel { lastUsedAt?: string | null; triggerTrend?: LibraryTriggerTrendPointModel[]; lifetimeTriggerCount?: number | null; + instructionBytes?: number | null; detailHref?: string | null; restoreId?: string | null; + onDemandSource?: { skillPath: string; packagePath: string; contentHash: string } | null; + onDemandSources?: readonly { skillPath: string; packagePath: string; contentHash: string }[]; + onDemandReason?: string | null; + contextEntries?: readonly SkillContextEntry[]; archiveRecommendation?: LibraryArchiveRecommendationModel | null; consolidationRecommendation?: LibraryConsolidationRecommendationModel | null; statusBadge?: LibraryStatusBadgeModel | null; @@ -226,11 +247,14 @@ export interface LibraryMergeConnectionModel { export interface LibraryArchiveInput { skillName: string; skillPath: string; + expectedContentHash?: string; } export interface LibraryArchiveBatchResult { succeeded: number; failed: number; + receipts?: readonly { skillName: string; restoreId: string }[]; + failures?: readonly { skillName: string; message: string }[]; } export interface LibraryPrepareMergeInput { diff --git a/packages/dashboard-core/src/routes/manifest.test.ts b/packages/dashboard-core/src/routes/manifest.test.ts index 33277f7f..1d768062 100644 --- a/packages/dashboard-core/src/routes/manifest.test.ts +++ b/packages/dashboard-core/src/routes/manifest.test.ts @@ -72,7 +72,9 @@ describe("resolveDashboardRoutes", () => { expect(byId.get("settings")?.access).toBe("enabled"); expect(byId.has("status")).toBe(false); expect( - ["registry", "improve", "proposals", "unmatched", "analytics"].filter((id) => byId.has(id)), + (["registry", "improve", "proposals", "unmatched", "analytics"] as const).filter((id) => + byId.has(id), + ), ).toEqual([]); }); }); diff --git a/packages/dashboard-core/src/routes/types.ts b/packages/dashboard-core/src/routes/types.ts index f9b8d11c..47392d02 100644 --- a/packages/dashboard-core/src/routes/types.ts +++ b/packages/dashboard-core/src/routes/types.ts @@ -22,8 +22,8 @@ export function resolveRoutePredicate( capabilities: Capabilities, fallback: boolean, ): boolean { - if (typeof predicate === "undefined") return fallback; - if (typeof predicate === "boolean") return predicate; + if (predicate === undefined) return fallback; + if (predicate === true || predicate === false) return predicate; return predicate(capabilities); } diff --git a/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx b/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx index e0b0acaf..d53c84e6 100644 --- a/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx +++ b/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { DashboardHostProvider, type DashboardHostModules } from "../host"; import type { LibraryInventoryModel, ProjectsInventoryModel } from "../models"; diff --git a/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx b/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx index 856f81a5..e21816dc 100644 --- a/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx +++ b/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx @@ -5,15 +5,15 @@ import { DurableDecisionCard } from "./DurableDecisionCard"; const common = { id: "decision-1", - status: "pending", + status: "pending" as const, createdAt: "2026-07-16T10:00:00.000Z", updatedAt: "2026-07-16T10:00:00.000Z", expiresAt: "2026-07-17T10:00:00.000Z", decidedAt: null, failure: null, - audit: [{ event: "prepared", at: "2026-07-16T10:00:00.000Z", reason: null }], + audit: [{ event: "prepared" as const, at: "2026-07-16T10:00:00.000Z", reason: null }], hasRecoveryReceipt: false, -} as const; +}; describe("shared durable decision UI", () => { it("renders common lifecycle and typed impact presenters for all consumers", () => { diff --git a/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts b/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts index 3dd98035..b1d29e3a 100644 --- a/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts +++ b/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts @@ -10,7 +10,7 @@ describe("resolveOverviewWatchlistChange", () => { it("prefers an explicit watchlist change handler", () => { const explicit = vi.fn(); const host = { - actions: { + mutations: { updateOverviewWatchlist: vi.fn(), }, }; @@ -21,7 +21,7 @@ describe("resolveOverviewWatchlistChange", () => { initialSkills: [], onChange: explicit, }, - host as never, + host, ), ).toBe(explicit); }); @@ -35,11 +35,10 @@ describe("resolveOverviewWatchlistChange", () => { initialSkills: ["selftune"], }, { - actions: { - openUpgrade: vi.fn(), + mutations: { updateOverviewWatchlist: hostAction, }, - } as never, + }, ), ).toBe(hostAction); }); @@ -52,10 +51,8 @@ describe("resolveOverviewWatchlistChange", () => { initialSkills: [], }, { - actions: { - openUpgrade: vi.fn(), - }, - } as never, + mutations: {}, + }, ), ).toBeUndefined(); }); @@ -67,21 +64,18 @@ describe("resolveOverviewWatchlistLoad", () => { expect( resolveOverviewWatchlistLoad({ - actions: { - openUpgrade: vi.fn(), + mutations: { getOverviewWatchlist: hostLoader, }, - } as never), + }), ).toBe(hostLoader); }); it("returns undefined when the host does not provide a loader", () => { expect( resolveOverviewWatchlistLoad({ - actions: { - openUpgrade: vi.fn(), - }, - } as never), + mutations: {}, + }), ).toBeUndefined(); }); }); diff --git a/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx b/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx index cc4f3a9b..49ca005f 100644 --- a/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx +++ b/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx @@ -1,46 +1,5 @@ -import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("./OverviewCoreSurface", () => ({ - OverviewCoreSurface: ({ - beforeHero, - betweenHeroAndFeed, - afterFeed, - }: { - beforeHero?: ReactNode; - betweenHeroAndFeed?: ReactNode; - afterFeed?: ReactNode; - }) => ( -
-
{beforeHero}
-
{betweenHeroAndFeed}
-
{afterFeed}
-
- ), -})); - -vi.mock("./OverviewOnboardingBanner", () => ({ - OverviewOnboardingBanner: ({ skillCount }: { skillCount: number }) => ( -
Onboarding {skillCount}
- ), -})); - -vi.mock("./OverviewCleanupCheckpoint", () => ({ - OverviewCleanupCheckpoint: ({ candidates }: { candidates: Array }) => ( -
Cleanup {candidates.length}
- ), -})); - -vi.mock("./OverviewComparisonSurface", () => ({ - OverviewComparisonSurface: ({ rows }: { rows: Array }) => ( -
Comparison {rows.length}
- ), -})); - -vi.mock("./OverviewRunSummary", () => ({ - OverviewRunSummary: ({ runCount }: { runCount: number }) =>
Run Summary {runCount}
, -})); +import { describe, expect, it } from "vitest"; import { OverviewCompositionSurface } from "./OverviewCompositionSurface"; @@ -100,15 +59,15 @@ describe("OverviewCompositionSurface", () => { />, ); - expect(html).toContain("Onboarding 0"); - expect(html).toContain("Comparison 1"); - expect(html).toContain("Cleanup 1"); + expect(html).toContain("Use the local dashboard to understand a skill before you change it."); + expect(html).toContain("Skill Comparison"); + expect(html).toContain("Cleanup ready"); expect(html).toContain("Before Feed"); - expect(html).toContain("Run Summary 4"); + expect(html).toContain("Last Cycle"); expect(html).toContain("After Feed"); - expect(html.indexOf("Cleanup 1")).toBeLessThan(html.indexOf("Comparison 1")); - expect(html.indexOf("Comparison 1")).toBeLessThan(html.indexOf("Before Feed")); - expect(html.indexOf("Run Summary 4")).toBeLessThan(html.indexOf("After Feed")); + expect(html.indexOf("Cleanup ready")).toBeLessThan(html.indexOf("Skill Comparison")); + expect(html.indexOf("Skill Comparison")).toBeLessThan(html.indexOf("Before Feed")); + expect(html.indexOf("Last Cycle")).toBeLessThan(html.indexOf("After Feed")); expect(html).toContain('
Before Feed
'); }); diff --git a/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.stories.tsx b/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.stories.tsx index b18ddf0e..3192622f 100644 --- a/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.stories.tsx +++ b/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; import { OverviewOnboardingBanner } from "./OverviewOnboardingBanner"; diff --git a/packages/dashboard-core/src/screens/projects/AssignedSkillSets.interaction.test.tsx b/packages/dashboard-core/src/screens/projects/AssignedSkillSets.interaction.test.tsx index 99225791..8c94e0d8 100644 --- a/packages/dashboard-core/src/screens/projects/AssignedSkillSets.interaction.test.tsx +++ b/packages/dashboard-core/src/screens/projects/AssignedSkillSets.interaction.test.tsx @@ -84,7 +84,7 @@ function contribution({ }: { assignments?: readonly ProjectAssignedSkillSetModel[]; actions: DashboardAssignedSkillSetsActions; - refresh?: ReturnType; + refresh?: () => void | Promise; }): DashboardAssignedSkillSetsContribution { return { access: "available", diff --git a/packages/dashboard-core/src/screens/projects/ProjectCaptureCandidates.tsx b/packages/dashboard-core/src/screens/projects/ProjectCaptureCandidates.tsx index eabb3bfa..010fac1e 100644 --- a/packages/dashboard-core/src/screens/projects/ProjectCaptureCandidates.tsx +++ b/packages/dashboard-core/src/screens/projects/ProjectCaptureCandidates.tsx @@ -3,16 +3,9 @@ import { useState } from "react"; import { FolderInputIcon, FolderKanbanIcon } from "lucide-react"; -import type { ProjectCaptureCandidateModel, ProjectConnectionId } from "../../models"; +import type { ProjectCaptureCandidateModel } from "../../models"; import { Badge, Button } from "@selftune/ui/primitives"; - -const CONNECTION_LABELS: Record = { - codex: "Codex", - claude_code: "Claude Code", - opencode: "OpenCode", - openclaw: "OpenClaw", - pi: "Pi", -}; +import { CONNECTION_LABELS } from "./skill-set-constants"; export function ProjectCaptureCandidates({ candidates, diff --git a/packages/dashboard-core/src/screens/projects/ProjectsScreen.assignments.test.tsx b/packages/dashboard-core/src/screens/projects/ProjectsScreen.assignments.test.tsx index e6182e45..972d7ff0 100644 --- a/packages/dashboard-core/src/screens/projects/ProjectsScreen.assignments.test.tsx +++ b/packages/dashboard-core/src/screens/projects/ProjectsScreen.assignments.test.tsx @@ -66,6 +66,7 @@ describe("Projects assignment composition", () => { failure: null, canInstall: true, canRollback: false, + syncStatus: "synced", }, ], isLoading: false, diff --git a/packages/dashboard-core/src/screens/projects/ProjectsScreen.test.tsx b/packages/dashboard-core/src/screens/projects/ProjectsScreen.test.tsx index 03bd1d99..a1ccf83f 100644 --- a/packages/dashboard-core/src/screens/projects/ProjectsScreen.test.tsx +++ b/packages/dashboard-core/src/screens/projects/ProjectsScreen.test.tsx @@ -497,7 +497,7 @@ describe("shared Projects screen", () => { }); it("keeps detected project capture in its focused project surface", () => { - const candidate = inventory.captureCandidates[0]; + const candidate = inventory.captureCandidates?.[0]; if (!candidate) throw new Error("Expected a capture candidate fixture."); const html = renderToStaticMarkup( { it("presents conflict review and host-owned resolution before apply", () => { const resolveConflict = { access: "available", - execute: async () => ({ - skillSetId: "software-development", - skillSetName: "Software Development", - projectRoot: "/projects/app", - creates: 1, - unchanged: 0, - conflicts: 0, - missingDependencies: 0, - operations: [], - }), + execute: async () => { + throw new Error("Server rendering must not execute conflict resolution."); + }, } as const; const html = renderToStaticMarkup( undefined} onReviewExpansion={() => undefined} diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx index da34c19b..520dccf8 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx @@ -25,7 +25,12 @@ const skillSet: ProjectSkillSetModel = { updatedAt: "2026-07-22T00:00:00.000Z", }; -function action(execute: ReturnType) { +function action( + execute: Extract< + NonNullable, + { access: "available" } + >["execute"], +) { return { access: "available" as const, execute, @@ -33,7 +38,10 @@ function action(execute: ReturnType) { } function managedCloudAction( - execute: ReturnType, + execute: Extract< + NonNullable, + { access: "available" } + >["execute"], ): Extract, { access: "available" }> { return { access: "available", diff --git a/packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.stories.tsx b/packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.stories.tsx index fd6085d8..e8aab0d3 100644 --- a/packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.stories.tsx +++ b/packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn } from "storybook/test"; import type { ProjectSkillSetPackModel } from "../../models"; diff --git a/packages/dashboard-core/src/screens/projects/SkillSetIntelligencePanels.tsx b/packages/dashboard-core/src/screens/projects/SkillSetIntelligencePanels.tsx index 5d134e09..7f842993 100644 --- a/packages/dashboard-core/src/screens/projects/SkillSetIntelligencePanels.tsx +++ b/packages/dashboard-core/src/screens/projects/SkillSetIntelligencePanels.tsx @@ -43,51 +43,36 @@ import { Textarea, } from "@selftune/ui/primitives"; -type TraceCandidateTarget = { - sourceId: string; - snapshotId: string; - skillId: string; - suiteId: string; - suiteName: string; - manifestDigest: string; -}; - -type TraceCandidateTargetResult = { - targets: TraceCandidateTarget[]; - blockers: Array<{ code: string; message: string }>; - runId: string | null; -}; - -const PATTERN_LABELS: Record = { +const PATTERN_LABELS = { workflow: "Ordered workflow", co_usage: "Used together", project: "Project pattern", -}; +} satisfies Record; -const EVIDENCE_LABELS: Record = { +const EVIDENCE_LABELS = { exploratory: "Exploratory", supported: "Supported", validated: "Validated", -}; +} satisfies Record; -const DISMISSAL_LABELS: Record< - Exclude< - ProjectSkillSetSuggestionReviewReasonCode, - "accepted_as_suggested" | "edited_before_creation" - >, - string -> = { +const DISMISSAL_LABELS = { not_relevant_now: "Not relevant right now", skills_should_remain_separate: "These skills should stay separate", not_a_real_pattern: "This isn't a real pattern", already_have_workflow: "I already have this workflow", other: "Other", -}; +} satisfies Record< + Exclude< + ProjectSkillSetSuggestionReviewReasonCode, + "accepted_as_suggested" | "edited_before_creation" + >, + string +>; type DismissalReasonCode = keyof typeof DISMISSAL_LABELS; function isDismissalReasonCode(value: string): value is DismissalReasonCode { - return value in DISMISSAL_LABELS; + return Object.hasOwn(DISMISSAL_LABELS, value); } const OUTCOME_METRICS: Array<{ @@ -204,20 +189,14 @@ function TraceSignalsPanel({ signals, patterns, prepareCandidate, - loadTargets, - submitTarget, }: { signals: readonly ProjectSkillTraceSignalModel[]; patterns: readonly ProjectSkillExecutionPatternModel[]; prepareCandidate?: DashboardProjectsActions["prepareTraceCandidate"]; - loadTargets?: DashboardProjectsActions["traceCandidateTargets"]; - submitTarget?: DashboardProjectsActions["submitTraceCandidateTarget"]; }) { const [review, setReview] = useState(null); const [preparationError, setPreparationError] = useState(null); - const [targets, setTargets] = useState(null); - const [scheduledRunId, setScheduledRunId] = useState(null); - const [pendingAction, setPendingAction] = useState<"prepare" | "targets" | "submit" | null>(null); + const [pendingAction, setPendingAction] = useState<"prepare" | null>(null); const patternsBySkillName = new Map( patterns.map((pattern) => [pattern.skillName.trim().toLowerCase(), pattern]), ); @@ -226,8 +205,6 @@ function TraceSignalsPanel({ if (prepareCandidate?.access !== "available" || pendingAction) return; setPendingAction("prepare"); setPreparationError(null); - setTargets(null); - setScheduledRunId(null); try { setReview(await prepareCandidate.execute(patternId)); } catch (error) { @@ -237,35 +214,6 @@ function TraceSignalsPanel({ } } - async function loadEligibleTargets(draftId: string) { - if (loadTargets?.access !== "available" || pendingAction) return; - setPendingAction("targets"); - setPreparationError(null); - try { - setTargets(await loadTargets.execute(draftId)); - } catch (error) { - setPreparationError(error instanceof Error ? error.message : "Could not load Cloud targets."); - } finally { - setPendingAction(null); - } - } - - async function submitEligibleTarget(draftId: string, target: TraceCandidateTarget) { - if (submitTarget?.access !== "available" || pendingAction) return; - setPendingAction("submit"); - setPreparationError(null); - try { - const receipt = await submitTarget.execute({ draftId, ...target }); - setScheduledRunId(receipt.runId); - } catch (error) { - setPreparationError( - error instanceof Error ? error.message : "Could not schedule Cloud evaluation.", - ); - } finally { - setPendingAction(null); - } - } - if (signals.length === 0) { return ( @@ -341,54 +289,9 @@ function TraceSignalsPanel({

{review.candidate.changedLines} changed lines · {review.candidate.targetSection}

- {review.draftId && loadTargets?.access === "available" ? ( - - ) : null} - {targets ? ( -
- {targets.runId ?

Already scheduled: {targets.runId}

: null} - {targets.targets.map((target) => ( -
- {target.suiteName} - {submitTarget?.access === "available" && review.draftId ? ( - - ) : null} -
- ))} - {targets.targets.length === 0 ? ( -

No compatible outcome-task targets are available.

- ) : null} - {targets.blockers.map((blocker) => ( -

- {blocker.message} -

- ))} -
- ) : null} - {scheduledRunId ? ( -
- Review scheduled Cloud evaluation - - ) : null} +

+ This candidate and its evaluation evidence stay on this device. +

) : (

{review.failureReason ?? "Candidate is not ready."}

@@ -428,8 +331,6 @@ export function SkillSetIntelligencePanels({ intelligence, reviewAction, prepareCandidate, - loadTargets, - submitTarget, onReview, onReviewExpansion, view = "suggestions", @@ -439,8 +340,6 @@ export function SkillSetIntelligencePanels({ intelligence: DashboardProjectsIntelligenceQueryState; reviewAction: DashboardProjectsActions["reviewSuggestion"]; prepareCandidate?: DashboardProjectsActions["prepareTraceCandidate"]; - loadTargets?: DashboardProjectsActions["traceCandidateTargets"]; - submitTarget?: DashboardProjectsActions["submitTraceCandidateTarget"]; onReview(suggestion: ProjectSkillSetSuggestionModel): void; onReviewExpansion(expansion: ProjectCatalogSkillSetExpansionModel): void; view?: "suggestions" | "outcomes" | "trace-signals"; @@ -510,8 +409,6 @@ export function SkillSetIntelligencePanels({ signals={report.traceSignals} patterns={report.executionPatterns} prepareCandidate={prepareCandidate} - loadTargets={loadTargets} - submitTarget={submitTarget} /> ) : null ) : view === "suggestions" ? ( diff --git a/packages/dashboard-core/src/screens/projects/TraceSignalsPanel.interaction.test.tsx b/packages/dashboard-core/src/screens/projects/TraceSignalsPanel.interaction.test.tsx index 30f55db3..5eb38b8d 100644 --- a/packages/dashboard-core/src/screens/projects/TraceSignalsPanel.interaction.test.tsx +++ b/packages/dashboard-core/src/screens/projects/TraceSignalsPanel.interaction.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { DashboardProjectsIntelligenceQueryState } from "../../host"; import type { ProjectSkillSetIntelligenceModel } from "../../models"; @@ -55,15 +55,8 @@ const query: DashboardProjectsIntelligenceQueryState = { refresh() {}, }; -describe("trace candidate Cloud evaluation", () => { - it("requires an explicit eligible target and renders its maintained receipt link", async () => { - let resolveSubmission!: (receipt: { runId: string }) => void; - const submit = vi.fn( - () => - new Promise<{ runId: string }>((resolve) => { - resolveSubmission = resolve; - }), - ); +describe("local trace candidate review", () => { + it("prepares a local review without offering a hosted submission", async () => { render( { }, }), }} - loadTargets={{ - access: "available", - isPending: false, - execute: async () => ({ - runId: null, - blockers: [], - targets: [ - { - sourceId: "source", - snapshotId: "snapshot", - skillId: "skill", - suiteId: "suite", - suiteName: "Reliable outcome suite", - manifestDigest: "sha256:digest", - }, - ], - }), - }} - submitTarget={{ access: "available", isPending: false, execute: submit }} />, ); fireEvent.click(screen.getByText("Prepare candidate")); - await waitFor(() => expect(screen.getByText("Load Cloud targets")).toBeTruthy()); - fireEvent.click(screen.getByText("Load Cloud targets")); - await waitFor(() => expect(screen.getByText("Reliable outcome suite")).toBeTruthy()); - fireEvent.click(screen.getByText("Evaluate")); - fireEvent.click(screen.getByText("Evaluate")); - await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); - resolveSubmission({ runId: "run_123" }); - await waitFor(() => expect(screen.getByText("Review scheduled Cloud evaluation")).toBeTruthy()); - expect(screen.getByText("Review scheduled Cloud evaluation").getAttribute("href")).toBe( - "https://app.selftune.dev/improve/run_123", - ); + await waitFor(() => expect(screen.getByText("Body")).toBeTruthy()); + expect(screen.getByText("Target revision: rev")).toBeTruthy(); + expect(screen.getByText("Evidence: 3/3 resolved")).toBeTruthy(); + expect( + screen.getByText("This candidate and its evaluation evidence stay on this device."), + ).toBeTruthy(); + expect(screen.queryByText("Load Cloud targets")).toBeNull(); + expect(screen.queryByText("Review scheduled Cloud evaluation")).toBeNull(); }); - it("shows unavailable target states without enabling an implicit submission", async () => { + it("keeps local preparation failures visible", async () => { render( { onReview={() => undefined} onReviewExpansion={() => undefined} prepareCandidate={{ - access: "available", - isPending: false, - execute: async () => ({ - draftId: "draft-stale", - patternId: "execution-pattern-diagnose", - cohortFingerprint: "sha256:abc", - targetRevision: "rev", - readiness: "review_ready", - failureReason: null, - evidence: { cohortEntries: 3, resolvedEntries: 3 }, - candidate: { - body: "Body", - rationale: "Why", - changedLines: 1, - targetSection: "Workflow", - uncertainty: [], - }, - }), - }} - loadTargets={{ access: "available", isPending: false, execute: async () => { - throw new Error("Cloud is not linked; this candidate is stale."); + throw new Error("The local skill revision changed."); }, }} />, ); fireEvent.click(screen.getByText("Prepare candidate")); - await waitFor(() => expect(screen.getByText("Load Cloud targets")).toBeTruthy()); - fireEvent.click(screen.getByText("Load Cloud targets")); - await waitFor(() => - expect(screen.getByText("Cloud is not linked; this candidate is stale.")).toBeTruthy(), - ); - expect(screen.queryByText("Evaluate")).toBeNull(); + await waitFor(() => expect(screen.getByText("The local skill revision changed.")).toBeTruthy()); + expect(screen.queryByText("Load Cloud targets")).toBeNull(); }); }); diff --git a/packages/dashboard-core/src/screens/projects/skill-set-constants.test.ts b/packages/dashboard-core/src/screens/projects/skill-set-constants.test.ts new file mode 100644 index 00000000..d641652e --- /dev/null +++ b/packages/dashboard-core/src/screens/projects/skill-set-constants.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import type { ProjectConnectionId } from "../../models"; +import { CONNECTIONS, CONNECTION_LABELS } from "./skill-set-constants"; + +test("connection choices cover every supported harness once with the shared display name", () => { + const expected = { + codex: "Codex", + claude_code: "Claude Code", + opencode: "OpenCode", + openclaw: "OpenClaw", + pi: "Pi", + } satisfies Record; + expect(CONNECTION_LABELS).toEqual(expected); + expect(CONNECTIONS).toEqual(Object.entries(expected)); +}); diff --git a/packages/dashboard-core/src/screens/projects/skill-set-constants.ts b/packages/dashboard-core/src/screens/projects/skill-set-constants.ts index 0cc4a873..9136f46b 100644 --- a/packages/dashboard-core/src/screens/projects/skill-set-constants.ts +++ b/packages/dashboard-core/src/screens/projects/skill-set-constants.ts @@ -1,15 +1,13 @@ import type { ProjectConnectionId } from "../../models"; -export const CONNECTION_LABELS: Record = { - codex: "Codex", - claude_code: "Claude Code", - opencode: "OpenCode", - openclaw: "OpenClaw", - pi: "Pi", -}; +export const CONNECTIONS = [ + ["codex", "Codex"], + ["claude_code", "Claude Code"], + ["opencode", "OpenCode"], + ["openclaw", "OpenClaw"], + ["pi", "Pi"], +] satisfies Array<[ProjectConnectionId, string]>; -export const CONNECTIONS = Object.entries(CONNECTION_LABELS) as Array< - [ProjectConnectionId, string] ->; +export const CONNECTION_LABELS = Object.fromEntries(CONNECTIONS); export type SkillSetEditorMode = "create" | "edit" | "derive"; diff --git a/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx b/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx index 12e22f4a..a25714d6 100644 --- a/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx +++ b/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx @@ -1,58 +1,5 @@ -import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@selftune/ui/components", () => ({ - DataQualityPanel: ({ - evidenceQuality, - dataHygiene, - }: { - evidenceQuality?: { prompt_link_rate: number }; - dataHygiene?: { raw_checks: number }; - }) => ( -
- Data Quality - {evidenceQuality ? ` / prompt ${evidenceQuality.prompt_link_rate}` : ""} - {dataHygiene ? ` / rows ${dataHygiene.raw_checks}` : ""} -
- ), - EvidenceViewer: ({ proposalId }: { proposalId: string }) => ( -
Evidence Viewer {proposalId}
- ), - InvocationsPanel: ({ - invocations, - sessionMetadata, - }: { - invocations: Array; - sessionMetadata?: Array; - }) => ( -
- Invocations {invocations.length} - {sessionMetadata ? ` / sessions ${sessionMetadata.length}` : ""} -
- ), - PromptEvidencePanel: ({ - examples, - }: { - examples: { good: Array; missed: Array; noisy: Array }; - }) => ( -
- Prompt Evidence / good {examples.good.length} / missed {examples.missed.length} / noisy{" "} - {examples.noisy.length} -
- ), -})); - -vi.mock("@selftune/ui/primitives", () => ({ - Card: ({ children }: { children?: ReactNode }) =>
{children}
, - CardContent: ({ children }: { children?: ReactNode }) =>
{children}
, -})); - -vi.mock("./SkillReportEvidenceRail", () => ({ - SkillReportEvidenceRail: ({ activeProposal }: { activeProposal: string | null }) => ( -
Evidence Rail {activeProposal ?? "none"}
- ), -})); +import { describe, expect, it } from "vitest"; import { SkillReportEvidenceSection } from "./SkillReportEvidenceSection"; import { SkillReportEvidenceTabContent } from "./SkillReportEvidenceTabContent"; @@ -93,8 +40,9 @@ describe("Skill report shared sections", () => { />, ); - expect(html).toContain("Evidence Rail p1"); - expect(html).toContain("Evidence Viewer p1"); + expect(html).toContain("Lifecycle stages"); + expect(html).toContain("#p1"); + expect(html).toContain("Evidence: description"); }); it("renders the empty state when the viewer is disabled", () => { @@ -139,7 +87,9 @@ describe("Skill report shared sections", () => { ); expect(html).toContain("Operational invocations only"); - expect(html).toContain("Invocations 1 / sessions 1"); + expect(html).toContain("test query"); + expect(html).toContain("sess-1"); + expect(html).toContain("codex"); }); it("renders prompt evidence ahead of the shared evidence viewer", () => { @@ -176,7 +126,9 @@ describe("Skill report shared sections", () => { />, ); - expect(html).toContain("Prompt Evidence / good 1 / missed 0 / noisy 0"); + expect(html).toContain("Prompt Evidence"); + expect(html).toContain("good query"); + expect(html.indexOf("good query")).toBeLessThan(html.indexOf("No shared evidence yet")); expect(html).toContain("No shared evidence yet"); }); @@ -213,7 +165,9 @@ describe("Skill report shared sections", () => { />, ); - expect(html).toContain("Data Quality / prompt 0.85 / rows 42"); + expect(html).toContain("Prompt-linked"); + expect(html).toContain("85%"); + expect(html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ")).toContain("40 of 42 checks"); }); it("renders the empty data-quality state when metrics are unavailable", () => { diff --git a/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx b/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx index 745af385..aebfdae7 100644 --- a/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx +++ b/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx @@ -1,76 +1,56 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@selftune/ui/primitives", () => ({ - Tabs: ({ children }: { children?: ReactNode }) =>
{children}
, - TabsList: ({ children }: { children?: ReactNode }) =>
{children}
, - TabsTrigger: ({ children }: { children?: ReactNode }) => , - TabsContent: ({ children }: { children?: ReactNode }) =>
{children}
, - Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, - TooltipTrigger: ({ children, render }: { children?: ReactNode; render?: ReactNode }) => ( -
- {render} - {children} -
- ), - TooltipContent: ({ children }: { children?: ReactNode }) =>
{children}
, -})); - +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { TooltipProvider } from "@selftune/ui/primitives"; import { SkillReportTabs } from "./SkillReportTabs"; +afterEach(cleanup); + describe("SkillReportTabs", () => { - it("renders only visible tabs and their content", () => { - const html = renderToStaticMarkup( + it("shows only the selected panel and allows switching between visible tabs", () => { + render( Evidence body
, - }, - { - value: "invocations", - label: "Invocations", - content:
Invocations body
, - }, - { - value: "hidden", - label: "Hidden", - hidden: true, - content:
Hidden body
, - }, + { value: "evidence", label: "Evidence", content:
Evidence body
}, + { value: "invocations", label: "Invocations", content:
Invocations body
}, + { value: "hidden", label: "Hidden", hidden: true, content:
Hidden body
}, ]} />, ); - - expect(html).toContain("Evidence"); - expect(html).toContain("Invocations"); - expect(html).toContain("Evidence body"); - expect(html).toContain("Invocations body"); - expect(html).not.toContain("Hidden"); - expect(html).not.toContain("Hidden body"); + expect(screen.getByRole("tabpanel").textContent).toContain("Evidence body"); + expect(screen.queryByText("Invocations body")).toBeNull(); + expect(screen.queryByRole("tab", { name: "Hidden" })).toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: "Invocations" })); + expect(screen.getByRole("tabpanel").textContent).toContain("Invocations body"); + expect(screen.queryByText("Evidence body")).toBeNull(); + expect(screen.queryByText("Hidden body")).toBeNull(); }); - it("renders tooltip and badge content when configured", () => { - const html = renderToStaticMarkup( - 12, - tooltip: "Operational invocations only", - content:
Invocations body
, - }, - ]} - />, + it("renders the badge and exposes the tooltip on keyboard focus", async () => { + render( + + 12, + tooltip: "Operational invocations only", + content:
Invocations body
, + }, + ]} + /> +
, ); - - expect(html).toContain("Invocations"); - expect(html).toContain("12"); - expect(html).toContain("Operational invocations only"); + const tab = screen.getByRole("tab", { name: "Invocations12" }); + expect(tab.textContent).toContain("12"); + expect(screen.queryByText("Operational invocations only")).toBeNull(); + fireEvent.keyDown(document.body, { key: "Tab" }); + fireEvent.focus(tab); + expect( + (await screen.findByText("Operational invocations only")).hasAttribute("data-open"), + ).toBe(true); }); }); diff --git a/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.stories.tsx b/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.stories.tsx index 7523ddab..fc3ef331 100644 --- a/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.stories.tsx +++ b/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { SkillReportTrustBadge } from "./SkillReportTrustBadge"; diff --git a/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx b/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx index 8e775e63..4e299023 100644 --- a/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx +++ b/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx @@ -1,6 +1,7 @@ import { cn } from "@selftune/ui/lib"; import { Badge } from "@selftune/ui/primitives"; import type { TrustState } from "@selftune/ui/types"; +import type { ComponentProps } from "react"; export function SkillReportTrustBadge({ state }: { state: TrustState }) { const config = getSkillReportTrustBadgeConfig(state); @@ -13,11 +14,13 @@ export function SkillReportTrustBadge({ state }: { state: TrustState }) { ); } -export function getSkillReportTrustBadgeConfig(state: TrustState): { +interface TrustBadgeConfig { label: string; - variant: "default" | "secondary" | "destructive" | "outline"; + variant: ComponentProps["variant"]; dotClassName: string; -} { +} + +export function getSkillReportTrustBadgeConfig(state: TrustState): TrustBadgeConfig { switch (state) { case "low_sample": return { diff --git a/packages/dashboard-core/src/screens/skills/ContextSavings.tsx b/packages/dashboard-core/src/screens/skills/ContextSavings.tsx new file mode 100644 index 00000000..d703bd0b --- /dev/null +++ b/packages/dashboard-core/src/screens/skills/ContextSavings.tsx @@ -0,0 +1,91 @@ +import type { LibrarySkillModel } from "../../models"; +import { contextFootprint } from "./context-footprint"; +import { HarnessLabel } from "@selftune/ui/components"; + +export function ContextSavings({ + skills, + selectedIds, +}: { + skills: readonly LibrarySkillModel[]; + selectedIds?: ReadonlySet; +}) { + const rows = contextFootprint(skills, selectedIds); + const baseline = new Map(contextFootprint(skills).map((row) => [row.key, row])); + const preview = selectedIds !== undefined; + return ( +
+

+ {preview ? "Estimated tokens freed per session" : "Context savings by harness"} +

+

+ Estimated discovery tokens per new session. Full instructions are excluded. +

+ {rows.length ? ( +
+ + + + + {preview ? : null} + + + + + + {rows.map((row) => ( + + + {preview ? ( + + ) : null} + + + + ))} + +
Harness / scopeNow{preview ? "After" : "In use"}{preview ? "Freed" : "Avoided"}
+ skill.locations) + .find( + (location) => + location.connection === row.harness && location.connectionIcon, + )?.connectionIcon + } + /> + + {row.scope.split("/").filter(Boolean).at(-1) ?? row.scope} + + + {row.unknown + ? "Unknown" + : `~${baseline.get(row.key)?.current.toLocaleString("en") ?? 0}`} + + {row.unknown ? "Unknown" : `~${row.current.toLocaleString("en")}`} + + {row.unknown + ? "Unknown" + : `~${(preview ? Math.max(0, (baseline.get(row.key)?.current ?? 0) - row.current) : row.savings).toLocaleString("en")}`} +
+
+ ) : ( +

+ No installed or previously moved skill metadata available. +

+ )} +
+ How this is calculated +

+ Names and descriptions, plus paths for Codex and Pi, at four bytes per token. Claude and + Pi manual-only skills count as zero. Host budgets, disabled skills, shared search paths + and model tokenizers can reduce actual savings. Project rows are separate contexts, not + additive. Measured prompt savings are unavailable until the harness exposes comparable + before/after prompts. Existing conversations do not shrink. +

+
+
+ ); +} diff --git a/packages/dashboard-core/src/screens/skills/LibrarySourceControl.tsx b/packages/dashboard-core/src/screens/skills/LibrarySourceControl.tsx index 35b2a056..3a0bed2e 100644 --- a/packages/dashboard-core/src/screens/skills/LibrarySourceControl.tsx +++ b/packages/dashboard-core/src/screens/skills/LibrarySourceControl.tsx @@ -38,8 +38,8 @@ export function LibrarySourceControl({ className="max-w-52" title={source.path} onClick={() => { - void openLocation.execute(source.path ?? "").catch((error: unknown) => { - onError(error instanceof Error ? error.message : String(error)); + void openLocation.execute(source.path ?? "").catch((cause: unknown) => { + onError(cause instanceof Error ? cause.message : String(cause)); }); }} > diff --git a/packages/dashboard-core/src/screens/skills/OnDemandSkillTable.tsx b/packages/dashboard-core/src/screens/skills/OnDemandSkillTable.tsx new file mode 100644 index 00000000..803da19a --- /dev/null +++ b/packages/dashboard-core/src/screens/skills/OnDemandSkillTable.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import { + Button, + Checkbox, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@selftune/ui/primitives"; +import type { LibrarySkillModel } from "../../models"; +import { LibraryConnections } from "./LibraryConnections"; +import { + SortableTableHead, + sortSkills, + type SortState, + type SortColumn, +} from "./SkillsLibrarySorting"; +import { contextFootprint } from "./context-footprint"; + +const PAGE_SIZE = 20; +function skillDescription(skill: LibrarySkillModel): string { + return ( + skill.contextEntries?.find((entry) => entry.metadata?.description)?.metadata?.description ?? + "No description available" + ); +} +function savingsLabel(skill: LibrarySkillModel): string { + const rows = contextFootprint([skill], new Set([skill.id])); + if (!rows.length || rows.some((row) => row.unknown)) return "Unknown"; + const amounts = rows.map((row) => row.savings); + const low = Math.min(...amounts); + const high = Math.max(...amounts); + return low === high ? `~${low}` : `~${low}–${high}`; +} + +export function OnDemandSkillTable({ + skills, + selected, + onSelectionChange, + pending, +}: { + skills: readonly LibrarySkillModel[]; + selected: ReadonlySet; + onSelectionChange(ids: Set): void; + pending: boolean; +}) { + const [search, setSearch] = useState(""); + const [suggestedOnly, setSuggestedOnly] = useState(false); + const [page, setPage] = useState(0); + const [sort, setSort] = useState({ column: "skill", direction: "asc" }); + const query = search.trim().toLowerCase(); + const filtered = sortSkills( + skills.filter( + (skill) => + (!suggestedOnly || skill.onDemandReason || skill.archiveRecommendation) && + [skill.name, skillDescription(skill), ...skill.locations.map((location) => location.path)] + .join(" ") + .toLowerCase() + .includes(query), + ), + sort, + ); + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount - 1); + const visible = filtered.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE); + const setMany = (rows: readonly LibrarySkillModel[], checked: boolean) => { + const next = new Set(selected); + for (const skill of rows) { + if (checked) next.add(skill.id); + else next.delete(skill.id); + } + onSelectionChange(next); + }; + const changeSort = (column: SortColumn) => { + setSort({ + column, + direction: sort.column === column && sort.direction === "asc" ? "desc" : "asc", + }); + setPage(0); + }; + return ( +
+
+ { + setSearch(event.target.value); + setPage(0); + }} + /> + +
+
+ + {filtered.length} matching · {selected.size} selected + +
+ + +
+
+
+ + + + + 0 && visible.every((skill) => selected.has(skill.id))} + onCheckedChange={(checked) => setMany(visible, checked === true)} + /> + + + Harnesses + Tokens freed + + + + {visible.map((skill) => ( + + + setMany([skill], checked === true)} + /> + + +
+ {skill.name} +
+
+ {skillDescription(skill)} +
+ {skill.onDemandReason || skill.archiveRecommendation ? ( + + Suggested · usage evidence + + ) : null} +
+ + + + + {savingsLabel(skill)} + +
+ ))} + {!visible.length ? ( + + + No matching eligible skills. Try another search or turn off Suggested only. + + + ) : null} +
+
+
+
+ + Page {currentPage + 1} of {pageCount} · up to {PAGE_SIZE} skills per page + +
+ + +
+
+
+ ); +} diff --git a/packages/dashboard-core/src/screens/skills/OnDemandSkillsPanel.test.tsx b/packages/dashboard-core/src/screens/skills/OnDemandSkillsPanel.test.tsx new file mode 100644 index 00000000..406d1b57 --- /dev/null +++ b/packages/dashboard-core/src/screens/skills/OnDemandSkillsPanel.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { LibrarySkillModel } from "../../models"; +import { + estimateInstructionTokens, + OnDemandSkillsPanel, + ON_DEMAND_SKILL_PROMPT, + ON_DEMAND_SETUP_KEY, +} from "./OnDemandSkillsPanel"; + +function skill(overrides: Partial): LibrarySkillModel { + return { + id: "skill", + name: "skill", + lifecycle: "library", + status: "Stored", + updateStatus: "untracked", + sources: [], + locations: [], + revisionHashes: [], + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("OnDemandSkillsPanel", () => { + it("shows setup once, keeps the page clear after dismissal, and can reopen", async () => { + const values = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + }); + const view = render(); + expect(screen.getByRole("dialog")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(values.get(ON_DEMAND_SETUP_KEY)).toBe("true"); + view.unmount(); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole("region", { name: "Context savings by harness" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Use on demand" })); + await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy()); + }); + + it("uses the shared harness asset in context savings", () => { + render( + , + ); + expect(screen.getByTitle("Codex").getAttribute("src")).toBe("/harness/codex.svg"); + expect(screen.getByTitle("Codex").className).toContain("dark:invert"); + }); + it("does not present full instruction bytes as context savings", () => { + render( + , + ); + + expect(screen.getByText("2")).toBeTruthy(); + expect(screen.queryByText("~1.5K")).toBeNull(); + expect(screen.getByRole("region", { name: "Context savings by harness" })).toBeTruthy(); + expect(screen.getByText(/four bytes per token/i)).toBeTruthy(); + }); + + it("copies the agent-operated activation request", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + render(); + fireEvent.click(screen.getByText("How to ask your agent")); + + fireEvent.click(screen.getByRole("button", { name: /copy example request/i })); + expect(writeText).toHaveBeenCalledWith(ON_DEMAND_SKILL_PROMPT); + }); + + it("offers a manual fallback when clipboard access is denied", async () => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) }, + }); + render(); + fireEvent.click(screen.getByText("How to ask your agent")); + + fireEvent.click(screen.getByRole("button", { name: /copy example request/i })); + + expect((await screen.findByRole("status")).textContent).toMatch(/select the request text/i); + }); +}); + +describe("estimateInstructionTokens", () => { + it("uses a conservative deterministic four-byte estimate", () => { + expect(estimateInstructionTokens(0)).toBe(0); + expect(estimateInstructionTokens(5)).toBe(2); + }); +}); diff --git a/packages/dashboard-core/src/screens/skills/OnDemandSkillsPanel.tsx b/packages/dashboard-core/src/screens/skills/OnDemandSkillsPanel.tsx new file mode 100644 index 00000000..9d0aec83 --- /dev/null +++ b/packages/dashboard-core/src/screens/skills/OnDemandSkillsPanel.tsx @@ -0,0 +1,124 @@ +"use client"; +import { CheckIcon, CopyIcon, LibraryIcon } from "lucide-react"; +import { useState } from "react"; +import type { LibrarySkillModel } from "../../models"; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@selftune/ui/primitives"; +import type { DashboardLibraryActions } from "../../host"; +import { OnDemandSkillsReview } from "./OnDemandSkillsReview"; +import { ContextSavings } from "./ContextSavings"; + +export const ON_DEMAND_SKILL_PROMPT = + "Use the Corey Haines marketing skills for this task. Find the exact local collection with SelfTune, activate it only in this project for this task, and remove it when we are done."; +export const ON_DEMAND_SETUP_KEY = "selftune-on-demand-setup-dismissed"; +export function estimateInstructionTokens(bytes: number): number { + return Math.ceil(Math.max(0, bytes) / 4); +} +export function OnDemandSkillsPanel({ + skills, + actions, + refresh, +}: { + skills: readonly LibrarySkillModel[]; + actions?: DashboardLibraryActions; + refresh?: () => void | Promise; +}) { + const [open, setOpen] = useState(() => { + try { + return globalThis.localStorage?.getItem(ON_DEMAND_SETUP_KEY) !== "true"; + } catch { + return true; + } + }); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + const count = skills.filter( + (skill) => skill.lifecycle === "library" || skill.lifecycle === "draft", + ).length; + const dismiss = () => { + setOpen(false); + try { + globalThis.localStorage?.setItem(ON_DEMAND_SETUP_KEY, "true"); + } catch { + /* Storage may be disabled; dismissal still works for this mount. */ + } + }; + const copyPrompt = async () => { + try { + await navigator.clipboard.writeText(ON_DEMAND_SKILL_PROMPT); + setCopyFailed(false); + setCopied(true); + window.setTimeout(() => setCopied(false), 2_000); + } catch { + setCopied(false); + setCopyFailed(true); + } + }; + const introduction = ( +
+

+ {count} skills ready without project + installs. +

+
+ How to ask your agent +

{ON_DEMAND_SKILL_PROMPT}

+
+ + {copyFailed ? ( + + Copy failed. Select the request text instead. + + ) : null} + + How temporary skills work + +
+
+
+ ); + if (actions && refresh && actions.moveToLibraryMany?.access === "available") { + return ( + + ); + } + return ( + <> + + (next ? setOpen(true) : dismiss())}> + + + Keep skills for on-demand use + + Keep occasional skills in your searchable Library and load them only for the task that + needs them. + + + {introduction} + + + + + + ); +} diff --git a/packages/dashboard-core/src/screens/skills/OnDemandSkillsReview.test.tsx b/packages/dashboard-core/src/screens/skills/OnDemandSkillsReview.test.tsx new file mode 100644 index 00000000..4daadaff --- /dev/null +++ b/packages/dashboard-core/src/screens/skills/OnDemandSkillsReview.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { LibrarySkillModel } from "../../models"; +import { OnDemandSkillsReview } from "./OnDemandSkillsReview"; + +afterEach(cleanup); + +it("handles 125 skills with bounded pages and preserves selections across search", async () => { + const execute = vi.fn().mockResolvedValue({ succeeded: 2, failed: 0, receipts: [] }); + const skills = Array.from({ length: 125 }, (_, index): LibrarySkillModel => { + const name = `skill-${String(index).padStart(3, "0")}`; + return { + id: name, + name, + lifecycle: "active", + status: "Ready", + updateStatus: "current", + sources: [], + locations: [], + revisionHashes: ["hash"], + onDemandSource: { + skillPath: `/${name}/SKILL.md`, + packagePath: `/${name}`, + contentHash: "hash", + }, + contextEntries: [ + { + harness: "codex", + scope: "global", + projectRoot: null, + path: `/${name}/SKILL.md`, + state: "active", + metadata: { + name, + description: index === 124 ? "Write newsletter campaigns" : "Review code changes", + disableModelInvocation: false, + originalSkillPath: `/${name}/SKILL.md`, + }, + }, + ], + }; + }); + render( + Setup

} + initialOpen + refresh={vi.fn()} + actions={{ + moveToLibraryMany: { access: "available", execute }, + restore: { access: "unavailable", reason: "test" }, + }} + />, + ); + const table = screen.getByRole("table", { name: "On-demand skill library" }); + expect(within(table).getAllByRole("row")).toHaveLength(21); + fireEvent.click(screen.getByRole("checkbox", { name: "Keep skill-000 on demand" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.queryByRole("checkbox", { name: "Keep skill-000 on demand" })).toBeNull(); + fireEvent.change(screen.getByRole("textbox", { name: "Search on-demand skills" }), { + target: { value: "newsletter" }, + }); + expect(within(table).getAllByRole("row")).toHaveLength(2); + fireEvent.click(screen.getByRole("checkbox", { name: "Keep skill-124 on demand" })); + expect(screen.getByText("2 skills will load only on request")).toBeTruthy(); + expect(screen.getByText(/2 active installations removed/)).toBeTruthy(); + const impact = within(screen.getByRole("region", { name: "Context savings by harness" })); + expect(impact.getByText("Now")).toBeTruthy(); + expect(impact.getByText("After")).toBeTruthy(); + expect(impact.getByText("Freed")).toBeTruthy(); + const cells = impact.getAllByRole("row")[1]; + const values = within(cells) + .getAllByRole("cell") + .slice(1) + .map((cell) => Number(cell.textContent?.replace(/[~,]/g, ""))); + expect(values[0] - values[1]).toBe(values[2]); + expect(values[2]).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("button", { name: "Move 2 skills to on-demand" })); + await waitFor(() => + expect(execute).toHaveBeenCalledWith([ + { skillName: "skill-000", skillPath: "/skill-000/SKILL.md", expectedContentHash: "hash" }, + { skillName: "skill-124", skillPath: "/skill-124/SKILL.md", expectedContentHash: "hash" }, + ]), + ); +}); +const candidate: LibrarySkillModel = { + id: "marketing", + name: "marketing", + lifecycle: "active", + status: "Ready", + updateStatus: "current", + sources: [], + locations: [], + revisionHashes: ["reviewed-revision"], + instructionBytes: 4000, + archiveRecommendation: { + classification: "inactive_candidate", + reason: "No invocation in 60 days across 25 sessions.", + skillPath: "/skills/marketing/SKILL.md", + packagePath: "/skills/marketing", + contentHash: "reviewed-revision", + }, +}; + +it("keeps setup, selection and Undo in one modal", async () => { + const restore = vi.fn().mockResolvedValue(undefined); + const onDismiss = vi.fn(); + render( + On-demand setup

} + initialOpen + onDismiss={onDismiss} + refresh={vi.fn()} + actions={{ + moveToLibraryMany: { + access: "available", + execute: vi.fn().mockResolvedValue({ + succeeded: 1, + failed: 0, + receipts: [{ skillName: "marketing", restoreId: "receipt-modal" }], + }), + }, + restore: { access: "available", execute: restore }, + }} + />, + ); + expect(screen.getByRole("table", { name: "On-demand skill library" })).toBeTruthy(); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + fireEvent.click(screen.getByRole("checkbox", { name: /Keep marketing on demand/ })); + fireEvent.click(screen.getByRole("button", { name: /Move \d+ skills? to on-demand/ })); + fireEvent.click(await screen.findByRole("button", { name: "Undo" })); + await waitFor(() => expect(restore).toHaveBeenCalledWith("receipt-modal")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + await waitFor(() => + expect(screen.getByRole("button", { name: "Done" }).hasAttribute("disabled")).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(onDismiss).toHaveBeenCalledOnce(); +}); + +it("reviews exact selected revisions, shows impact, moves only on confirmation, and undoes receipts", async () => { + const execute = vi.fn().mockResolvedValue({ + succeeded: 1, + failed: 0, + receipts: [{ skillName: "marketing", restoreId: "receipt-1" }], + }); + const restore = vi.fn().mockResolvedValue(undefined); + const refresh = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Review 1 suggestion" })); + expect(screen.queryByRole("checkbox", { name: /recent/ })).toBeNull(); + expect(execute).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("checkbox", { name: /Keep marketing on demand/ })); + expect(screen.getByText("1 skill will load only on request")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Move \d+ skills? to on-demand/ })); + await waitFor(() => + expect(execute).toHaveBeenCalledWith([ + { + skillName: "marketing", + skillPath: "/skills/marketing/SKILL.md", + expectedContentHash: "reviewed-revision", + }, + ]), + ); + fireEvent.click(await screen.findByRole("button", { name: "Undo" })); + await waitFor(() => expect(restore).toHaveBeenCalledWith("receipt-1")); + expect(await screen.findByText(/Restored the original installations/)).toBeTruthy(); +}); + +it("keeps failed moves reviewable and reports the actual reason", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Review 1 suggestion" })); + fireEvent.click(screen.getByRole("checkbox", { name: /Keep marketing on demand/ })); + fireEvent.click(screen.getByRole("button", { name: /Move \d+ skills? to on-demand/ })); + expect((await screen.findByRole("alert")).textContent).toContain("Skill changed after review"); + expect(screen.getByRole("dialog")).toBeTruthy(); +}); + +it("allows an explicit choice without claiming an inactivity recommendation", () => { + const execute = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Choose skills" })); + expect(screen.getByRole("checkbox", { name: /Keep marketing on demand/ })).toBeTruthy(); + expect(screen.getByText("No description available")).toBeTruthy(); + expect(execute).not.toHaveBeenCalled(); +}); + +it("moves all identical installations together and restores every receipt", async () => { + const sources = ["claude", "codex", "pi"].map((harness) => ({ + skillPath: `/${harness}/marketing/SKILL.md`, + packagePath: `/${harness}/marketing`, + contentHash: "reviewed-revision", + })); + const execute = vi.fn().mockResolvedValue({ + succeeded: 3, + failed: 0, + receipts: sources.map((source, index) => ({ + skillName: "marketing", + restoreId: `receipt-${index}`, + })), + }); + const restore = vi.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Choose skills" })); + fireEvent.click(screen.getByRole("checkbox", { name: /Keep marketing on demand/ })); + fireEvent.click(screen.getByRole("button", { name: /Move \d+ skills? to on-demand/ })); + await waitFor(() => + expect(execute).toHaveBeenCalledWith( + sources.map((source) => ({ + skillName: "marketing", + skillPath: source.skillPath, + expectedContentHash: source.contentHash, + })), + ), + ); + fireEvent.click(await screen.findByRole("button", { name: "Undo" })); + await waitFor(() => expect(restore).toHaveBeenCalledTimes(3)); +}); diff --git a/packages/dashboard-core/src/screens/skills/OnDemandSkillsReview.tsx b/packages/dashboard-core/src/screens/skills/OnDemandSkillsReview.tsx new file mode 100644 index 00000000..ee56cd95 --- /dev/null +++ b/packages/dashboard-core/src/screens/skills/OnDemandSkillsReview.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { LibraryIcon, Undo2Icon } from "lucide-react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@selftune/ui/primitives"; +import type { DashboardLibraryActions } from "../../host"; +import type { LibrarySkillModel } from "../../models"; +import { ContextSavings } from "./ContextSavings"; +import { OnDemandSkillTable } from "./OnDemandSkillTable"; + +export function OnDemandSkillsReview({ + skills, + actions, + refresh, + introduction, + initialOpen = false, + onDismiss, +}: { + skills: readonly LibrarySkillModel[]; + actions: Pick; + refresh(): void | Promise; + introduction?: ReactNode; + initialOpen?: boolean; + onDismiss?: () => void; +}) { + const [open, setOpen] = useState(initialOpen); + const [selected, setSelected] = useState>(() => new Set()); + const [pending, setPending] = useState<"move" | "undo" | null>(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [receipts, setReceipts] = useState([]); + const eligible = skills.filter( + (skill) => + skill.lifecycle === "active" && + (skill.onDemandSources?.length || + skill.onDemandSource?.contentHash || + skill.archiveRecommendation?.contentHash), + ); + const suggestions = eligible.filter( + (skill) => skill.onDemandReason || skill.archiveRecommendation, + ); + const chosen = eligible.filter((skill) => selected.has(skill.id)); + const installations = chosen.reduce( + (count, skill) => count + (skill.onDemandSources?.length ?? 1), + 0, + ); + const action = actions.moveToLibraryMany; + if (action?.access !== "available") return null; + + const move = async () => { + if (pending || !chosen.length) return; + setPending("move"); + setError(null); + setNotice(null); + try { + const inputs = chosen.flatMap((skill) => { + if (skill.onDemandSources?.length) + return skill.onDemandSources.map((source) => ({ + skillName: skill.name, + skillPath: source.skillPath, + expectedContentHash: source.contentHash, + })); + const recommendation = skill.onDemandSource ?? skill.archiveRecommendation; + return recommendation?.contentHash + ? [ + { + skillName: skill.name, + skillPath: recommendation.skillPath, + expectedContentHash: recommendation.contentHash, + }, + ] + : []; + }); + const result = await action.execute(inputs); + setReceipts((previous) => [ + ...new Map( + [...previous, ...(result.receipts ?? [])].map((receipt) => [receipt.restoreId, receipt]), + ).values(), + ]); + if (result.succeeded) { + setNotice( + `${result.succeeded} installation${result.succeeded === 1 ? "" : "s"} moved to the searchable Library. Identical copies share one revision. Undo restores original locations.`, + ); + } + if (result.failed) { + setError( + result.failures + ?.map((failure) => `${failure.skillName}: ${failure.message}`) + .join("\n") || + `${result.failed} skills could not be moved. Refresh and review them again.`, + ); + } else { + if (!introduction) setOpen(false); + setSelected(new Set()); + } + await refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setPending(null); + } + }; + + const undo = async () => { + const restore = actions.restore; + if (restore.access !== "available" || pending) return; + setPending("undo"); + setError(null); + const remaining = []; + for (const receipt of receipts) { + try { + // oxlint-disable-next-line no-await-in-loop -- Restore filesystem locations sequentially and retain failed receipts for retry. + await restore.execute(receipt.restoreId); + } catch (cause) { + remaining.push(receipt); + setError(`${receipt.skillName}: ${cause instanceof Error ? cause.message : String(cause)}`); + } + } + setReceipts(remaining); + setNotice( + remaining.length + ? "Some installations could not be restored. Undo can retry them." + : "Restored the original installations. The searchable Library copies are still available.", + ); + try { + await refresh(); + } catch { + setError("Refresh the Library to see the restored installations."); + } + setPending(null); + }; + + const choices = ( +
+ + + + {suggestions.length + ? "Based on local usage history" + : "No inactivity suggestions yet. You can still choose skills yourself."} + +
+ ); + const feedback = ( + <> + {notice ? ( +
+ {notice} + {receipts.length && actions.restore.access === "available" ? ( + + ) : null} +
+ ) : null} + {error && !open ? ( +

+ {error} +

+ ) : null} + + ); + const close = () => { + setOpen(false); + onDismiss?.(); + }; + return ( +
+ {introduction ? ( + + ) : ( + <> + {choices} + {feedback} + + )} + { + if (!pending) { + if (next) setOpen(true); + else close(); + } + }} + > + + + Keep skills for on-demand use + + Choose which skills should stop loading by default. Keep them searchable for the tasks + that need them. + + + {introduction ? feedback : null} +
+
+ + {introduction ? ( +
+ How to load a saved skill later +
{introduction}
+
+ ) : null} +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+
+ ); +} diff --git a/packages/dashboard-core/src/screens/skills/ShareSkillDialog.test.tsx b/packages/dashboard-core/src/screens/skills/ShareSkillDialog.test.tsx index 23c018a2..b5ad70e3 100644 --- a/packages/dashboard-core/src/screens/skills/ShareSkillDialog.test.tsx +++ b/packages/dashboard-core/src/screens/skills/ShareSkillDialog.test.tsx @@ -25,12 +25,20 @@ const skill: LibrarySkillModel = { revisionHashes: ["a".repeat(64)], }; -function action(execute: ReturnType): NonNullable { +function action( + execute: Extract< + NonNullable, + { access: "available" } + >["execute"], +): NonNullable { return { access: "available", execute }; } function managedCloudAction( - execute: ReturnType, + execute: Extract< + NonNullable, + { access: "available" } + >["execute"], ): Extract, { access: "available" }> { return { access: "available", diff --git a/packages/dashboard-core/src/screens/skills/SkillDetail.tsx b/packages/dashboard-core/src/screens/skills/SkillDetail.tsx index 41446cc5..7796831f 100644 --- a/packages/dashboard-core/src/screens/skills/SkillDetail.tsx +++ b/packages/dashboard-core/src/screens/skills/SkillDetail.tsx @@ -431,8 +431,8 @@ export function SkillDetail({ void backupAction .execute(skill.id) .then(setBackupReceipt) - .catch((error: unknown) => - setTransferError(error instanceof Error ? error.message : String(error)), + .catch((cause: unknown) => + setTransferError(cause instanceof Error ? cause.message : String(cause)), ); }} > @@ -453,7 +453,10 @@ export function SkillDetail({