From 86eadcc2fda81a5edebdaad8a6996dd47fe4b186 Mon Sep 17 00:00:00 2001 From: selftune-bot Date: Sat, 29 Aug 2026 07:35:18 +0000 Subject: [PATCH] chore: export from selftune-cloud-app --- .changeset/README.md | 7 +- .changeset/v040-local-first-relaunch.md | 5 + .github/release-notes.json | 210 +- .github/sbom-toolchain/package-lock.json | 2206 ----------------- .github/sbom-toolchain/package.json | 11 - .github/scripts/smoke-selfhost-image.sh | 77 +- .github/workflows/auto-bump-cli-version.yml | 2 - .github/workflows/desktop.yml | 53 +- .github/workflows/publish.yml | 99 +- .gitignore | 1 - .oxlintrc.json | 1 + AGENTS.md | 1 + ARCHITECTURE.md | 646 +---- CHANGELOG.md | 7 + README.md | 228 +- Research_trigger_eval.json | 394 --- apps/cli/CHANGELOG.md | 16 - apps/cli/package.json | 2 +- apps/cli/src/commands/lifecycle.ts | 9 +- .../src/effect-cli/argument-compatibility.ts | 17 +- apps/cli/src/effect-cli/commands/eval.ts | 42 +- apps/cli/src/effect-cli/commands/registry.ts | 24 +- .../src/effect-cli/compatibility/registry.ts | 10 +- apps/cli/src/main.ts | 16 +- apps/desktop/build/dmg-background.png | Bin 0 -> 237300 bytes apps/desktop/build/icon-mac.png | Bin 0 -> 173493 bytes apps/desktop/build/icon-mac.svg | 13 + apps/desktop/build/icon.icns | Bin 446548 -> 309320 bytes apps/desktop/desktop-builder-config.ts | 17 +- apps/desktop/package.json | 2 +- apps/desktop/scripts/build-mac-icon.ts | 55 + apps/desktop/scripts/build-sidecar.ts | 121 +- apps/desktop/scripts/duckdb-sidecar-probe.ts | 5 +- apps/desktop/scripts/smoke-packaged.ts | 45 +- apps/desktop/scripts/smoke-sidecar.ts | 80 +- apps/desktop/src/desktop-test-contract.ts | 1 + .../src/main/desktop-activation.test.ts | 52 + apps/desktop/src/main/desktop-activation.ts | 38 + .../src/main/desktop-agent-detection.test.ts | 9 +- .../src/main/desktop-builder-config.test.ts | 36 +- .../main/desktop-install-credential.test.ts | 185 -- .../src/main/desktop-install-credential.ts | 207 -- .../desktop-install-handoff-events.test.ts | 33 + .../main/desktop-install-handoff-events.ts | 31 +- .../src/main/desktop-pack-handoff.test.ts | 24 + apps/desktop/src/main/desktop-pack-handoff.ts | 38 + .../desktop/src/main/desktop-protocol.test.ts | 11 +- apps/desktop/src/main/desktop-protocol.ts | 2 +- .../src/main/desktop-recipient-preview.ts | 2 +- apps/desktop/src/main/desktop-shell.ts | 2 +- apps/desktop/src/main/desktop-window.ts | 29 +- apps/desktop/src/main/diagnostics.test.ts | 21 + apps/desktop/src/main/diagnostics.ts | 55 +- apps/desktop/src/main/index.ts | 44 +- apps/desktop/src/main/reset-state.test.ts | 14 +- apps/desktop/src/main/runtime-install.test.ts | 77 +- apps/desktop/src/main/runtime-integrity.ts | 33 +- apps/desktop/src/main/state-backup.ts | 9 +- apps/desktop/src/main/tray-state.test.ts | 2 +- apps/desktop/src/main/tray-state.ts | 4 +- apps/desktop/src/preload/index.ts | 12 +- apps/local-dashboard/CHANGELOG.md | 14 - apps/local-dashboard/package.json | 3 +- apps/local-dashboard/src/App.tsx | 19 +- apps/local-dashboard/src/api.test.ts | 124 +- apps/local-dashboard/src/api.ts | 148 ++ .../src/components/SetupWizard.test.tsx | 22 + .../src/components/SetupWizard.tsx | 78 +- .../src/dashboard-host-collaboration.test.tsx | 224 ++ .../src/dashboard-host-selfhost.test.ts | 135 - .../src/dashboard-host.test.ts | 21 +- apps/local-dashboard/src/dashboard-host.ts | 815 +++--- apps/local-dashboard/src/hooks/useLibrary.ts | 16 + apps/local-dashboard/src/hooks/usePlugins.ts | 17 + .../local-dashboard/src/hooks/useSkillSets.ts | 58 + .../src/lib/sync-destination.ts | 16 +- .../local-library-transfer-actions.test.ts | 31 - .../src/local-library-transfer-actions.ts | 25 +- apps/local-dashboard/src/pages/Settings.tsx | 369 +-- apps/local-dashboard/src/pages/Team.test.ts | 22 + apps/local-dashboard/src/pages/Team.tsx | 343 +++ .../src/shared-app-routes.test.ts | 10 +- apps/local-dashboard/src/shared-app-routes.ts | 13 +- apps/local-dashboard/src/types.ts | 1 + apps/local/CHANGELOG.md | 12 - apps/local/package.json | 4 +- apps/local/src/cloud-billing.ts | 92 +- apps/local/src/cloud-team-collaboration.ts | 340 +++ apps/local/src/daemon.ts | 20 +- apps/local/src/dashboard-http.ts | 2 +- apps/local/src/dashboard-operation-errors.ts | 12 + apps/local/src/dashboard-operations.ts | 266 +- apps/local/src/dashboard-server.ts | 136 +- apps/local/src/historical-evidence-safety.ts | 95 + .../local/src/historical-skill-improve-cli.ts | 221 ++ .../historical-skill-improvement-service.ts | 686 +++++ .../src/historical-skill-replay-executor.ts | 426 ++++ apps/local/src/historical-task-candidate.ts | 666 +++++ apps/local/src/hosted-state.ts | 188 ++ apps/local/src/plugin-inventory.ts | 312 +++ .../src/prepared-trace-candidate-draft.ts | 116 + .../src/proactive-correction-e2-service.ts | 15 +- apps/local/src/remote-library-operations.ts | 95 +- apps/local/src/routes/application.ts | 192 ++ apps/local/src/routes/core.ts | 11 + apps/local/src/routes/correction-studies.ts | 6 +- apps/local/src/routes/index.ts | 8 +- .../local/src/routes/library-transfer.test.ts | 162 +- apps/local/src/routes/library-transfer.ts | 198 +- apps/local/src/routes/overview.ts | 70 +- apps/local/src/routes/trace-candidates.ts | 24 +- apps/local/src/routes/workspace-settings.ts | 3 + .../windows/installation/controller.ts | 37 +- .../service/windows/installation/evidence.ts | 90 +- .../installation/legacy-cleanup-controller.ts | 24 +- .../src/service/windows/installation/store.ts | 65 - .../installation/task-definition-proof.ts | 46 - .../src/service/windows/lock-compatibility.ts | 2 +- .../src/service/windows/mutation-lock.ts | 2 +- apps/local/src/skill-set-pack-import.ts | 123 + apps/local/src/skill-set-plugin-install.ts | 467 ++++ apps/local/src/trace-candidate-contract.ts | 62 + apps/local/src/trace-candidate-service.ts | 352 +-- apps/local/tests/cloud-billing.test.ts | 35 +- .../tests/cloud-team-collaboration.test.ts | 181 ++ .../tests/correction-studies-route.test.ts | 76 +- ...storical-skill-improvement-service.test.ts | 400 +++ .../historical-skill-replay-executor.test.ts | 307 +++ .../tests/historical-task-candidate.test.ts | 293 +++ apps/local/tests/hosted-state.test.ts | 145 ++ .../tests/library-transfer-routes.test.ts | 263 -- .../tests/otlp-dashboard-runtime.test.ts | 59 + apps/local/tests/plugin-inventory.test.ts | 131 + apps/local/tests/plugin-routes.test.ts | 100 + .../proactive-correction-e2-service.test.ts | 13 + .../tests/skill-set-plugin-install.test.ts | 188 ++ .../tests/team-collaboration-routes.test.ts | 141 ++ .../local/tests/trace-candidate-route.test.ts | 43 + .../tests/trace-candidate-threshold.test.ts | 50 + apps/selfhost/CHANGELOG.md | 16 - apps/selfhost/Dockerfile | 9 - apps/selfhost/README.md | 12 +- apps/selfhost/docker-compose.yml | 2 +- apps/selfhost/package.json | 2 +- apps/selfhost/src/config.ts | 8 + apps/selfhost/src/contract.ts | 56 + apps/selfhost/src/remote-api.test.ts | 216 +- apps/selfhost/src/remote-api.ts | 272 +- apps/selfhost/src/repository.ts | 505 +++- .../src/terminal-disclosure.ts | 101 +- .../tests/terminal-disclosure.test.ts | 65 - bun.lock | 195 +- .../design-docs/alpha-remote-data-contract.md | 350 --- docs/design-docs/index.md | 1 + .../portable-skill-evaluation-contract.md | 70 + .../active/agent-first-alpha-onboarding.md | 218 -- .../active/alpha-rollout-data-loop-plan.md | 365 --- .../active/alpha-simplification-program.md | 328 --- ...mer-creator-progressive-disclosure-plan.md | 424 ---- ...cremental-report-aggregation-2026-07-18.md | 2 +- .../completed/output-quality-loop-prereqs.md | 178 -- .../completed/telemetry-normalization.md | 446 ---- .../cloud-auth-unification-for-alpha.md | 360 --- ...-change-skills-call-2026-04-09-insights.md | 183 -- package.json | 7 +- packages/api-contract/README.md | 23 - .../fixtures/distribution-binding-golden.json | 528 ---- packages/api-contract/index.ts | 14 - packages/api-contract/package.json | 38 - packages/api-contract/src/billing.ts | 79 - packages/api-contract/src/client.ts | 590 ----- packages/api-contract/src/cloud-improve.ts | 163 -- packages/api-contract/src/contracts.ts | 795 ------ .../src/distribution-read-models.ts | 127 - .../src/distribution-source-staging.ts | 950 ------- packages/api-contract/src/distribution.ts | 1137 --------- packages/api-contract/src/github.ts | 59 - .../api-contract/src/install-credentials.ts | 49 - .../api-contract/src/recipient-actions.ts | 624 ----- packages/api-contract/src/share-grants.ts | 91 - .../api-contract/src/share-invitations.ts | 406 --- .../src/signal-routing-capability.ts | 153 -- packages/api-contract/src/team.ts | 52 - packages/api-contract/tests/billing.test.ts | 105 - packages/api-contract/tests/client.test.ts | 416 ---- .../api-contract/tests/cloud-improve.test.ts | 58 - .../tests/distribution-source-staging.test.ts | 490 ---- .../api-contract/tests/distribution.test.ts | 892 ------- packages/api-contract/tests/github.test.ts | 47 - .../tests/install-credentials.test.ts | 58 - .../tests/recipient-actions-client.test.ts | 349 --- .../tests/recipient-actions-exports.test.ts | 21 - .../tests/recipient-actions.test.ts | 483 ---- .../api-contract/tests/share-grants.test.ts | 41 - .../tests/share-invitations.test.ts | 326 --- .../signal-routing-capability-exports.test.ts | 9 - .../tests/signal-routing-capability.test.ts | 62 - .../api-contract/tests/skill-detail.test.ts | 48 - packages/api-contract/tests/team.test.ts | 52 - packages/api-contract/tsconfig.json | 13 - packages/app-core/CHANGELOG.md | 8 - packages/app-core/index.ts | 1 - packages/app-core/package.json | 4 +- packages/app-core/src/host.tsx | 14 +- packages/app-core/src/manifest.ts | 49 +- packages/app-core/src/route-factory.tsx | 88 - packages/app-core/src/routes.tsx | 56 +- packages/app-core/src/shell.ts | 24 +- packages/app-core/tests/composition.test.tsx | 58 +- .../tests/semantic-token-guard.test.ts | 15 +- packages/app-core/tsconfig.json | 1 + packages/app-core/vitest.config.ts | 8 + packages/control-plane/package.json | 1 + .../control-plane/src/domain/hosted-state.ts | 69 + packages/control-plane/src/domain/index.ts | 5 + .../src/domain/package-bundle.ts | 244 +- .../src/domain/plugin-capability-bundle.ts | 241 ++ .../src/domain/portable-plugin-export.ts | 198 ++ .../src/domain/portable-skill-set.ts | 48 +- .../src/domain/skill-set-pack.ts | 187 ++ .../src/domain/skill-set-source-composer.ts | 150 ++ packages/control-plane/src/index.ts | 1 + .../control-plane/src/layers/remote-http.ts | 22 +- .../control-plane/src/library-selection.ts | 4 +- packages/control-plane/src/review/adapters.ts | 196 ++ .../src/review/evaluation-submission.ts | 246 ++ packages/control-plane/src/review/portable.ts | 3 + .../control-plane/src/review/run-package.ts | 120 + .../control-plane/src/science/synthesis.ts | 24 +- .../tests/package-bundle.test.ts | 23 +- .../tests/plugin-capability-bundle.test.ts | 133 + .../tests/portable-plugin-export.test.ts | 75 + .../tests/reconcile-library.test.ts | 1 + .../tests/skill-set-pack.test.ts | 67 + .../tests/skill-set-source-composer.test.ts | 125 + .../tests/synthesis-science.test.ts | 1 + packages/control-plane/vitest.config.ts | 8 + packages/dashboard-core/CHANGELOG.md | 13 - packages/dashboard-core/package.json | 15 +- .../src/chrome/DashboardChrome.tsx | 6 +- .../src/chrome/RuntimeBadge.stories.tsx | 39 + .../src/gates/CloudFeatureGateDialog.test.ts | 12 +- .../src/gates/CloudFeatureGateDialog.tsx | 33 +- .../src/host/DashboardHostProvider.tsx | 54 +- packages/dashboard-core/src/host/adapter.ts | 165 +- .../src/host/capabilities.test.ts | 72 +- .../dashboard-core/src/host/capabilities.ts | 8 +- packages/dashboard-core/src/host/index.ts | 1 + packages/dashboard-core/src/host/modules.ts | 25 + .../src/models/correction-studies.ts | 23 + packages/dashboard-core/src/models/index.ts | 2 + packages/dashboard-core/src/models/library.ts | 13 + packages/dashboard-core/src/models/plugins.ts | 55 + .../dashboard-core/src/models/projects.ts | 91 + .../src/models/team-collaboration.ts | 80 + .../dashboard-core/src/review/adapters.ts | 190 +- .../src/review/evaluation-submission.test.ts | 36 +- .../src/review/evaluation-submission.ts | 421 +--- .../src/review/run-package.test.tsx | 2 +- .../dashboard-core/src/review/run-package.ts | 182 +- .../src/routes/manifest.test.ts | 28 +- .../dashboard-core/src/routes/manifest.ts | 65 +- .../screens/MarketingProductProof.stories.tsx | 259 ++ .../decisions/DurableDecisionCard.test.tsx | 2 +- packages/dashboard-core/src/screens/index.ts | 2 + .../OverviewComparisonSurface.test.ts | 10 +- .../overview/OverviewComparisonSurface.tsx | 42 +- .../OverviewOnboardingBanner.stories.tsx | 30 + .../screens/plugins/PluginsScreen.test.tsx | 114 + .../src/screens/plugins/PluginsScreen.tsx | 486 ++++ .../src/screens/plugins/index.ts | 1 + ...Screen.plugin-install.interaction.test.tsx | 165 ++ .../screens/projects/ProjectsScreen.test.tsx | 103 +- .../src/screens/projects/ProjectsScreen.tsx | 530 +++- .../projects/ShareSkillSetDialog.test.tsx | 58 +- .../screens/projects/ShareSkillSetDialog.tsx | 276 ++- .../projects/SharedSkillSetPacks.stories.tsx | 69 + .../projects/SharedSkillSetPacks.test.tsx | 79 + .../screens/projects/SharedSkillSetPacks.tsx | 270 ++ .../projects/SkillSetWorkspaceNavigation.tsx | 62 +- .../projects/TraceSignalsPanel.test.tsx | 2 +- .../RecipientShareScreen.test.tsx | 75 +- .../recipient-shares/RecipientShareScreen.tsx | 14 +- .../SkillReportTrustBadge.stories.tsx | 19 + ...ctionStudyReviewPanel.interaction.test.tsx | 79 + .../CorrectionStudyReviewPanel.test.tsx | 4 +- .../skills/CorrectionStudyReviewPanel.tsx | 10 +- .../screens/skills/ShareSkillDialog.test.tsx | 63 +- .../src/screens/skills/ShareSkillDialog.tsx | 259 +- .../screens/skills/SkillsLibraryFilters.tsx | 9 +- .../SkillsLibraryScreen.interaction.test.tsx | 384 ++- .../skills/SkillsLibraryScreen.test.tsx | 39 +- .../screens/skills/SkillsLibraryScreen.tsx | 106 +- .../screens/skills/SkillsLibrarySorting.tsx | 168 ++ ...amCollaborationScreen.interaction.test.tsx | 219 ++ .../TeamCollaborationScreen.tsx | 820 ++++++ .../src/screens/team-collaboration/index.ts | 1 + .../dashboard-core/src/test/host-modules.ts | 15 + packages/harnesses/claude-code/CHANGELOG.md | 8 - packages/harnesses/claude-code/package.json | 2 +- packages/harnesses/cline/CHANGELOG.md | 9 - packages/harnesses/cline/package.json | 2 +- packages/harnesses/codex/CHANGELOG.md | 9 - packages/harnesses/codex/package.json | 4 +- packages/harnesses/core/package.json | 2 +- packages/harnesses/openclaw/CHANGELOG.md | 8 - packages/harnesses/openclaw/package.json | 2 +- packages/harnesses/opencode/CHANGELOG.md | 9 - packages/harnesses/opencode/package.json | 2 +- packages/harnesses/pi/CHANGELOG.md | 9 - packages/harnesses/pi/package.json | 2 +- packages/harnesses/registry/CHANGELOG.md | 13 - packages/harnesses/registry/package.json | 2 +- packages/library/src/hash.ts | 34 +- packages/library/src/manifests.ts | 233 +- packages/library/src/remote/sharing.ts | 117 +- packages/library/src/remote/types.ts | 58 +- packages/library/src/remote/workspace.ts | 8 + packages/library/src/storage.ts | 30 +- ..._drop_legacy_evaluation_draft_identity.sql | 1 + .../src/drizzle/meta/_journal.json | 7 + .../src/embedded-migrations.gen.ts | 4 +- packages/observability/package.json | 4 + packages/observability/src/duckdb-node-api.ts | 139 +- packages/observability/src/duckdb-store.ts | 102 + packages/orchestration/CHANGELOG.md | 11 - packages/orchestration/package.json | 2 +- packages/orchestration/src/improve.ts | 23 +- packages/orchestration/vitest.config.ts | 8 + packages/runtime/CHANGELOG.md | 8 - packages/runtime/auth/device-code.ts | 4 +- .../runtime/command-surface/improvement.ts | 21 +- packages/runtime/constants.ts | 6 +- packages/runtime/contribution-config.ts | 11 +- packages/runtime/contribution-relay.ts | 22 +- packages/runtime/contribution-signals.ts | 51 +- packages/runtime/contributions/help.ts | 4 +- .../runtime/creator-contributions-help.ts | 2 +- packages/runtime/creator-contributions.ts | 6 +- .../dashboard-contract/local-management.ts | 6 + packages/runtime/dashboard-reactivity.ts | 2 + packages/runtime/desktop-settings.ts | 7 + packages/runtime/eval/cli-contract.ts | 12 + packages/runtime/eval/hooks-to-evals.ts | 34 +- packages/runtime/eval/output-quality.ts | 455 ++++ packages/runtime/eval/programs.ts | 31 + packages/runtime/eval/unit-test-cli.ts | 18 +- packages/runtime/eval/unit-test.ts | 28 +- .../cloud-evaluation-target-client.ts | 9 +- .../evolution/evidence-cohort-body-adapter.ts | 11 +- .../runtime/evolution/host-task-replay.ts | 235 ++ packages/runtime/evolution/pareto.ts | 71 +- .../runtime/evolution/validate-host-replay.ts | 14 + .../validate-host-replay/contracts.ts | 4 + .../evolution/validate-host-replay/runner.ts | 9 + packages/runtime/license-draft.test.ts | 72 + packages/runtime/license-draft.ts | 137 + packages/runtime/localdb/queries.ts | 2 + packages/runtime/localdb/queries/trust.ts | 88 + packages/runtime/normalization.ts | 3 + packages/runtime/package.json | 4 +- packages/runtime/portable-feedback-helper.ts | 2 +- .../runtime/registry/automatic-suggestions.ts | 343 +++ packages/runtime/registry/client.ts | 4 +- packages/runtime/registry/contracts.ts | 30 + packages/runtime/registry/install-utils.ts | 24 + packages/runtime/registry/install.ts | 41 +- packages/runtime/registry/platform.ts | 280 ++- packages/runtime/registry/program-types.ts | 9 +- packages/runtime/registry/programs.ts | 5 +- packages/runtime/registry/registry-outbox.ts | 95 + .../runtime/registry/registry-state-store.ts | 13 +- packages/runtime/registry/registry-state.ts | 110 + packages/runtime/registry/suggest.ts | 91 + packages/runtime/registry/sync.ts | 255 +- packages/runtime/remote-library/collect.ts | 27 +- packages/runtime/remote-library/config.ts | 27 +- .../package-bundle-collector.cjs | 72 +- packages/runtime/remote-library/pull.ts | 7 +- packages/runtime/remote-library/sync.ts | 13 +- packages/runtime/schedule-definitions.ts | 4 +- packages/runtime/scheduling.ts | 6 +- packages/runtime/testing-readiness.ts | 28 +- .../runtime/testing-readiness/artifacts.ts | 95 +- packages/runtime/utils/cli-error.ts | 1 + packages/runtime/utils/llm-call.ts | 19 +- packages/runtime/utils/skill-discovery.ts | 5 +- packages/skill-intelligence/package.json | 1 + .../skill-intelligence/src/blind-benchmark.ts | 37 +- packages/skill-intelligence/src/index.ts | 1 + .../skill-intelligence/src/paired-replay.ts | 22 +- .../skill-intelligence/src/study-drafts.ts | 83 +- .../src/trajectory-process.ts | 55 + packages/ui/components.json | 4 +- packages/ui/package.json | 5 +- .../ui/src/components/EvidenceViewer.test.tsx | 54 - packages/ui/src/components/EvidenceViewer.tsx | 57 +- .../ui/src/components/PierreDiffReview.tsx | 108 + .../ui/src/components/StatusBadge.stories.tsx | 19 + .../src/components/TriggerSparkline.test.tsx | 2 +- packages/ui/src/components/index.ts | 27 + .../interior-motion-components.test.tsx | 42 + .../components/interior/loading-button.tsx | 274 ++ .../src/components/interior/skeleton-swap.tsx | 170 ++ .../components/interior/sortable-table.tsx | 442 ++++ .../components/interior/wizard-steps.test.tsx | 23 + .../src/components/interior/wizard-steps.tsx | 403 +++ packages/ui/src/primitives/badge.stories.tsx | 18 + packages/ui/src/primitives/button.stories.tsx | 26 + packages/ui/src/primitives/card.stories.tsx | 47 + .../ui/src/primitives/checkbox.stories.tsx | 22 + packages/ui/src/primitives/field.tsx | 65 + packages/ui/src/primitives/index.ts | 2 + packages/ui/src/primitives/input.stories.tsx | 23 + packages/ui/src/primitives/separator.tsx | 19 + scripts/smoke-windows-service.ps1 | 43 - skill/SKILL.md | 53 +- skill/evals/evals.json | 74 + skill/evals/routing.json | 25 + skill/references/cli-quick-reference.md | 1 - skill/references/creator-playbook.md | 6 +- skill/workflows/AlphaUpload.md | 65 - skill/workflows/Contributions.md | 14 +- skill/workflows/CreatorContributions.md | 4 +- skill/workflows/EvalRun.md | 35 + skill/workflows/Evals.md | 15 +- skill/workflows/Evolve.md | 58 +- skill/workflows/Improve.md | 46 +- skill/workflows/Initialize.md | 163 +- skill/workflows/Recover.md | 2 +- skill/workflows/Registry.md | 47 +- skill/workflows/SignalsDashboard.md | 46 +- skill/workflows/Sync.md | 5 +- skill/workflows/UnitTest.md | 50 +- tests/cli/cli-error.test.ts | 22 + tests/cli/cli-routing-parity.test.ts | 14 +- tests/cli/daemon-effect-cli.test.ts | 2 +- tests/cli/effect-cli.test.ts | 63 +- tests/cli/eval-effect-cli.test.ts | 42 +- tests/cli/recover-effect-cli-compat.test.ts | 2 +- tests/cli/registry-effect-cli.test.ts | 2 +- tests/cli/registry-legacy-contract.test.ts | 13 +- .../contribution-relay.test.ts | 1 + .../contribution-signals.test.ts | 2 +- tests/contributions/contributions.test.ts | 24 +- .../creator-contributions.test.ts | 8 +- tests/dashboard/dashboard-server.test.ts | 64 + tests/dashboard/sync-destination.test.ts | 13 +- tests/dashboard/tray-status.test.ts | 100 + tests/eval/hooks-to-evals.test.ts | 6 +- tests/eval/output-quality.test.ts | 110 + tests/eval/unit-test.test.ts | 31 + tests/evolution/host-task-replay.test.ts | 68 + tests/evolution/pareto.test.ts | 20 + tests/improve.test.ts | 63 + tests/init/device-code.test.ts | 2 +- tests/init/install-hooks.test.ts | 19 +- tests/library/package-bundle.test.ts | 72 +- tests/library/remote-library-config.test.ts | 8 +- tests/library/remote-library-sync.test.ts | 125 +- tests/localdb/drizzle-migrations.test.ts | 18 +- tests/normalization/normalization.test.ts | 7 + tests/observability/duckdb-node-api.test.ts | 237 +- tests/observability/duckdb-store.test.ts | 126 +- ...ncode-duckdb-dashboard.integration.test.ts | 222 +- .../local-trace-importer.test.ts | 1 + .../automatic-suggestions-loopback.test.ts | 239 ++ tests/registry/automatic-suggestions.test.ts | 212 ++ tests/registry/platform.test.ts | 145 +- tests/registry/programs.test.ts | 491 +++- tests/registry/registry-state.test.ts | 53 + tests/runtime/daemon.test.ts | 63 +- tests/runtime/service-systemd-backend.test.ts | 2 +- ...ce-windows-installation-controller.test.ts | 42 - ...vice-windows-installation-evidence.test.ts | 49 - .../service-windows-installation-live.test.ts | 36 +- ...service-windows-installation-store.test.ts | 60 +- ...-windows-legacy-cleanup-controller.test.ts | 67 - .../service-windows-legacy-migration.test.ts | 9 - .../service-windows-mutation-lock.test.ts | 6 +- tests/runtime/service.test.ts | 19 +- tests/schedule/schedule.test.ts | 13 +- tests/scripts/release-workflows.test.ts | 159 +- tests/settings/onboarding.test.ts | 8 +- tests/skill-sets/blind-benchmark.test.ts | 19 +- tests/skill-sets/paired-replay.test.ts | 15 + ...telligence-catalog-duckdb-failsoft.test.ts | 7 + tests/skill-sets/skill-set-packs.test.ts | 76 + tests/skill-sets/skill-sets.test.ts | 68 + tests/skill-sets/study-drafts.test.ts | 25 - tests/testing-readiness.test.ts | 51 +- tests/trust-floor/health.test.ts | 254 +- tests/trust-floor/publish-deps.test.ts | 128 +- tests/utils/llm-call.test.ts | 35 + 494 files changed, 26812 insertions(+), 22428 deletions(-) create mode 100644 .changeset/v040-local-first-relaunch.md delete mode 100644 .github/sbom-toolchain/package-lock.json delete mode 100644 .github/sbom-toolchain/package.json delete mode 100644 Research_trigger_eval.json delete mode 100644 apps/cli/CHANGELOG.md create mode 100644 apps/desktop/build/dmg-background.png create mode 100644 apps/desktop/build/icon-mac.png create mode 100644 apps/desktop/build/icon-mac.svg create mode 100644 apps/desktop/scripts/build-mac-icon.ts create mode 100644 apps/desktop/src/main/desktop-activation.test.ts create mode 100644 apps/desktop/src/main/desktop-activation.ts delete mode 100644 apps/desktop/src/main/desktop-install-credential.test.ts delete mode 100644 apps/desktop/src/main/desktop-install-credential.ts create mode 100644 apps/desktop/src/main/desktop-pack-handoff.test.ts create mode 100644 apps/desktop/src/main/desktop-pack-handoff.ts delete mode 100644 apps/local-dashboard/CHANGELOG.md create mode 100644 apps/local-dashboard/src/dashboard-host-collaboration.test.tsx delete mode 100644 apps/local-dashboard/src/dashboard-host-selfhost.test.ts create mode 100644 apps/local-dashboard/src/hooks/usePlugins.ts delete mode 100644 apps/local-dashboard/src/local-library-transfer-actions.test.ts create mode 100644 apps/local-dashboard/src/pages/Team.test.ts create mode 100644 apps/local-dashboard/src/pages/Team.tsx delete mode 100644 apps/local/CHANGELOG.md create mode 100644 apps/local/src/cloud-team-collaboration.ts create mode 100644 apps/local/src/historical-evidence-safety.ts create mode 100644 apps/local/src/historical-skill-improve-cli.ts create mode 100644 apps/local/src/historical-skill-improvement-service.ts create mode 100644 apps/local/src/historical-skill-replay-executor.ts create mode 100644 apps/local/src/historical-task-candidate.ts create mode 100644 apps/local/src/hosted-state.ts create mode 100644 apps/local/src/plugin-inventory.ts create mode 100644 apps/local/src/prepared-trace-candidate-draft.ts delete mode 100644 apps/local/src/service/windows/installation/task-definition-proof.ts create mode 100644 apps/local/src/skill-set-pack-import.ts create mode 100644 apps/local/src/skill-set-plugin-install.ts create mode 100644 apps/local/src/trace-candidate-contract.ts create mode 100644 apps/local/tests/cloud-team-collaboration.test.ts create mode 100644 apps/local/tests/historical-skill-improvement-service.test.ts create mode 100644 apps/local/tests/historical-skill-replay-executor.test.ts create mode 100644 apps/local/tests/historical-task-candidate.test.ts create mode 100644 apps/local/tests/hosted-state.test.ts delete mode 100644 apps/local/tests/library-transfer-routes.test.ts create mode 100644 apps/local/tests/plugin-inventory.test.ts create mode 100644 apps/local/tests/plugin-routes.test.ts create mode 100644 apps/local/tests/skill-set-plugin-install.test.ts create mode 100644 apps/local/tests/team-collaboration-routes.test.ts create mode 100644 apps/local/tests/trace-candidate-threshold.test.ts delete mode 100644 apps/selfhost/CHANGELOG.md delete mode 100644 docs/design-docs/alpha-remote-data-contract.md create mode 100644 docs/design-docs/portable-skill-evaluation-contract.md delete mode 100644 docs/exec-plans/active/agent-first-alpha-onboarding.md delete mode 100644 docs/exec-plans/active/alpha-rollout-data-loop-plan.md delete mode 100644 docs/exec-plans/active/alpha-simplification-program.md delete mode 100644 docs/exec-plans/active/consumer-creator-progressive-disclosure-plan.md delete mode 100644 docs/exec-plans/completed/output-quality-loop-prereqs.md delete mode 100644 docs/exec-plans/completed/telemetry-normalization.md delete mode 100644 docs/exec-plans/deferred/cloud-auth-unification-for-alpha.md delete mode 100644 docs/exec-plans/reference/state-change-skills-call-2026-04-09-insights.md delete mode 100644 packages/api-contract/README.md delete mode 100644 packages/api-contract/fixtures/distribution-binding-golden.json delete mode 100644 packages/api-contract/index.ts delete mode 100644 packages/api-contract/package.json delete mode 100644 packages/api-contract/src/billing.ts delete mode 100644 packages/api-contract/src/client.ts delete mode 100644 packages/api-contract/src/cloud-improve.ts delete mode 100644 packages/api-contract/src/contracts.ts delete mode 100644 packages/api-contract/src/distribution-read-models.ts delete mode 100644 packages/api-contract/src/distribution-source-staging.ts delete mode 100644 packages/api-contract/src/distribution.ts delete mode 100644 packages/api-contract/src/github.ts delete mode 100644 packages/api-contract/src/install-credentials.ts delete mode 100644 packages/api-contract/src/recipient-actions.ts delete mode 100644 packages/api-contract/src/share-grants.ts delete mode 100644 packages/api-contract/src/share-invitations.ts delete mode 100644 packages/api-contract/src/signal-routing-capability.ts delete mode 100644 packages/api-contract/src/team.ts delete mode 100644 packages/api-contract/tests/billing.test.ts delete mode 100644 packages/api-contract/tests/client.test.ts delete mode 100644 packages/api-contract/tests/cloud-improve.test.ts delete mode 100644 packages/api-contract/tests/distribution-source-staging.test.ts delete mode 100644 packages/api-contract/tests/distribution.test.ts delete mode 100644 packages/api-contract/tests/github.test.ts delete mode 100644 packages/api-contract/tests/install-credentials.test.ts delete mode 100644 packages/api-contract/tests/recipient-actions-client.test.ts delete mode 100644 packages/api-contract/tests/recipient-actions-exports.test.ts delete mode 100644 packages/api-contract/tests/recipient-actions.test.ts delete mode 100644 packages/api-contract/tests/share-grants.test.ts delete mode 100644 packages/api-contract/tests/share-invitations.test.ts delete mode 100644 packages/api-contract/tests/signal-routing-capability-exports.test.ts delete mode 100644 packages/api-contract/tests/signal-routing-capability.test.ts delete mode 100644 packages/api-contract/tests/skill-detail.test.ts delete mode 100644 packages/api-contract/tests/team.test.ts delete mode 100644 packages/api-contract/tsconfig.json delete mode 100644 packages/app-core/CHANGELOG.md delete mode 100644 packages/app-core/src/route-factory.tsx create mode 100644 packages/app-core/vitest.config.ts create mode 100644 packages/control-plane/src/domain/hosted-state.ts create mode 100644 packages/control-plane/src/domain/plugin-capability-bundle.ts create mode 100644 packages/control-plane/src/domain/portable-plugin-export.ts create mode 100644 packages/control-plane/src/domain/skill-set-pack.ts create mode 100644 packages/control-plane/src/domain/skill-set-source-composer.ts create mode 100644 packages/control-plane/src/review/adapters.ts create mode 100644 packages/control-plane/src/review/evaluation-submission.ts create mode 100644 packages/control-plane/src/review/portable.ts create mode 100644 packages/control-plane/src/review/run-package.ts create mode 100644 packages/control-plane/tests/plugin-capability-bundle.test.ts create mode 100644 packages/control-plane/tests/portable-plugin-export.test.ts create mode 100644 packages/control-plane/tests/skill-set-pack.test.ts create mode 100644 packages/control-plane/tests/skill-set-source-composer.test.ts create mode 100644 packages/control-plane/vitest.config.ts delete mode 100644 packages/dashboard-core/CHANGELOG.md create mode 100644 packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx create mode 100644 packages/dashboard-core/src/host/modules.ts create mode 100644 packages/dashboard-core/src/models/plugins.ts create mode 100644 packages/dashboard-core/src/models/team-collaboration.ts create mode 100644 packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx create mode 100644 packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.stories.tsx create mode 100644 packages/dashboard-core/src/screens/plugins/PluginsScreen.test.tsx create mode 100644 packages/dashboard-core/src/screens/plugins/PluginsScreen.tsx create mode 100644 packages/dashboard-core/src/screens/plugins/index.ts create mode 100644 packages/dashboard-core/src/screens/projects/ProjectsScreen.plugin-install.interaction.test.tsx create mode 100644 packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.stories.tsx create mode 100644 packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.test.tsx create mode 100644 packages/dashboard-core/src/screens/projects/SharedSkillSetPacks.tsx create mode 100644 packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.stories.tsx create mode 100644 packages/dashboard-core/src/screens/skills/SkillsLibrarySorting.tsx create mode 100644 packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.interaction.test.tsx create mode 100644 packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.tsx create mode 100644 packages/dashboard-core/src/screens/team-collaboration/index.ts create mode 100644 packages/dashboard-core/src/test/host-modules.ts delete mode 100644 packages/harnesses/claude-code/CHANGELOG.md delete mode 100644 packages/harnesses/cline/CHANGELOG.md delete mode 100644 packages/harnesses/codex/CHANGELOG.md delete mode 100644 packages/harnesses/openclaw/CHANGELOG.md delete mode 100644 packages/harnesses/opencode/CHANGELOG.md delete mode 100644 packages/harnesses/pi/CHANGELOG.md delete mode 100644 packages/harnesses/registry/CHANGELOG.md create mode 100644 packages/local-store/src/drizzle/0018_drop_legacy_evaluation_draft_identity.sql delete mode 100644 packages/orchestration/CHANGELOG.md create mode 100644 packages/orchestration/vitest.config.ts delete mode 100644 packages/runtime/CHANGELOG.md create mode 100644 packages/runtime/eval/output-quality.ts create mode 100644 packages/runtime/evolution/host-task-replay.ts create mode 100644 packages/runtime/license-draft.test.ts create mode 100644 packages/runtime/license-draft.ts create mode 100644 packages/runtime/registry/automatic-suggestions.ts create mode 100644 packages/runtime/registry/registry-outbox.ts create mode 100644 packages/runtime/registry/suggest.ts create mode 100644 packages/skill-intelligence/src/trajectory-process.ts delete mode 100644 packages/ui/src/components/EvidenceViewer.test.tsx create mode 100644 packages/ui/src/components/PierreDiffReview.tsx create mode 100644 packages/ui/src/components/StatusBadge.stories.tsx create mode 100644 packages/ui/src/components/interior/interior-motion-components.test.tsx create mode 100644 packages/ui/src/components/interior/loading-button.tsx create mode 100644 packages/ui/src/components/interior/skeleton-swap.tsx create mode 100644 packages/ui/src/components/interior/sortable-table.tsx create mode 100644 packages/ui/src/components/interior/wizard-steps.test.tsx create mode 100644 packages/ui/src/components/interior/wizard-steps.tsx create mode 100644 packages/ui/src/primitives/badge.stories.tsx create mode 100644 packages/ui/src/primitives/button.stories.tsx create mode 100644 packages/ui/src/primitives/card.stories.tsx create mode 100644 packages/ui/src/primitives/checkbox.stories.tsx create mode 100644 packages/ui/src/primitives/field.tsx create mode 100644 packages/ui/src/primitives/input.stories.tsx create mode 100644 packages/ui/src/primitives/separator.tsx create mode 100644 skill/evals/evals.json create mode 100644 skill/evals/routing.json delete mode 100644 skill/workflows/AlphaUpload.md create mode 100644 skill/workflows/EvalRun.md create mode 100644 tests/cli/cli-error.test.ts create mode 100644 tests/dashboard/tray-status.test.ts create mode 100644 tests/eval/output-quality.test.ts create mode 100644 tests/evolution/host-task-replay.test.ts create mode 100644 tests/registry/automatic-suggestions-loopback.test.ts create mode 100644 tests/registry/automatic-suggestions.test.ts create mode 100644 tests/skill-sets/skill-set-packs.test.ts diff --git a/.changeset/README.md b/.changeset/README.md index 19f49dd9..9826f8b5 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,8 +1,9 @@ # Changesets Every pull request that changes a shipped SelfTune surface must include a changeset. -Run `bun run changeset`, select `selftune`, and describe the user-visible change. +Run `bun run changeset`, select `@selftune/desktop`, and describe the user-visible change. -The `selftune` npm package and `@selftune/desktop` are a fixed release group. A -single `selftune` changeset advances both versions in the post-merge Version +`@selftune/desktop` is the workspace-visible owner of the coupled release train. +A desktop changeset advances its version, then `changeset:version` synchronizes +the root `selftune` npm package to the same version in the post-merge Version Packages pull request. diff --git a/.changeset/v040-local-first-relaunch.md b/.changeset/v040-local-first-relaunch.md new file mode 100644 index 00000000..aa4ffcf8 --- /dev/null +++ b/.changeset/v040-local-first-relaunch.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": minor +--- + +Relaunch SelfTune as a local-first control center for agent skills: discover and reconcile libraries across supported harnesses, organize reusable Skill Sets, scope them to projects, review updates and evidence, and share portable packages through explicit local or optional remote boundaries. diff --git a/.github/release-notes.json b/.github/release-notes.json index 02763420..8e58ccbb 100644 --- a/.github/release-notes.json +++ b/.github/release-notes.json @@ -4,24 +4,202 @@ "docsUrl": "https://docs.selftune.dev/changelog", "entries": [ { - "label": "2026-07-30", - "description": "SelfTune v0.3.0 makes the Skill Manager ready for teams", - "tags": ["Cloud", "OSS", "Desktop", "Dashboard"], + "label": "2026-08-28", + "description": "Cloud sign-in now explains the workspace value", + "tags": ["Cloud", "Dashboard"], "rss": { - "title": "SelfTune v0.3.0 ships the Skill Manager for individuals and teams", + "title": "SelfTune Cloud gets a clearer account journey", "description": null }, - "bullets": [ - "**Manage skills visually** — search and inspect installed skills, understand their source and revision, organize categories, and review reversible cleanup recommendations without editing configuration files.", - "**Build reusable Skill Sets** — create and edit sets directly, review evidence-backed suggestions, and use local preview, apply, rollback, and conflict-resolution flows before changing a project.", - "**Share exact packages by link** — create an expiring, revocable link for an individual skill or Skill Set. The link downloads the exact immutable package directly; Cloud email, member, private-claim, and workspace delivery are not part of this release.", - "**Keep host boundaries honest** — Desktop owns filesystem changes, Cloud provides team management, reusable-link issuance, and direct immutable package downloads, and Self-host exposes remote Skills and Skill Sets as read-only until its mutation API is available.", - "**Harden cross-platform delivery** — packaged Desktop builds retain their native runtime dependencies, support large macOS filesystem identities, and keep Windows setup and CLI lifecycle checks portable and bounded across release runners." - ], - "versionRange": { - "from": "0.3.0", - "to": "0.3.0" - } + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-27", + "description": "Local skills can now be served to MCP agents", + "tags": ["CLI", "OSS", "Platforms"], + "rss": { + "title": "SelfTune adds a local Skills-over-MCP registry", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-26", + "description": "SelfTune prepares a coupled v0.4 release train", + "tags": ["CLI", "OSS", "Dashboard", "Platforms"], + "rss": { + "title": "SelfTune prepares the v0.4 release train", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-25", + "description": "Codex ingestion filters injected context from user evidence", + "tags": ["CLI", "OSS", "Platforms"], + "rss": { + "title": "SelfTune makes Codex ingestion wrapper-safe", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-25", + "description": "Desktop onboarding now connects the user's AI agent", + "tags": ["Dashboard", "OSS", "Platforms"], + "rss": { + "title": "SelfTune Desktop adds agent-skill setup to onboarding", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-25", + "description": "The website and docs now lead with local evidence-backed skill improvement", + "tags": ["OSS", "Dashboard", "CLI"], + "rss": { + "title": "SelfTune rebuilds its public product story around the local evaluation loop", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-25", + "description": "SelfTune runs paired output-quality evaluations for skills", + "tags": ["CLI", "OSS"], + "rss": { + "title": "SelfTune adds evidence-backed paired skill evaluation runs", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-25", + "description": "Skills can carry portable evaluation cases and expected results", + "tags": ["CLI", "OSS"], + "rss": { + "title": "SelfTune packages portable skill evaluations", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-25", + "description": "Desktop and Cloud now share explicit journey modules", + "tags": ["Cloud", "Dashboard", "OSS"], + "rss": { + "title": "SelfTune completes the shared Desktop and Cloud host cutover", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-11", + "description": "Desktop manages the plugins already installed in Claude and Codex", + "tags": ["Dashboard", "OSS", "Platforms"], + "rss": { + "title": "SelfTune Desktop adds unified local plugin management", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-09", + "description": "Desktop installs a reviewed Skill Set in Claude and Codex with one confirmation", + "tags": ["Dashboard", "OSS", "Platforms"], + "rss": { + "title": "SelfTune Desktop adds native one-click Skill Set plugin installation", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-09", + "description": "Desktop Skill Sets can be deleted without a terminal", + "tags": ["Dashboard", "OSS"], + "rss": { + "title": "SelfTune Desktop adds local Skill Set deletion", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-08", + "description": "Skill Sets export to Claude, OpenAI, and Agent Plugins 1.0", + "tags": ["Cloud", "Dashboard", "Platforms"], + "rss": { + "title": "SelfTune adds exact-revision multi-platform plugin export", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-06", + "description": "SelfTune can evaluate skill improvements from historical local traces", + "tags": ["CLI", "Dashboard", "OSS"], + "rss": { + "title": "SelfTune adds historical trace evaluation for skill improvement", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-04", + "description": "SelfTune marketing now leads with team skill synchronization", + "tags": ["Cloud", "Community"], + "rss": { + "title": "SelfTune reframes the product around always-in-sync team skills", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-03", + "description": "Skill sharing can draft and review missing proprietary licenses", + "tags": ["Dashboard", "OSS"], + "rss": { + "title": "SelfTune adds review-before-write license drafting", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-08-01", + "description": "Desktop recovers safely from a known DuckDB observability WAL failure", + "tags": ["OSS", "Desktop"], + "rss": { + "title": "SelfTune Desktop adds automatic observability WAL recovery", + "description": null + }, + "bullets": [], + "versionRange": null + }, + { + "label": "2026-07-31", + "description": "Teams can review local skill revisions and manage safe rollouts", + "tags": ["Cloud", "CLI", "Dashboard", "Registry", "OSS"], + "rss": { + "title": "SelfTune adds exact-revision team collaboration and managed skill rollouts", + "description": null + }, + "bullets": [], + "versionRange": null }, { "label": "2026-07-29", @@ -135,7 +313,7 @@ }, { "label": "2026-07-22", - "description": "Skill Sets can now be shared by reusable link", + "description": "Skill Sets can now be shared by link, email, member, or workspace", "tags": ["Cloud", "OSS", "Desktop", "Dashboard"], "rss": { "title": "SelfTune adds portable Skill Set sharing", diff --git a/.github/sbom-toolchain/package-lock.json b/.github/sbom-toolchain/package-lock.json deleted file mode 100644 index 7f43e86f..00000000 --- a/.github/sbom-toolchain/package-lock.json +++ /dev/null @@ -1,2206 +0,0 @@ -{ - "name": "selftune-release-sbom-toolchain", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "selftune-release-sbom-toolchain", - "version": "1.0.0", - "dependencies": { - "@cyclonedx/cyclonedx-npm": "6.0.0", - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "ajv-formats-draft2019": "1.6.1" - } - }, - "node_modules/@cyclonedx/cyclonedx-library": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-library/-/cyclonedx-library-10.1.0.tgz", - "integrity": "sha512-4yq0KTQIA32UHJwFMDeY5xj2asp3Mk5lzx4JRVXLOb0YE8w1KeR61c1m/gJ4sjMXpiiEDfaITVQu8BLUuy7t3A==", - "funding": [ - { - "type": "individual", - "url": "https://owasp.org/donate/?reponame=www-project-cyclonedx&title=OWASP+CycloneDX" - } - ], - "license": "Apache-2.0", - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "ajv-formats-draft2019": "^1.6.1", - "libxmljs2": "^0.35||^0.37", - "packageurl-js": "*", - "spdx-expression-parse": "*", - "xmlbuilder2": "^3.0.2||^4.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - }, - "ajv-formats": { - "optional": true - }, - "ajv-formats-draft2019": { - "optional": true - }, - "libxmljs2": { - "optional": true - }, - "packageurl-js": { - "optional": true - }, - "spdx-expression-parse": { - "optional": true - }, - "xmlbuilder2": { - "optional": true - } - } - }, - "node_modules/@cyclonedx/cyclonedx-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-npm/-/cyclonedx-npm-6.0.0.tgz", - "integrity": "sha512-kpWjjV0j5y0mMHUB5dSx1hxweH8K2blSqkgdQ6eHgU7aClB4CcXGhbHtGY6WVHSo3A01Rt7WOLas/wQ1E+tBDg==", - "funding": [ - { - "type": "individual", - "url": "https://owasp.org/donate/?reponame=www-project-cyclonedx&title=OWASP+CycloneDX" - } - ], - "license": "Apache-2.0", - "dependencies": { - "@cyclonedx/cyclonedx-library": "^10.0.0", - "commander": "^14.0.0", - "normalize-package-data": "^7.0.0 || ^8.0.0", - "packageurl-js": "^2.0.1", - "spdx-expression-parse": "^3.0.1 || ^4.0.0", - "xmlbuilder2": "^3.0.2 || ^4.0.3" - }, - "bin": { - "cyclonedx-npm": "bin/cyclonedx-npm-cli.js" - }, - "engines": { - "node": ">=20.18.0", - "npm": ">=9" - }, - "optionalDependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "ajv-formats-draft2019": "^1.6.1", - "libxmljs2": "^0.35||^0.37" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "license": "ISC", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "license": "ISC", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@oozcitak/dom": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", - "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", - "license": "MIT", - "dependencies": { - "@oozcitak/infra": "^2.0.2", - "@oozcitak/url": "^3.0.0", - "@oozcitak/util": "^10.0.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@oozcitak/infra": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", - "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", - "license": "MIT", - "dependencies": { - "@oozcitak/util": "^10.0.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@oozcitak/url": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", - "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", - "license": "MIT", - "dependencies": { - "@oozcitak/infra": "^2.0.2", - "@oozcitak/util": "^10.0.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@oozcitak/util": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", - "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", - "license": "MIT", - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats-draft2019": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", - "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1", - "schemes": "^1.4.0", - "smtp-address-parser": "^1.0.3", - "uri-js": "^4.4.1" - }, - "peerDependencies": { - "ajv": "*" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT", - "optional": true - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "optional": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT", - "optional": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT", - "optional": true - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "optional": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "optional": true - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT", - "optional": true - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "optional": true - }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "optional": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true - }, - "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/libxmljs2": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.37.0.tgz", - "integrity": "sha512-Xb78V8GZouoZFrq8cCwx7+G3WYOcJG0xb3YUbweSyE4z2EIrQCZMr3Ye/dHn4mESs6YxUMeQeUZm5IXg+iLHog==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "bindings": "~1.5.0", - "nan": "~2.22.2", - "node-gyp": "^11.2.0", - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC", - "optional": true - }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "optional": true - }, - "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", - "license": "BSD-3-Clause" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true - }, - "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", - "license": "MIT", - "optional": true - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT", - "optional": true - }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", - "license": "MIT", - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/nearley/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/normalize-package-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", - "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^9.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/p-map": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", - "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0", - "optional": true - }, - "node_modules/packageurl-js": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-2.0.1.tgz", - "integrity": "sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg==", - "license": "MIT" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", - "license": "CC0-1.0" - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "license": "MIT", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "license": "MIT", - "engines": { - "node": ">=0.12" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true - }, - "node_modules/schemes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/schemes/-/schemes-1.4.0.tgz", - "integrity": "sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==", - "license": "MIT", - "dependencies": { - "extend": "^3.0.0" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "optional": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/smtp-address-parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", - "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", - "license": "MIT", - "dependencies": { - "nearley": "^2.20.1" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "license": "CC0-1.0" - }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "optional": true - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "optional": true, - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "optional": true - }, - "node_modules/xmlbuilder2": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", - "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", - "license": "MIT", - "dependencies": { - "@oozcitak/dom": "^2.0.2", - "@oozcitak/infra": "^2.0.2", - "@oozcitak/util": "^10.0.0", - "js-yaml": "^4.1.1" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - } - } -} diff --git a/.github/sbom-toolchain/package.json b/.github/sbom-toolchain/package.json deleted file mode 100644 index 553fcf44..00000000 --- a/.github/sbom-toolchain/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "selftune-release-sbom-toolchain", - "version": "1.0.0", - "private": true, - "dependencies": { - "@cyclonedx/cyclonedx-npm": "6.0.0", - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "ajv-formats-draft2019": "1.6.1" - } -} diff --git a/.github/scripts/smoke-selfhost-image.sh b/.github/scripts/smoke-selfhost-image.sh index 5eb6e12a..2e6b9eaf 100644 --- a/.github/scripts/smoke-selfhost-image.sh +++ b/.github/scripts/smoke-selfhost-image.sh @@ -29,20 +29,8 @@ esac container="selftune-release-proof-${platform#linux/}-$$" work_dir="$(mktemp -d)" -read -r token < <(openssl rand -hex 32) -read -r member_token < <(openssl rand -hex 32) -if [[ ! "$token" =~ ^[a-f0-9]{64}$ ]]; then - echo "Self-host image proof failed (admin token generation): expected 64 lowercase hex characters." >&2 - exit 1 -fi -if [[ ! "$member_token" =~ ^[a-f0-9]{64}$ ]]; then - echo "Self-host image proof failed (member token generation): expected 64 lowercase hex characters." >&2 - exit 1 -fi -if [[ "$token" == "$member_token" ]]; then - echo "Self-host image proof failed (token isolation): generated duplicate credentials." >&2 - exit 1 -fi +token="TOKEN_PLACEHOLDER" +member_token="TOKEN_PLACEHOLDER" member_email="release-proof-recipient@example.com" users_json="$(jq --null-input --compact-output \ --arg email "$member_email" \ @@ -57,10 +45,7 @@ cleanup() { trap cleanup EXIT dump_logs() { - docker inspect \ - --format 'container status={{.State.Status}} exit_code={{.State.ExitCode}} error={{json .State.Error}}' \ - "$container" >&2 2>/dev/null || true - docker logs "$container" >&2 || true + docker logs "$container" >&2 2>/dev/null || true } trap dump_logs ERR @@ -84,32 +69,6 @@ request_status() { curl --silent --show-error --max-time 10 --output "$output_path" --write-out '%{http_code}' "$@" } -assert_equal() { - local check="$1" - local expected="$2" - local actual="$3" - if [[ "$actual" == "$expected" ]]; then - return 0 - fi - printf 'Self-host image proof failed (%s): expected %q, got %q\n' \ - "$check" "$expected" "$actual" >&2 - return 1 -} - -assert_json_array_singleton() { - local check="$1" - local expected="$2" - local actual="$3" - if jq --exit-status --arg expected "$expected" \ - 'type == "array" and length == 1 and (.[0] | ascii_downcase) == ($expected | ascii_downcase)' \ - <<<"$actual" >/dev/null; then - return 0 - fi - printf 'Self-host image proof failed (%s): expected only %q, got %s\n' \ - "$check" "$expected" "$actual" >&2 - return 1 -} - docker pull --platform "$platform" "$image_ref" docker run --detach \ @@ -126,29 +85,13 @@ docker run --detach \ --env "SELFTUNE_PUBLIC_URL=$base_url" \ "$image_ref" >/dev/null -expected_image_id="$(docker image inspect --format '{{.Id}}' "$image_ref")" -container_image_id="$(docker inspect --format '{{.Image}}' "$container")" -assert_equal "immutable container image ID" "$expected_image_id" "$container_image_id" -assert_equal \ - "read-only root filesystem" \ - "true" \ - "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container")" -assert_json_array_singleton \ - "dropped capabilities" \ - "ALL" \ - "$(docker inspect --format '{{json .HostConfig.CapDrop}}' "$container")" -security_options="$(docker inspect --format '{{json .HostConfig.SecurityOpt}}' "$container")" -if ! jq --exit-status \ - 'type == "array" and length == 1 and - (.[0] == "no-new-privileges" or .[0] == "no-new-privileges:true")' \ - <<<"$security_options" >/dev/null; then - printf 'Self-host image proof failed (security options): expected only no-new-privileges, got %s\n' \ - "$security_options" >&2 - exit 1 -fi -assert_equal "container state" "running" "$(docker inspect --format '{{.State.Status}}' "$container")" -assert_equal "runtime user" "10001" "$(docker exec "$container" id -u)" -assert_equal "runtime architecture" "$expected_machine" "$(docker exec "$container" uname -m)" +[[ "$(docker inspect --format '{{.Config.Image}}' "$container")" == "$image_ref" ]] +[[ "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container")" == "true" ]] +[[ "$(docker inspect --format '{{json .HostConfig.CapDrop}}' "$container")" == '["ALL"]' ]] +[[ "$(docker inspect --format '{{json .HostConfig.SecurityOpt}}' "$container")" == \ + '["no-new-privileges"]' ]] +[[ "$(docker exec "$container" id -u)" == "10001" ]] +[[ "$(docker exec "$container" uname -m)" == "$expected_machine" ]] wait_for_readiness diff --git a/.github/workflows/auto-bump-cli-version.yml b/.github/workflows/auto-bump-cli-version.yml index b087b70e..7f3d684c 100644 --- a/.github/workflows/auto-bump-cli-version.yml +++ b/.github/workflows/auto-bump-cli-version.yml @@ -25,8 +25,6 @@ on: - "bun.lock" - "cli/selftune/**" - "package.json" - - "packages/api-contract/**" - - "packages/app-core/**" - "packages/control-plane/**" - "packages/dashboard-core/**" - "packages/harnesses/**" diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 33a6f582..65f9247e 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -42,8 +42,6 @@ on: - apps/local-dashboard/** - scripts/smoke-windows-service.ps1 - cli/selftune/** - - packages/api-contract/** - - packages/app-core/** - packages/control-plane/** - packages/dashboard-core/** - packages/harnesses/** @@ -70,7 +68,7 @@ concurrency: jobs: build-windows-sidecar: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 env: BUN_TARGET: bun-windows-x64 steps: @@ -106,23 +104,21 @@ jobs: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" bun run scripts/release-version.ts check --stable --tag-env RELEASE_TAG - - name: Install host tooling and Windows runtime bindings - run: bun install --frozen-lockfile --os='*' --cpu=x64 + - name: Download cross-compiled Windows sidecar + if: matrix.platform == 'win' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: selftune-runtime-windows-x64 + path: ${{ runner.temp }}/selftune-sidecar + + - run: bun install --frozen-lockfile - run: bun run --cwd apps/desktop build:ui - run: bun run --cwd apps/desktop build:sidecar - - name: Verify complete cross-compiled Windows runtime - run: | - set -euo pipefail - test -s apps/desktop/resources/selftune/selftune.exe - test -s apps/desktop/resources/selftune/selftune-report-worker.exe - test -s apps/desktop/resources/selftune/runtime-manifest.json - test -s apps/desktop/resources/selftune/node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: selftune-runtime-windows-x64 - path: apps/desktop/resources/selftune + path: apps/desktop/resources/selftune/selftune.exe if-no-files-found: error build: @@ -198,13 +194,6 @@ jobs: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" bun run scripts/release-version.ts check --stable --tag-env RELEASE_TAG - - name: Download cross-compiled Windows runtime - if: matrix.platform == 'win' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: selftune-runtime-windows-x64 - path: apps/desktop/prebuilt-runtime - - run: bun install --frozen-lockfile - run: bun run --cwd apps/desktop typecheck - run: bun run --cwd apps/desktop test @@ -212,10 +201,10 @@ jobs: - name: Build desktop if: matrix.platform != 'win' run: bun run --cwd apps/desktop build - - name: Build desktop with cross-compiled Windows runtime + - name: Build desktop with cross-compiled Windows sidecar if: matrix.platform == 'win' env: - SELFTUNE_PREBUILT_RUNTIME_DIR: ${{ github.workspace }}/apps/desktop/prebuilt-runtime + SELFTUNE_PREBUILT_SIDECAR: ${{ runner.temp }}/selftune-sidecar/selftune.exe run: bun run --cwd apps/desktop build - run: bun run --cwd apps/desktop smoke:sidecar - run: bun run --cwd apps/desktop smoke:sidecar-duckdb @@ -266,18 +255,11 @@ jobs: echo "APPLE_API_KEY_PATH=${RUNNER_TEMP}/private_keys/AuthKey.p8" >> "$GITHUB_ENV" - name: Build unsigned desktop distributable - if: inputs.release_tag == '' && matrix.platform != 'win' + if: inputs.release_tag == '' env: CSC_IDENTITY_AUTO_DISCOVERY: "false" run: bun run --cwd apps/desktop ${{ matrix.package_script }} -- ${{ matrix.arch_flag }} --publish never - - name: Build unsigned Windows desktop distributable - if: inputs.release_tag == '' && matrix.platform == 'win' - env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" - SELFTUNE_PREBUILT_RUNTIME_DIR: ${{ github.workspace }}/apps/desktop/prebuilt-runtime - run: bun run --cwd apps/desktop ${{ matrix.package_script }} -- ${{ matrix.arch_flag }} --publish never - - name: Build signed macOS release distributable if: inputs.release_tag != '' && matrix.platform == 'mac' shell: bash @@ -337,16 +319,9 @@ jobs: done - name: Build non-macOS release distributable - if: inputs.release_tag != '' && matrix.platform != 'mac' && matrix.platform != 'win' - env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" - run: bun run --cwd apps/desktop ${{ matrix.package_script }} -- ${{ matrix.arch_flag }} --publish never - - - name: Build Windows release distributable - if: inputs.release_tag != '' && matrix.platform == 'win' + if: inputs.release_tag != '' && matrix.platform != 'mac' env: CSC_IDENTITY_AUTO_DISCOVERY: "false" - SELFTUNE_PREBUILT_RUNTIME_DIR: ${{ github.workspace }}/apps/desktop/prebuilt-runtime run: bun run --cwd apps/desktop ${{ matrix.package_script }} -- ${{ matrix.arch_flag }} --publish never - name: Verify signed macOS application bundle diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ec205ed6..7ac25c95 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -428,110 +428,17 @@ jobs: npm publish "${{ steps.pack.outputs.tarball }}" --provenance --access public fi - - name: Checkout locked SBOM toolchain - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - ref: ${{ github.sha }} - sparse-checkout: .github/sbom-toolchain - path: .release-workflow - - - name: Install locked SBOM toolchain - run: >- - npm ci - --prefix "$GITHUB_WORKSPACE/.release-workflow/.github/sbom-toolchain" - --ignore-scripts - --omit=dev - --no-audit - --no-fund - - name: Generate SBOM shell: bash - env: - RELEASE_VERSION: ${{ needs.prepare-release.outputs.version }} - TARBALL_PATH: ${{ steps.pack.outputs.tarball }} run: | set -euo pipefail TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT - tar -xzf "$TARBALL_PATH" -C "$TMPDIR" + tar -xzf "${{ steps.pack.outputs.tarball }}" -C "$TMPDIR" cd "$TMPDIR/package" node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" - - # v0.3.3 packed dashboard metadata selected lucide-react 0.576 while the - # bundled UI requires 0.577. Normalize only the disposable SBOM tree to - # the canonical UI constraint; the immutable npm tarball is untouched. - if [[ "$RELEASE_VERSION" == "0.3.3" ]]; then - node - <<'NODE' - const fs = require("node:fs"); - const dashboardPath = "node_modules/@selftune/dashboard-core/package.json"; - const uiPath = "node_modules/@selftune/ui/package.json"; - const dashboard = JSON.parse(fs.readFileSync(dashboardPath, "utf8")); - const ui = JSON.parse(fs.readFileSync(uiPath, "utf8")); - if (dashboard.peerDependencies?.["lucide-react"] !== "^0.576.0") { - throw new Error("Unexpected v0.3.3 dashboard-core lucide-react constraint"); - } - if (ui.peerDependencies?.["lucide-react"] !== "^0.577.0") { - throw new Error("Unexpected v0.3.3 UI lucide-react constraint"); - } - dashboard.peerDependencies["lucide-react"] = "^0.577.0"; - fs.writeFileSync(dashboardPath, `${JSON.stringify(dashboard, null, 2)}\n`); - NODE - fi - - npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null - - SBOM_LOG="$TMPDIR/cyclonedx.log" - if ! node "$GITHUB_WORKSPACE/.release-workflow/.github/sbom-toolchain/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \ - --package-lock-only \ - --omit dev \ - --no-workspaces \ - --validate \ - -v -v \ - --output-file "$GITHUB_WORKSPACE/sbom.cdx.json" \ - >"$SBOM_LOG" 2>&1; then - cat "$SBOM_LOG" >&2 - exit 1 - fi - cat "$SBOM_LOG" - if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then - echo "CycloneDX skipped SBOM validation; refusing to publish." >&2 - exit 1 - fi - if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then - echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2 - exit 1 - fi - - node - "$TMPDIR/package/package.json" "$GITHUB_WORKSPACE/sbom.cdx.json" <<'NODE' - const fs = require("node:fs"); - const [, , packagePath, bomPath] = process.argv; - const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); - const bom = JSON.parse(fs.readFileSync(bomPath, "utf8")); - const rootRef = bom.metadata?.component?.["bom-ref"]; - const root = bom.dependencies?.find((entry) => entry.ref === rootRef); - if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`); - const namesByRef = new Map( - (bom.components ?? []).map((component) => [ - component["bom-ref"], - `${component.group ? `${component.group}/` : ""}${component.name}`, - ]), - ); - const actual = (root.dependsOn ?? []) - .map((ref) => { - const name = namesByRef.get(ref); - if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`); - return name; - }) - .sort(); - const expected = Object.keys(pkg.dependencies ?? {}).sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error( - `Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`, - ); - } - console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`); - NODE + npm install --package-lock-only --ignore-scripts --omit=dev >/dev/null + npm sbom --sbom-format cyclonedx --package-lock-only --workspaces=false > "$GITHUB_WORKSPACE/sbom.cdx.json" - name: Attest npm package provenance uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v2 diff --git a/.gitignore b/.gitignore index bbbd92b4..e3c8f25b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules/ .turbo/ package-lock.json -!.github/sbom-toolchain/package-lock.json pnpm-lock.yaml *.tsbuildinfo .context/ diff --git a/.oxlintrc.json b/.oxlintrc.json index d4fd90d5..9efb854e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,6 +14,7 @@ "skipComments": true } ], + "no-underscore-dangle": "off", "no-shadow": "off", "no-unmodified-loop-condition": "off", "preserve-caught-error": "off", diff --git a/AGENTS.md b/AGENTS.md index 6ceaeabf..744a36f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ selftune/ │ ├── use-once-helper/ # Separate signed ephemeral shared-skill runner; never installs SelfTune │ └── selfhost/ # One-container dashboard and Remote Library host ├── packages/ +│ ├── api-contract/ # Shared typed HTTP contracts used by optional Cloud handoffs │ ├── config/ # Persisted config schemas, path resolution, loading, and atomic writes │ ├── runtime/ # Local filesystem, SQLite/Drizzle, and host adapters │ ├── library/ # Catalog, Skill Sets, and Remote Library protocol core diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bff22839..d58d0c64 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,592 +1,70 @@ - +# SelfTune OSS architecture -# Architecture — selftune +SelfTune is a local-first desktop product for finding, organizing, packaging, +project-scoping, sharing, and maintaining Agent Skills. -selftune is a local-first feedback loop for AI agent skills. It turns saved agent activity into trustworthy local evidence, uses that evidence to improve low-risk skill behavior, and exposes the result through CLI surfaces and a local dashboard SPA. - -## Agent-First Design Principle - -selftune is a **skill consumed by AI agents**, not a CLI tool for humans. The user installs the skill (`npx skills add selftune-dev/selftune`), then interacts through their coding agent ("set up selftune", "improve my skills"). The agent reads `skill/SKILL.md` to discover commands, routes to the correct workflow doc, and executes CLI commands on the user's behalf. - -This means: - -- `skill/SKILL.md` is the primary product surface (agent reads this to know what to do) -- `skill/workflows/*.md` are the agent's step-by-step guides -- `apps/cli/` composes the agent-facing API; `cli/selftune/` only preserves old entrypoints -- Error messages and output should be machine-parseable (JSON) and guide the agent to the next action - -If you are new to the repo, read these in order: - -1. [docs/design-docs/system-overview.md](docs/design-docs/system-overview.md) -2. [PRD.md](PRD.md) -3. This file - -## Architecture At A Glance +## Runtime shape ```mermaid flowchart LR - Agent[Claude Code / Codex / OpenCode / Pi / OpenClaw] --> Sources[Transcripts / rollouts / session stores] - Agent -. hook hints .-> Hooks[Claude hooks] - + Agents[Claude Code, Codex, OpenCode, Pi, OpenClaw] --> Sources[Local agent history] Sources --> Sync[selftune sync] - Hooks --> SQLite[(SQLite — operational + product state)] - Sync --> SQLite - Sync -->|Claude / Codex / OpenCode / Pi| AnalyticsImport[Shared LocalTraceImporter] - AnalyticsImport --> DuckDB[(DuckDB — observability facts + metrics)] - AnalyticsImport --> Checkpoint[SQLite import checkpoint] - Sync --> Repaired[Repaired skill-usage overlay] - - SQLite --> Eval[Eval + grading] - Repaired --> Eval - Eval --> Orchestrate[selftune orchestrate] - Orchestrate --> Evolution[Evolve / deploy / audit] - Orchestrate --> Monitoring[Watch / rollback] - - Evolution --> SQLite - Monitoring --> SQLite - - Logs[JSONL files — recovery only] -. disaster recovery .-> Materializer[Materializer — one-time rebuild] - Materializer --> SQLite - - DuckDB --> Signals[Trace-derived signals + patterns] - Signals --> API[apps/local v2 API] - SQLite --> API - SQLite -. WAL watch .-> API - API -. SSE push .-> SPA[apps/local-dashboard] - Desktop[Electron desktop] -->|same CLI binary over authenticated loopback| API - Desktop --> SPA - API --> CLI[status / last / badge] - - CLI -->|immutable objects + snapshots| RemoteAPI[Remote Library v1] - RemoteAPI --> SelfHost[(optional one-container SQLite + object store)] - - SQLite -. alpha enrolled .-> AlphaUpload[alpha-upload pipeline] - AlphaUpload --> Queue[(upload_queue table)] - Queue --> Flush[flush + retry] - Flush --> CloudAPI[cloud API — POST /api/v1/push] - CloudAPI --> Postgres[(Neon Postgres — canonical tables)] -``` - -## Operating Rules - -- **Source-truth first.** Transcripts, rollouts, and session stores are authoritative. Hooks are low-latency hints. -- **Shared local evidence.** Product modules communicate through SQLite (sole operational store) and repaired overlays; bounded analytical workers derive trace signals from the separate DuckDB domain. Legacy JSONL files are retained on disk for disaster recovery only. -- **Autonomy with safeguards.** Low-risk description evolution can deploy automatically, but validation, watch, and rollback remain mandatory. -- **Local-first product surfaces.** `status`, `last`, and the dashboard read from local evidence, not external services. -- **Alpha data pipeline.** Opted-in users upload V2 canonical push payloads to the cloud API via `alpha-upload/`. Uploads are fail-open and never block the orchestrate loop. -- **Generic scheduling first.** `selftune cron setup` is the main automation path (auto-detects platform). `selftune schedule` is a backward-compatible alias. -- **One runtime owner.** The `selftune` binary owns daemon startup, the durable manifest, and launchd/systemd/Task Scheduler registration. Desktop is a thin supervisor and renderer host. -- **Deployment-neutral backup.** The Remote Library protocol syncs immutable skills, drafts, Skill Sets, and decision metadata to SelfTune Cloud or the one-container OSS self-host. Raw transcripts remain local. - -## Domain Map - -| Domain | Owner | Responsibility | -| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| CLI composition | `apps/cli/` | Parse commands and compose runtime, orchestration, harness, and local-host capabilities | -| Local host | `apps/local/` | Authenticated daemon, HTTP API, routes, and OS service lifecycle | -| Harness protocol | `packages/harnesses/core/` | Harness-neutral event types, source-adapter contracts, normalization, stdin dispatch, and session utilities | -| Harness registry | `packages/harnesses/registry/` | Split composition of lightweight descriptors and explicitly loaded durable source adapters | -| Harness integrations | `packages/harnesses/{claude-code,codex,opencode,cline,pi,openclaw}/` | Platform hooks, installers, and transcript/session ingestion | -| Configuration | `packages/config/` | Persisted config schemas, environment-derived paths, loading, and atomic writes | -| Runtime | `packages/runtime/` | Remaining local adapters, evaluation, evolution, monitoring, and reusable capabilities | -| Library | `packages/library/` | Catalog read models, Skill Sets, and Remote Library config, transport, sharing, sync, and backup core | -| Local Store | `packages/local-store/` | Effect-managed SQLite lifecycle, Drizzle schema, migrations, and database path ownership | -| Observability | `packages/observability/` | Harness-neutral Effect trace contract, DuckDB analytics, bounded writes, and replay-safe signal queries | -| Skill Intelligence | `packages/skill-intelligence/` | Pure classification, non-causal patterns, temporal validation, calibration, and outcomes | -| Source Management | `packages/source-management/` | Source identity, update and merge contracts, and the Effect source-sync service | -| Orchestration | `packages/orchestration/` | Setup convergence, account linking, desktop onboarding, source sync, repair, improve, export, and scheduling | -| Control plane | `packages/control-plane/` | Effect services, typed failures, domain programs, and live/test Layers | -| Dashboard client | `packages/dashboard-core/`, `packages/ui/`, `apps/local-dashboard/` | Host-neutral dashboard behavior and the local React client | -| Desktop | `apps/desktop/` | Scoped Effect supervisor plus native window, tray, updater, and IPC adapters around the compiled CLI | -| Self-host | `apps/selfhost/` | One-container dashboard and tenant-scoped Remote Library | -| Compatibility | `bin/selftune.cjs`, `cli/selftune/` | Preserve the npm binary and existing hook file paths; no new implementation belongs here | -| Agent product surface | `skill/` | Agent-facing routing, workflows, settings, and references | - -## Dependency Direction - -The workspace follows the same composition rule as Executor: applications assemble capabilities; -packages do not reach back into applications. - -```mermaid -flowchart TD - CLI[apps/cli] --> Local[apps/local] - CLI --> Orchestration[packages/orchestration] - CLI --> HarnessRegistry[packages/harnesses/registry] - CLI --> Runtime[packages/runtime] - CLI --> Intelligence[packages/skill-intelligence] - - Local --> Runtime - Local --> ControlPlane[packages/control-plane] - Orchestration --> Runtime - Local --> HarnessRegistry - Orchestration --> HarnessRegistry - - HarnessRegistry --> Harnesses[packages/harnesses integrations] - Harnesses --> HarnessCore[packages/harnesses/core] - Harnesses --> Runtime - Runtime --> Config - Runtime --> ControlPlane - Runtime --> Intelligence - Runtime --> Library[packages/library] - Runtime --> LocalStore[packages/local-store] - Harnesses --> Observability[packages/observability] - Observability --> Config - Runtime --> SourceManagement[packages/source-management] - Runtime --> Telemetry[packages/telemetry-contract] - LocalStore --> Config - - Desktop[apps/desktop] --> Local - Desktop --> Runtime - SelfHost[apps/selfhost] --> Local - SelfHost --> Runtime -``` - -Mechanical rules enforced by `lint-architecture.ts`: - -- `packages/runtime` cannot import harness, orchestration, or local-host packages. -- `packages/local-store` cannot import runtime, harness, orchestration, or application packages. -- `packages/observability` may import config, Effect, its private DuckDB adapter, and Node standard-library modules; it cannot import local-store, runtime, harness, orchestration, or application packages. -- `packages/library` cannot import runtime, harness, orchestration, or application packages. -- `packages/skill-intelligence` is deterministic and cannot import runtime, harness, orchestration, or application packages. -- `packages/source-management` cannot import runtime, harness, orchestration, or application packages. -- Harness packages cannot import orchestration or local-host packages; harness core cannot import runtime. -- `packages/harnesses/registry` may import harness integrations and harness core only. -- `packages/orchestration` may compose runtime and harness packages, but cannot import an application. -- Applications consume behavior through `@selftune/*` package exports. -- `cli/selftune` contains compatibility shims only. - -Configuration is a low-level dependency: `packages/config` owns persisted schemas, environment-derived -path policy, loading, and atomic writes, and does not depend on runtime, local-store, harness, -orchestration, or application packages. Runtime and local-store retain compatibility re-exports but -do not redefine configuration policy. - -Source sync is an Effect service contract in `packages/source-management/src/sync.ts`. Orchestration's -generic sync program accepts an injected source registry and does not import platform implementations. -Only its live-source composition consumes the local-only `@selftune/harness-registry/source` -entrypoint. Each integration owns its source scanner, checkpoints, and operational write path; -adapters return typed Effects, and orchestration executes enabled sources sequentially before repair -and downstream signal staging. The live composition scopes one DuckDB analytical-store instance to -the complete sync run instead of opening one store per source file. Claude Code, Codex, OpenCode, -and Pi each own a metadata-only source projector, then cross the same `LocalTraceImporter` service -after their canonical SQLite write succeeds. OpenClaw remains canonical-only and Cline remains -hook-only. Evolution and monitoring receive -the capability from the application composition edge rather than importing platform code. Promise -conversion happens only at CLI and Desktop boundaries. Setup is owned by orchestration as an explicit -capability-injected convergence program; the CLI and desktop onboarding adapters select capabilities -without reversing the package dependency direction. - -### Harness package contributions - -Each supported harness package owns separate local-runtime and client-safe contributions: - -- `src/descriptor.ts` exports a server-only runtime contribution with optional connection detection - and source-merge invocation mapping plus client-safe presentation metadata; -- for harnesses with a durable session source, `src/source-sync.ts` exports an adapter that owns - scanning, checkpointing, and operational ingestion. Cline is hook-only and intentionally has no - source adapter. - -`packages/harnesses/registry` is the single package-level composition boundary for all shipped -harnesses. Its main entrypoint composes only lightweight descriptors for Settings and setup; its -explicit `/source` entrypoint loads the five durable source adapters for orchestration. This split keeps -parsers and ingestion dependencies out of desktop startup paths. The descriptor registry rejects -duplicate or mismatched identities, and the source registry rejects duplicate or invalid adapters. The -schema-validated client projection never serializes runtime functions, credentials, environment -values, or detected local paths. Add a harness to the appropriate registry entrypoints; dashboard and -orchestration code must not add their own harness-name switches. - -### Local Host Composition - -`apps/local/src/dashboard-server.ts` is the local process composition root. It binds the socket, -constructs long-lived resources, orders transport handlers, and disposes those resources on -shutdown. It does not implement dashboard workflows or own route payload parsing. - -```mermaid -flowchart LR - Server[dashboard-server composition root] --> Auth[dashboard-auth] - Server --> Events[dashboard-events] - Server --> Spa[dashboard-spa] - Server --> Core[routes/core] - Server --> AppRoutes[routes/application] - Server --> Runtime[ManagedRuntime] - Runtime --> Operations[DashboardOperations Effect service] - Operations --> LocalRuntime[packages/runtime capabilities] - AppRoutes --> Operations -``` - -- `DashboardOperations` is the typed application capability boundary. Its live `Layer` acquires the - control-plane runtime, maps expected CLI and source-update failures into - `DashboardOperationError`, redacts unexpected causes, and releases the runtime when the managed - scope closes. -- `routes/application.ts` validates request bodies with Effect Schema and translates HTTP requests - into service effects. It contains no live filesystem, credential, or synchronization wiring. -- `routes/core.ts` owns the established read, report, badge, and CLI-action endpoints plus their - SQLite and status-cache dependencies. -- `dashboard-auth.ts`, `dashboard-events.ts`, and `dashboard-spa.ts` own authentication state, live - event resources, and SPA transport respectively. Their state is private to each server instance. - -Tests can replace individual application capabilities through the same Layer construction path used -by the live server. HTTP integration tests remain responsible for route order, CORS, authentication, -SSE, assets, and process shutdown. - -## Two Operating Modes - -selftune has two distinct operating modes with different execution models: - -### Interactive Mode (agent-driven) - -The user talks to their coding agent. The agent reads `skill/SKILL.md`, routes -to the correct workflow, and runs CLI commands. The agent is the operator. - -``` -User: "improve my skills" - → Agent reads SKILL.md → routes to Orchestrate workflow - → Agent runs: selftune orchestrate - → Agent summarizes results to user -``` - -### Automated Mode (OS-driven) - -System scheduling (cron/launchd/systemd) calls the CLI binary directly. -No agent session needed, no token cost. Set up via `selftune cron setup`. - -``` -OS scheduler fires every 6 hours - → selftune orchestrate --max-skills 3 - → sync → status → auto-grade ungraded → candidate selection → evolve → watch → write results to SQLite - → Next interactive session sees improved SKILL.md -``` - -The agent is NOT in the loop for automated runs. This is intentional: -automated runs are routine maintenance (sync, low-risk evolutions) that -don't need agent intelligence or user interaction. - -For desktop persistence, `selftune service install` registers the same CLI -binary as a user-owned launchd, systemd, or Task Scheduler service. The -Electron process can exit without terminating observation. It calls the CLI -for status and repair instead of owning platform service files itself. - -Inside Electron, `DesktopRuntime` is a scoped Effect service and the only owner -of active and pending sidecar connections, supervision transitions, connection -generations, background-service state, health monitoring, recovery, reset, and -shutdown. One semaphore queues every explicit ownership mutation, while health -and child-exit signals are bound to the connection generation that produced -them. A stale probe therefore cannot recover a replacement runtime, and a -restart, background toggle, reset, or update preparation cannot be reported as -complete without running. - -Electron-specific resources remain adapters around that service: -`desktop-window.ts` stages authenticated `BrowserWindow` replacement before a -connection is committed, `desktop-shell.ts` owns tray and updater controllers, -and `desktop-ipc.ts` owns removable, schema-validated IPC handlers. The main -entrypoint only composes these owners and disposes the managed Effect runtime -before allowing Electron to quit. - -## Data Architecture - -`packages/config` owns `config.json`, including desktop onboarding preferences and Alpha credential -references. Legacy `onboarding.json` is not completion state: on settings load or setup convergence, -its import-source and feature preferences are folded into `config.json.preferences` only when that -section is absent, then the legacy file is removed. Onboarding completion is derived from the -presence of preferences in valid config. Cloud API keys are stored by runtime credential adapters; -`config.json` contains only a provider/account reference (legacy inline keys migrate on first use). - -SQLite is the sole operational/product write target. Hooks and sync write -product records directly to SQLite via `localdb/direct-write.ts`. JSONL writes -have been removed (Phase 3 complete). Existing SelfTune JSONL files are retained -on disk but only cover pre-cutover history. Post-cutover operational recovery -requires `selftune export` snapshots or SQLite backups. The `skill_usage` table -still exists in the schema alongside `skill_invocations` for backward -compatibility; new consumers should use `skill_invocations` via -`localdb/queries.ts`. - -DuckDB is a separate, rebuildable observability-analytics domain. Source-native -transcripts, Codex rollouts, and platform session stores remain durable import -sources. Claude Code, Codex, OpenCode, and Pi each project bounded, -metadata-only source facts into one harness-neutral `LocalTraceImporter`. The -normal sync path first refreshes canonical SQLite records, then awaits the -DuckDB batch of spans, five scalar metrics, and explicit skill links. A durable -source marker advances only after both writes succeed. DuckDB receipts and the -SQLite analytical checkpoint make retries safe if a crash occurs between -acknowledgements. One scoped DuckDB connection serves the complete sync run, -not each source file. Hooks do not open DuckDB, the two databases never share a -transaction, and dry runs open neither write path. OpenClaw remains -canonical-only; Cline remains hook-only. Issue #172 governs the Product -Repository/cloud contract cutover and does not gate this internal local trace -pipeline. - -```text -Operational Store: SQLite (~/.selftune/selftune.db) -├── Hooks write directly via localdb/direct-write.ts (sole product write path) -├── Sync writes directly via localdb/direct-write.ts -├── Import checkpoints and pattern/proposal lifecycle remain operational state -├── Product reads (orchestrate, evolve, grade, status) query SQLite -└── Target freshness model: WAL-mode watch powers SSE live updates - -Observability Analytics: DuckDB (~/.selftune/observability.duckdb) -├── Scoped source-sync importer writes bounded source-native trace batches -├── Span completion derives duration/token/error/tool metrics -├── Bounded report worker queries correlated skill signals -├── Dashboard joins those signals into the product read model -└── The corpus is rebuildable from durable platform sources - -Legacy JSONL files (~/.claude/*.jsonl) — pre-cutover history only, no longer written -├── session_telemetry_log.jsonl Session telemetry records -├── skill_usage_log.jsonl Skill trigger/miss records (deprecated; consolidated into skill_invocations SQLite table) -├── all_queries_log.jsonl User prompt log -├── evolution_audit_log.jsonl Evolution decisions + evidence -├── orchestrate_runs.jsonl Orchestrate run reports -└── canonical_telemetry_log.jsonl Normalized cross-platform records - -Core Loop: reads SQLite -├── orchestrate.ts → db.query("SELECT ... FROM sessions ...") -├── evolve.ts → db.query("SELECT ... FROM evolution_audit ...") -├── grade.ts → db.query("SELECT ... FROM sessions ...") -└── status.ts → db.query("SELECT ... FROM sessions, skill_usage, queries ...") - -Rebuild Paths: -├── materialize.ts — runs once on startup for historical JSONL backfill -└── selftune export — generates JSONL from SQLite on demand - -Alpha Upload Path (opted-in users only): -├── stage-canonical.ts — reads canonical records from SQLite + evolution evidence + orchestrate_runs into canonical_upload_staging table -├── build-payloads.ts — reads staging table via single monotonic cursor, produces V2 canonical push payloads -├── flush.ts — POSTs to cloud API (POST /api/v1/push) with Bearer auth, handles 409/401/403 -└── Cloud storage: Neon Postgres (raw_pushes for lossless ingest → canonical tables for analysis) + Sync --> SQLite[(SQLite product state)] + Sync --> DuckDB[(DuckDB local analytics)] + SQLite --> Service[Authenticated loopback service] + DuckDB --> Service + Service --> Desktop[Desktop and local dashboard] + Desktop --> Projects[Project-scoped managed links] + Desktop --> SelfHost[Optional self-hosted backup] + Desktop -->|inventory, account, explicit shares only| Cloud[Optional SelfTune Cloud] ``` -Hooks and sync write exclusively to SQLite. JSONL writes have been removed -(Phase 3 complete). All local product reads go through SQLite. The materializer -runs once on startup to backfill any historical JSONL data not yet in the -database. `selftune export` can regenerate JSONL from SQLite when needed for -portability or debugging. - -The dashboard uses WAL-based invalidation for SSE live updates — JSONL file -watchers have been removed from the dashboard server. - -## Repository Shape - -```text -apps/ -├── cli/src/main.ts CLI bootstrap and composition root -├── cli/src/commands/ Lifecycle, operations, and harness command routers -├── cli/src/effect-cli/commands/ Typed command families and lazy live adapters -├── local/src/ Daemon/service host, Effect operations, transport resources, routes -├── local-dashboard/src/ React dashboard -├── desktop/ Electron distribution host -└── selfhost/ Container distribution host - -packages/ -├── config/ Persisted config schemas, preferences, path policy, loading, and atomic writes -├── runtime/ Local adapters and reusable product capabilities -│ ├── auth/cloud-credential.ts Cloud credential resolution and legacy inline-key migration -│ ├── command-surface/ CLI help and public command metadata by lifecycle area -│ ├── dashboard-action-result/ Defensive CLI decoding and dashboard result projections -│ ├── dashboard-contract/ Shared request DTOs, pagination, and upstream type exports -│ ├── evolution/evolve/ Evolution contracts, validation, orchestration, and CLI adapter -│ ├── evolution/evidence-cohort-body-adapter.ts Exact-revision, review-only adapter from contrastive trace evidence into body evolution -│ ├── evolution/cloud-evaluation-target-client.ts Linked-credential, case-free Cloud target discovery for an exact local revision -│ ├── evolution/validate-host-replay/ Replay staging, host output parsing, and execution -│ ├── library/ Catalog discovery and reconciliation adapter -│ ├── remote-library/ Config, collection, pull, restore, and sync adapters -│ ├── skill-portfolio/ Installed-skill audit, consolidation, quarantine, and CLI adapters -│ ├── source-management/ Metadata, package-tree, update, and merge-decision adapters -│ └── types/ Contribution, composability, workflow, and search contracts -├── library/ Skills, Skill Sets, and Remote Library protocol core -├── local-store/ SQLite/Drizzle lifecycle, schema, and migrations -├── observability/src/ Effect-owned DuckDB trace analytics, bounded Evidence Cohorts, and replay-safe per-skill signal read models -├── skill-intelligence/src/ Deterministic skill science, execution patterns, and domain contracts -├── source-management/src/ Source identity, update contracts, and sync service -├── orchestration/src/ Cross-capability workflows, setup adapters, and Effect live composition -│ ├── setup/ Capability-injected convergence, inspection, planning, and account linking -│ └── desktop-onboarding.ts Desktop request adapter and setup capability wiring -├── harnesses/ -│ ├── core/src/ Harness-neutral protocol and source-adapter contract -│ ├── registry/src/ Split descriptor and source-adapter composition entrypoints -│ ├── claude-code/src/ Hooks and replay ingestion -│ ├── codex/src/ Hooks, installer, wrapper, and rollout ingestion -│ ├── opencode/src/ Hooks, installer, and ingestion -│ ├── cline/src/ Hooks and installer -│ ├── pi/src/ Hooks, installer, and ingestion -│ └── openclaw/src/ Cron adapter and ingestion -├── control-plane/ Effect domain services and Layers -├── dashboard-core/ Shared dashboard application shell -├── telemetry-contract/ Canonical telemetry schemas and types -└── ui/ Shared UI primitives - -cli/selftune/ Compatibility shims only -skill/ Agent-facing product surface -``` - -The root package publishes the compatibility facade and bundles its private workspace packages. -`apps/cli` is the executable source; `apps/local` is the long-lived process host. - -## Module Definitions - -| Module | Owner | May Import | -| ------------------- | ------------------------------- | --------------------------------------------------------------------------------------- | -| Configuration | `packages/config` | Effect and Node standard library | -| Runtime | `packages/runtime` | config, local store, control plane, intelligence, telemetry, platform libraries | -| Library | `packages/library` | control plane, local store, telemetry contract, Effect, and Node APIs | -| Local Store | `packages/local-store` | config, Bun SQLite, Drizzle, Effect, and Node standard library | -| Observability | `packages/observability` | config, Effect, private DuckDB adapter, and Node standard library | -| Skill Intelligence | `packages/skill-intelligence` | Node standard library only | -| Source Management | `packages/source-management` | control plane, Effect, and Node standard library | -| Harness core | `packages/harnesses/core` | platform libraries only | -| Harness integration | `packages/harnesses/` | harness core, runtime, and shared Claude hook behavior where required | -| Harness registry | `packages/harnesses/registry` | harness integrations and harness core | -| Orchestration | `packages/orchestration` | setup convergence, desktop onboarding, account linking, and runtime/harness composition | -| Local host | `apps/local` | runtime, Effect, platform libraries | -| CLI | `apps/cli` | runtime, orchestration, harness integrations, local host | -| Desktop/self-host | `apps/desktop`, `apps/selfhost` | exported package capabilities and dashboard assets | -| Compatibility | `cli/selftune`, `bin` | stable package exports only | - -## Truth Model: Hooks vs. Source Systems - -```mermaid -flowchart LR - Hooks[Hook events] --> Hints[Low-latency hints] - Stores[Transcripts / rollouts / session stores] --> Sync[selftune sync] - Sync --> Truth[Trustworthy local evidence] - Hints -. enrich .-> Truth -``` - -Why this matters: - -- Hooks can be missing, polluted, or agent-specific. -- Source sync is how selftune stays cross-agent and backfillable. -- Autonomous changes should be justified from the synced evidence path, not from hooks alone. - -## Autonomous Loop - -```mermaid -sequenceDiagram - participant User - participant Orchestrate - participant Sync - participant Status - participant Evolution - participant Monitoring - - User->>Orchestrate: selftune orchestrate - Orchestrate->>Sync: rebuild source-truth telemetry - Sync-->>Orchestrate: shared logs + repaired overlay - Orchestrate->>Status: compute current skill health - Status-->>Orchestrate: candidates + reasons - Orchestrate->>Evolution: evolve selected low-risk descriptions - Evolution-->>Orchestrate: deployed proposals + audit entries - Orchestrate->>Monitoring: watch recent deployments - Monitoring-->>Orchestrate: stable or rollback result - Orchestrate-->>User: decision report -``` - -Current policy: - -- Low-risk description evolution is autonomous by default. -- `--review-required` is an opt-in stricter policy mode. -- Validation, watch, and rollback are the main safety system. - -## Signal-Reactive Improvement - -In addition to scheduled and interactive orchestration, selftune detects -high-priority improvement signals in real-time and triggers focused -orchestration automatically. - -```mermaid -sequenceDiagram - participant User - participant PromptLog as prompt-log hook - participant SignalLog as improvement_signals (SQLite) - participant SessionStop as session-stop hook - participant Orchestrate - - User->>PromptLog: "why didn't you use the commit skill?" - PromptLog->>SignalLog: append signal (correction, skill=commit) - Note over PromptLog: continues normal prompt logging - User->>SessionStop: session ends - SessionStop->>SignalLog: read pending signals - SessionStop->>Orchestrate: spawn background (--max-skills 2) - Note over SessionStop: exits immediately (fire-and-forget) - Orchestrate->>SignalLog: read signals, boost signaled skills - Orchestrate->>Orchestrate: evolve with signal-aware priority - Orchestrate->>SignalLog: mark signals consumed -``` - -Signal detection is pure regex in the prompt-log hook — no LLM calls, no -network. Patterns include corrections ("why didn't you use X?", "you -should have used X"), explicit requests ("please use the X skill"), and -manual invocations. Skill names are matched against the installed skill -directory listing. - -The orchestrator boosts signaled skills by +150 priority per signal -(capped at +450) and relaxes the minimum evidence gate and UNGRADED gate -for skills with pending signals. After a run completes, signals are -marked consumed so they don't affect subsequent runs. - -## Config System - -`packages/config` is the canonical owner of `~/.selftune/config.json`; every writer uses its atomic -write API. `selftune init`, desktop onboarding, and account linking update it through orchestration. - -| Field | Type | Description | -| ------------------ | --------------------------------------------------------- | ---------------------------------------------- | -| `agent_type` | `claude_code \| codex \| opencode \| openclaw \| unknown` | Detected host agent | -| `cli_path` | `string` | Absolute path to the selftune CLI entry point | -| `llm_mode` | `agent \| api` | How grading/evolution run model calls | -| `agent_cli` | `string \| null` | Preferred agent binary | -| `hooks_installed` | `boolean` | Whether Claude hooks are configured | -| `initialized_at` | `string` | ISO timestamp of the last bootstrap | -| `preferences` | `object \| undefined` | Desktop import-source and feature preferences | -| `alpha.credential` | `object \| undefined` | OS credential-store provider/account reference | - -Installed harness hooks are inspected from their real platform state and are not persisted as -preferences. The legacy `alpha.api_key` field remains decode-compatible only; runtime migrates it -to the credential store and removes it from config on first resolution. - -## Shared Local Artifacts - -| Artifact | Writer | Reader | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `~/.claude/session_telemetry_log.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery, export | -| `~/.claude/skill_usage_log.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery (deprecated — consolidated into `skill_invocations` table in SQLite) | -| `~/.claude/skill_usage_repaired.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery (deprecated — consolidated into `skill_invocations` table in SQLite) | -| `~/.claude/all_queries_log.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery, export | -| `~/.claude/evolution_audit_log.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery, export | -| `~/.claude/orchestrate_runs.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery, export | -| `~/.claude/improvement_signals.jsonl` | Legacy / export-only (`selftune export`) | Materializer recovery, export | -| `~/.selftune/skill-edit-captures.jsonl` | Write/Edit pre/post hook capture | Read-only correction-signal discovery; hash-only source evidence, never skill contents or paths | -| `~/.claude/.orchestrate.lock` | Orchestrator | session-stop hook (staleness check) | -| `~/.selftune/selftune.db` | `packages/local-store` owns Drizzle schema/migrations; hooks, sync, materializer, and analytical-import checkpoints write operational/product state | Orchestrate, evolve, grade, status, dashboard, and the analytical importer | -| `~/.selftune/observability.duckdb` | Bounded importer process through `packages/observability` | Bounded report worker through `packages/observability`; dashboard consumes its signal projection | - -## The Evaluation Model - -| Tier | What It Checks | Automated | -| ---------------- | --------------------------------------------- | ------------------------ | -| Tier 1 — Trigger | Did the skill fire when it should have? | Yes | -| Tier 2 — Process | Did the session follow the expected workflow? | Yes | -| Tier 3 — Quality | Was the resulting work actually good enough? | Yes, via agent-as-grader | - -## Invocation Taxonomy - -| Type | Description | -| ---------- | ------------------------------------------------- | -| Explicit | The user names the skill directly | -| Implicit | The task matches the skill without naming it | -| Contextual | The task is implicit with real-world domain noise | -| Negative | Nearby queries that should not trigger the skill | - -## Current Known Tensions - -- Candidate selection is improving, but still needs stronger real-world evidence gating. -- Local and cloud dashboard semantics should converge on the same payload contracts. -- Drizzle now owns the local schema and migration chain; handwritten query modules remain a deliberate compatibility boundary while they move behind typed repositories. -- OpenClaw cron remains supported, but it is no longer the primary automation story. - -## Related Docs - -- [docs/design-docs/system-overview.md](docs/design-docs/system-overview.md) -- [docs/integration-guide.md](docs/integration-guide.md) -- [docs/design-docs/evolution-pipeline.md](docs/design-docs/evolution-pipeline.md) -- [docs/design-docs/monitoring-pipeline.md](docs/design-docs/monitoring-pipeline.md) -- [docs/design-docs/live-dashboard-sse.md](docs/design-docs/live-dashboard-sse.md) -- [docs/design-docs/sqlite-first-migration.md](docs/design-docs/sqlite-first-migration.md) +The local machine owns skill packages, transcripts, evaluation evidence, drafts, +and improvement execution. SelfTune Cloud is not a remote execution environment +or a raw telemetry warehouse. When explicitly connected, it stores account and +subscription state, device records, a privacy-safe inventory manifest, security +audit entries, and packages the user deliberately shares. + +## Ownership + +| Area | Owner | +| ----------------------------------- | ------------------------------------------------------------- | +| CLI composition | `apps/cli` | +| Local service | `apps/local` | +| Desktop host | `apps/desktop` | +| Local dashboard | `apps/local-dashboard` | +| Product routes and screens | `packages/app-core`, `packages/dashboard-core`, `packages/ui` | +| Skill library and packaging | `packages/library`, `packages/control-plane` | +| Local state | `packages/local-store`, `packages/observability` | +| Agent integrations | `packages/harnesses/*` | +| Optional self-hosted backup | `apps/selfhost` | +| Agent-facing operating instructions | `skill` | + +## Rules + +- Skill bytes and agent history remain local unless the user explicitly shares + a package or configures their own self-hosted backup. +- Project scoping uses managed filesystem links so an agent sees the right + skills only where they are needed. +- SQLite is operational product state; DuckDB is bounded local analytics. +- JSONL exists for compatibility and recovery, not as a second source of truth. +- Apps compose capabilities; packages never import app implementations. +- Destructive library operations require preview, explicit approval, and a + recoverable receipt. +- The public repository has no dependency on Neon, Fly.io, or a hosted + improvement sandbox. + +## Optional hosted-state seam + +The product defines one customer-visible hosted-state seam. Its portable +contracts currently cover Desktop account state, privacy-safe manifests, and +consented contributor signals. Explicit sharing uses the Remote Library +protocol. The managed Convex adapter additionally provides device linking, +update notices, and account administration; the OSS SQLite adapter provides +configured users and roles, manifests, Remote Library sharing, contributor +aggregates, and an audit log inside one customer trust boundary. + +Managed billing, public signup, unrelated-customer SaaS isolation, abuse +operations, and fleet administration are not part of the portable seam. Skill +contents and operational history remain local unless the user explicitly shares +a package or consents to a bounded contributor signal. diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d529fd..f58ac39a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Fail-closed use-once helper** — a separately built and signed ephemeral helper now uses the pinned `https://cloud.selftune.dev` authority for token-bound preview, exact sealed-object delivery, bounded package validation, interactive rights disclosure, one-winner consume, fixed agent invocation, and owned temporary-workspace cleanup; redirects, cookies, caller origins, persistent installs, and trusted telemetry remain unavailable. +- **Optional Desktop-to-Cloud linking** — connect or create a SelfTune Cloud account from Sync & Backup or the state-aware sidebar Cloud profile through short-lived browser approval, return the native window to the foreground for account-backed backup, and open the hosted dashboard only through a separately labeled external action—without token copy/paste or an app-wide account gate. - **Native desktop lifecycle** — installed macOS builds can run the authenticated local sidecar as a crash-restarting, login-started LaunchAgent independent of the Electron window, and signed desktop releases now download GitHub Release updates in the background with explicit restart controls. - **Project Skill Sets** — cache immutable package revisions in a local Library, preview and apply conflict-free Codex or Claude Code project links, and roll back only receipt-owned paths through the CLI or native Projects screen. - **Real-time improvement signal detection** — `prompt-log` hook detects user corrections ("why didn't you use X?") and explicit skill requests via pure regex patterns. Signals are logged to `~/.claude/improvement_signals.jsonl` with skill name extraction from installed skills. @@ -19,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- **Cross-project skill visibility and reliable source sync** — the native Library discovers project-scoped installs across known workspaces, current Codex custom tool calls and user-attached skill paths count as usage, append-aware file fingerprints revisit resumed rollout logs without reprocessing unchanged history, and replayed sessions atomically replace their own prior canonical snapshot instead of inflating usage counts. +- **Verified macOS automation** — recurring desktop jobs use `launchctl bootout`, `enable`, and `bootstrap`, then require loaded-state proof instead of trusting the deprecated `load` command's misleading exit status. +- **Context-backed workflow sets** — Projects separates exploratory portfolio workflows from observed co-usage suggestions and can recommend broader Cloudflare Full-Stack and Mobile Engineering sets with reusable Matt Pocock skills and exact installed or catalog-backed identities. +- **Explainable overlapping Skill Sets** — larger recommendations are validated by recurrence of their discovery relationships in unseen newer sessions instead of requiring every member to recur at once, while the dashboard shows each reusable skill's source, contextual role, membership strength, and held-out relationship coverage. +- **Evidence-backed Skill Set suggestions** — suggestion mining now reads local source identity, stops presenting same-source pairs as finished sets, combines statistically supported co-usage edges into overlapping multi-skill communities, lets reusable core skills appear in multiple sets, and requires larger sets to meet the held-out quality floor. - **Scoped desktop supervision** — Electron now composes an Effect-owned runtime with queued lifecycle transitions, connection-generation-safe health recovery, staged authenticated window rebinding, schema-decoded tray responses, removable IPC handlers, deterministic shutdown that preserves CLI and OS-service ownership, and a packaged JavaScript boundary for internal workspace modules. - **Scoped local dashboard runtime** — dashboard workflows now run through a typed Effect service and managed Layer; auth, SSE resources, SPA transport, request validation, and SQLite read routes have independent owners while the Bun server remains the process composition root. - **Lean npm distribution** — release packaging now flattens shared external runtimes into the root manifest and normalizes bundled workspace links, preventing npm from recursively embedding duplicate Effect and Zod development trees in the SelfTune tarball. diff --git a/README.md b/README.md index ad19b652..22905520 100644 --- a/README.md +++ b/README.md @@ -132,53 +132,32 @@ SelfTune Cloud. Optional account tokens enable recipient-scoped private sharing between organizations. See [SelfTune Self-Host](apps/selfhost/README.md) for TLS, account, backup, and restore instructions. -## Local vs Cloud - -`selftune` has two customer-facing surfaces: - -- **The OSS local CLI + desktop/dashboard** for using and improving the skills - you run yourself -- **Selftune Cloud** for skills you need to review, publish, distribute, and - improve from downstream usage - -Start local by default. Add cloud when either of these becomes true: - -- you need a hosted review/apply workflow with evidence other people can inspect -- you publish skills and want registry/distribution plus contributor signals - from opted-in selftune-connected installs - -| Difference | Local CLI + local dashboard | Selftune Cloud | -| -------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| **Primary job** | Improve the skills you personally use | Operate the creator/team loop for skills that need hosted review, distribution, or downstream signal | -| **What it runs against** | Your local `SKILL.md`, local SQLite, local logs, and local eval artifacts | Immutable cloud snapshots plus cloud drafts | -| **How it tests skills** | Can validate from local eval sets, runtime replay, no-skill baseline, and bounded package search | Runs against an explicit cloud eval suite attached to a snapshot | -| **How it improves skills** | Can evolve description, routing, body, or package variants from local evidence | Evaluates `current_skill` vs `candidate_skill`, persists artifacts, and turns the winner into a proposal | -| **Distribution** | Local Skill Sets plus optional self-hosted backup and private sharing | Cloud registry/distribution and shared skill inventory | -| **Contributor signals** | Your own local telemetry only | Opted-in contributor aggregates from selftune-connected installs, kept separate from your local trust metrics | -| **What gets written** | Can write straight back to your local `SKILL.md` | Creates a new promoted cloud snapshot and advances the draft pointer on apply | -| **Apply model** | Direct local deploy unless you choose dry-run/review mode | Explicit apply step from the hosted proposal/run workflow | -| **Evidence shape** | Local diffs, local audit trail, local replay/baseline results | Hosted candidate archives, diffs, eval artifacts, run summaries, and proposal history | -| **Best for** | Personal iteration, debugging, replay, and daily operator loops | Skill creators, teams, and consultants who need hosted QA or post-distribution learning | -| **When to start here** | Start here first | Add this when the skill matters beyond your own working copy | - -Quick rule of thumb: - -- Start with the **local CLI + local dashboard**. -- Use **Selftune Cloud** when you need hosted review/apply, registry, or - contributor signals from selftune-connected installs after distribution. -- Think of the OSS path as the free daily operator surface, and cloud as the - hosted creator/team surface once the skill needs evidence, reuse, and - feedback beyond your own machine. - -Bridge commands: - -- There is no separate "cloud CLI" install. -- The same `selftune` binary includes cloud-linked commands such as - `selftune alpha upload`, `selftune creator-contributions ...`, - `selftune contributions upload`, and `selftune registry ...` when you need to - connect your local environment to Selftune Cloud. - -Today, contributor signals come from opted-in selftune-connected installs. +## Local, self-hosted, and managed + +SelfTune is one local-first product with two optional collaboration hosts. The +actual skills, sessions, evaluations, and improvements stay on your computer in +all three modes. + +| Capability | Local Desktop | Customer self-host | Managed SelfTune Cloud | +| ------------------------------------------------------------- | ------------------------- | ------------------------------- | ------------------------------ | +| Discover, organize, scope, evaluate, and improve skills | Yes | Yes | Yes, through connected Desktop | +| Users, roles, manifests, sharing, and audit | Local-only | Yes, inside your trust boundary | Yes | +| Browser device linking and managed update notices | No | Not yet | Yes | +| Consent-based contributor signals | Build and preview locally | Relay and aggregate | Relay and aggregate | +| Raw prompts, sessions, paths, and evaluations uploaded | Never by default | No | No | +| Billing, trials, and invoices | No | No | Yes | +| Cross-customer SaaS operations | No | No | Yes | + +Start with Desktop. Add the OSS self-host when your organization wants to own +its collaboration data and infrastructure. Add managed Cloud when you want the +same narrow collaboration journeys operated for you. Self-hosting does not need +Stripe or PragSys's multi-customer control plane. + +There is no separate cloud CLI. The same `selftune` binary can connect to either +host for Remote Library sharing and privacy-safe manifests. Contributor relay +follows the configured Remote Library host and credential, so selecting a +self-host also keeps those aggregates inside that trust boundary. Evaluation, +proposal generation, validation, and applying changes remain local. If you're contributing to the local dashboard runtime or HMR flow, see [CONTRIBUTING.md](CONTRIBUTING.md). @@ -295,81 +274,86 @@ selftune is empirical. It observes real sessions, grades execution quality, dete Your agent runs these — you just say what you want ("improve my skills", "show the dashboard"). -| Group | Command | What it does | -| ----------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------- | -| | `selftune status` | Get a one-line health summary plus compact attention / improving highlights | -| | `selftune last` | Quick insight from the most recent session | -| | `selftune verify --skill-path ` | Check draft-package readiness, then emit benchmark-style verification evidence | -| | `selftune publish --skill-path ` | Publish a verified draft package and start watch by default | -| | `selftune search-run --skill-path ` | Run bounded package search over routing/body variants against the measured frontier | -| | `selftune improve --skill ` | Route to the smallest matching evolution surface | -| | `selftune run` | Run the full autonomous loop through the simplified lifecycle alias | -| | `selftune orchestrate` | Advanced alias for `run` | -| | `selftune sync` | Replay source-truth transcripts/rollouts into SQLite and refresh repair state | -| | `selftune dashboard` | Open the visual skill health dashboard | -| | `selftune service install` | Keep the authenticated dashboard runtime alive under the native OS supervisor | -| | `selftune service status --json` | Inspect registration, process state, version, and durable daemon health | -| | `selftune service doctor --json` | Diagnose the fixed current-user Windows service-lock compatibility state | -| | `selftune service repair-lock --json` | Repair only a proven stale pre-SQLite Windows service lock | -| | `selftune doctor` | Health check: logs, hooks, config, permissions | -| **skills** | `selftune skills audit` | Inventory installed skills and recommend keep, repair, consolidate, or quarantine review | -| | `selftune skills consolidate --skill ` | Preview or apply reversible archive-and-link cleanup for duplicate installations | -| | `selftune skills consolidate --all-safe` | Preview or apply all source-confirmed consolidation recommendations | -| | `selftune skills consolidation-rollback --id ` | Restore every archived copy and remove receipt-owned project links | -| | `selftune skills quarantine --skill ` | Reversibly remove an approved skill package from active discovery | -| | `selftune skills restore --id ` | Restore a quarantined package to its exact previous registry path | -| **library** | `selftune library` | Reconcile installed, cached, draft, and archived revisions into one Library | -| | `selftune library synthesize scan` | Build local evidence-backed candidates without uploading raw transcripts | -| | `selftune library synthesize evaluate` | Run package, replay, routing, baseline, and regression gates for an approved draft | -| | `selftune library synthesize release` | Release only the unchanged immutable revision covered by a passing gate | -| | `selftune library preview` | Inspect the exact Sync & Backup artifact list and byte counts before sync | -| | `selftune library sync` | Back up selected immutable artifacts to SelfTune Cloud or a self-hosted server | -| **sets** | `selftune sets suggest` | Classify skills and validate recurring set patterns against later local sessions | -| | `selftune sets outcomes` | Compare accepted sets before and after activation without claiming causality | -| | `selftune sets create` | Cache pinned skill revisions as a reusable project Skill Set | -| | `selftune sets update` | Create a guarded immutable revision without overwriting concurrent edits | -| | `selftune sets capture` | Turn the current project's active skills into a Skill Set in one command | -| | `selftune sets derive` | Capture a project with explicit name, path, and harness options | -| | `selftune sets history` | Inspect immutable Skill Set manifest revisions | -| | `selftune sets export` / `sets import` | Share or restore a portable checked-in project manifest | -| | `selftune sets plan` | Preview project links, no-ops, and conflicts without changing the project | -| | `selftune sets apply` | Materialize a conflict-free Skill Set into any supported harness registry | -| | `selftune sets rollback` | Remove only project paths owned by an apply receipt | -| **ingest** | `selftune ingest claude` | Backfill from Claude Code transcripts | -| | `selftune ingest codex` | Import Codex rollout logs (experimental) | -| **grade** | `selftune grade --skill ` | Grade a skill session with evidence | -| | `selftune grade auto` | Auto-grade recent sessions for ungraded skills | -| | `selftune grade baseline --skill ` | Measure skill value vs no-skill baseline | -| **evolve** | `selftune evolve --skill ` | Propose, validate, and deploy improved descriptions | -| | `selftune evolve body --skill ` | Evolve full skill body or routing table | -| | `selftune evolve rollback --skill ` | Rollback a previous evolution | -| **create** | `selftune create init --name ` | Initialize a new draft skill package skeleton | -| | `selftune create status --skill-path ` | Show the current draft-package readiness | -| | `selftune create scaffold --from-workflow 1` | Scaffold a draft skill package from an observed workflow | -| | `selftune create check --skill-path ` | Advanced draft-package readiness primitive behind `verify` | -| | `selftune create replay --skill-path ` | Replay-validate the current draft package | -| | `selftune create baseline --skill-path ` | Measure draft-package lift vs a no-skill baseline | -| | `selftune create report --skill-path ` | Render measured draft-package evidence as a benchmark-style report | -| | `selftune create publish --skill-path ` | Advanced publish primitive behind `publish` | -| **eval** | `selftune eval generate --skill ` | Generate eval sets (`--synthetic` for cold-start) | -| | `selftune eval unit-test --skill ` | Run or generate skill-level unit tests | -| | `selftune eval composability --skill ` | Detect conflicts between co-occurring skills | -| | `selftune eval family-overlap --prefix sc-` | Detect sibling overlap and suggest when a skill family should be consolidated | -| | `selftune eval import` | Import external eval corpus from [SkillsBench](https://github.com/benchflow-ai/skillsbench) | -| **hooks** | `selftune codex install` | Install selftune hooks into Codex (`--dry-run`, `--uninstall`) | -| | `selftune opencode install` | Install selftune hooks into OpenCode | -| | `selftune cline install` | Install selftune hooks into Cline | -| | `selftune pi install` | Install selftune hooks into Pi | -| **auto** | `selftune cron setup` | Install OS-level scheduling (cron/launchd/systemd) | -| | `selftune watch --skill ` | Monitor after deploy. Auto-rollback on regression. | -| **other** | `selftune workflows` | Discover and manage multi-skill workflows | -| | `selftune contributions` | Manage creator-directed sharing preferences | -| | `selftune creator-contributions` | Create or remove bundled `selftune.contribute.json` configs for skill creators | -| | `selftune contribute` | Export an anonymized community contribution bundle | -| | `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 | +| Group | Command | What it does | +| ------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| | `selftune status` | Get a one-line health summary plus compact attention / improving highlights | +| | `selftune last` | Quick insight from the most recent session | +| | `selftune verify --skill-path ` | Check draft-package readiness, then emit benchmark-style verification evidence | +| | `selftune publish --skill-path ` | Publish a verified draft package and start watch by default | +| | `selftune search-run --skill-path ` | Run bounded package search over routing/body variants against the measured frontier | +| | `selftune improve --skill ` | Route to the smallest matching evolution surface | +| | `selftune run` | Run the full autonomous loop through the simplified lifecycle alias | +| | `selftune orchestrate` | Advanced alias for `run` | +| | `selftune sync` | Replay source-truth transcripts/rollouts into SQLite and refresh repair state | +| | `selftune dashboard` | Open the visual skill health dashboard | +| | `selftune service install` | Keep the authenticated dashboard runtime alive under the native OS supervisor | +| | `selftune service status --json` | Inspect registration, process state, version, and durable daemon health | +| | `selftune service doctor --json` | Diagnose the fixed current-user Windows service-lock compatibility state | +| | `selftune service repair-lock --json` | Repair only a proven stale pre-SQLite Windows service lock | +| | `selftune doctor` | Health check: logs, hooks, config, permissions | +| **skills** | `selftune skills audit` | Inventory installed skills and recommend keep, repair, consolidate, or quarantine review | +| | `selftune skills consolidate --skill ` | Preview or apply reversible archive-and-link cleanup for duplicate installations | +| | `selftune skills consolidate --all-safe` | Preview or apply all source-confirmed consolidation recommendations | +| | `selftune skills consolidation-rollback --id ` | Restore every archived copy and remove receipt-owned project links | +| | `selftune skills quarantine --skill ` | Reversibly remove an approved skill package from active discovery | +| | `selftune skills restore --id ` | Restore a quarantined package to its exact previous registry path | +| **registry** | `selftune registry install ` | Install an exact workspace-managed skill revision and record its local integrity baseline | +| | `selftune registry suggest [--summary ]` | Recover a managed edit only when automatic workspace delivery reports a failure | +| | `selftune registry sync` | Apply reviewed registry heads while refusing to overwrite unsubmitted local changes | +| | `selftune registry sync --automatic-only` | Apply only skills whose workspace rollout policy is automatic | +| **library** | `selftune library` | Reconcile installed, cached, draft, and archived revisions into one Library | +| | `selftune library synthesize scan` | Build local evidence-backed candidates without uploading raw transcripts | +| | `selftune library synthesize evaluate` | Run package, replay, routing, baseline, and regression gates for an approved draft | +| | `selftune library synthesize release` | Release only the unchanged immutable revision covered by a passing gate | +| | `selftune library preview` | Inspect the exact Sync & Backup artifact list and byte counts before sync | +| | `selftune library sync` | Back up selected immutable artifacts to SelfTune Cloud or a self-hosted server | +| **sets** | `selftune sets suggest` | Classify skills and validate recurring set patterns against later local sessions | +| | `selftune sets outcomes` | Compare accepted sets before and after activation without claiming causality | +| | `selftune sets create` | Cache pinned skill revisions as a reusable project Skill Set | +| | `selftune sets update` | Create a guarded immutable revision without overwriting concurrent edits | +| | `selftune sets capture` | Turn the current project's active skills into a Skill Set in one command | +| | `selftune sets derive` | Capture a project with explicit name, path, and harness options | +| | `selftune sets history` | Inspect immutable Skill Set manifest revisions | +| | `selftune sets export` / `sets import` | Share or restore a portable checked-in project manifest | +| | `selftune sets plan` | Preview project links, no-ops, and conflicts without changing the project | +| | `selftune sets apply` | Materialize a conflict-free Skill Set into any supported harness registry | +| | `selftune sets rollback` | Remove only project paths owned by an apply receipt | +| **ingest** | `selftune ingest claude` | Backfill from Claude Code transcripts | +| | `selftune ingest codex` | Import Codex rollout logs (experimental) | +| **grade** | `selftune grade --skill ` | Grade a skill session with evidence | +| | `selftune grade auto` | Auto-grade recent sessions for ungraded skills | +| | `selftune grade baseline --skill ` | Measure skill value vs no-skill baseline | +| **evolve** | `selftune evolve --skill ` | Propose, validate, and deploy improved descriptions | +| | `selftune evolve body --skill ` | Evolve full skill body or routing table | +| | `selftune evolve rollback --skill ` | Rollback a previous evolution | +| **create** | `selftune create init --name ` | Initialize a new draft skill package skeleton | +| | `selftune create status --skill-path ` | Show the current draft-package readiness | +| | `selftune create scaffold --from-workflow 1` | Scaffold a draft skill package from an observed workflow | +| | `selftune create check --skill-path ` | Advanced draft-package readiness primitive behind `verify` | +| | `selftune create replay --skill-path ` | Replay-validate the current draft package | +| | `selftune create baseline --skill-path ` | Measure draft-package lift vs a no-skill baseline | +| | `selftune create report --skill-path ` | Render measured draft-package evidence as a benchmark-style report | +| | `selftune create publish --skill-path ` | Advanced publish primitive behind `publish` | +| **eval** | `selftune eval generate --skill ` | Generate eval sets (`--synthetic` for cold-start) | +| | `selftune eval unit-test --skill ` | Run or generate skill-level unit tests | +| | `selftune eval run --skill-path ` | Run paired output-quality evals and write an iteration benchmark | +| | `selftune eval composability --skill ` | Detect conflicts between co-occurring skills | +| | `selftune eval family-overlap --prefix sc-` | Detect sibling overlap and suggest when a skill family should be consolidated | +| | `selftune eval import` | Import external eval corpus from [SkillsBench](https://github.com/benchflow-ai/skillsbench) | +| **hooks** | `selftune codex install` | Install selftune hooks into Codex (`--dry-run`, `--uninstall`) | +| | `selftune opencode install` | Install selftune hooks into OpenCode | +| | `selftune cline install` | Install selftune hooks into Cline | +| | `selftune pi install` | Install selftune hooks into Pi | +| **auto** | `selftune cron setup` | Install OS-level scheduling (cron/launchd/systemd) | +| | `selftune watch --skill ` | Monitor after deploy. Auto-rollback on regression. | +| **other** | `selftune workflows` | Discover and manage multi-skill workflows | +| | `selftune contributions` | Manage creator-directed sharing preferences | +| | `selftune creator-contributions` | Create or remove bundled `selftune.contribute.json` configs for skill creators | +| | `selftune contribute` | Export an anonymized community contribution bundle | +| | `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/Research_trigger_eval.json b/Research_trigger_eval.json deleted file mode 100644 index 156daf0c..00000000 --- a/Research_trigger_eval.json +++ /dev/null @@ -1,394 +0,0 @@ -[ - { - "query": "Search npm, GitHub, and the web for packages that solve these two specific problems:\n\nPROBLEM 1: \"Real-time TypeScript code structure extraction\"\nI need a package or combination of packages that:\n- Watches TypeScript files for changes\n- Incrementally re-parses only what changed\n- Extracts function calls, async/await patterns, class hierarchies, import relationships\n- Exposes this as a queryable data structure\n\nSearch for:\n- \"typescript incremental parser\"\n- \"typescript watch ast\"\n- \"typescript c", - "should_trigger": true, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "contextual" - }, - { - "query": "Research how open source developer tools handle opt-in telemetry and usage data sharing back to maintainers. I need concise findings on:\n\n1. **VS Code extension telemetry model** — how does VS Code handle telemetry for extensions? Can extension authors receive usage data from users? What's the opt-in/out UX?\n\n2. **npm package telemetry patterns** — how do npm packages collect usage data? Examples of packages that do this well (e.g., Next.js, Gatsby, Create React App). What's the consent flow?\n\n3", - "should_trigger": true, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "explicit" - }, - { - "query": "I'm looking for open-source tools that can turn TypeScript/JavaScript code into visual workflow diagrams. Cloudflare just published a blog post (March 2026) about how they use AST parsing to generate visual diagrams from their Workflows durable execution engine code.\n\nResearch the following angles:\n\n1. **AST-to-diagram tools**: Any OSS projects that parse JavaScript/TypeScript ASTs and produce visual flowcharts, workflow diagrams, or execution graphs. Think tools that analyze async/await pattern", - "should_trigger": true, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "explicit" - }, - { - "query": "context\n ", - "should_trigger": true, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "implicit" - }, - { - "query": "I'm building a real-time AST-based workflow visualizer for TypeScript (like pencil.dev but for system architecture, not UI). I need to find existing OSS packages I can piggyback off of for two specific layers:\n\n**Layer 1: AST Watcher — real-time TypeScript parsing + file watching**\nResearch:\n1. ts-morph — can it do incremental/watch-mode parsing? Or does it re-parse everything?\n2. TypeScript Language Server Protocol (LSP) — can I tap into the existing TS language server for real-time AST updates", - "should_trigger": true, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "explicit" - }, - { - "query": "I'm looking for open-source libraries or tools that render workflow/pipeline diagrams with these specific visual features (like Cloudflare's Workflow Visualizer):\n\n- Named steps as nodes (not code-level detail)\n- Visual containment/nesting (loops contain steps, try/catch wraps branches)\n- Parallel execution shown as side-by-side nodes\n- Clean minimal design\n\nSpecifically research:\n\n1. **XState Visualizer / Stately.ai** — Can XState's visual inspector/editor produce workflow step diagrams? It han", - "should_trigger": true, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "explicit" - }, - { - "query": "what is PCI DSS and how do I know if my organization holds it?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "the CLI should be first-class for agents whcih means the skill should include all the instructions and teach the agent how to use it effectively right? I'm thinking that our skill is a little too large and should be broken down into references or workflows more similar to how we did it with reins. What do you think? Do we need an initialize workflow? How is the agent going to know which coding agent it is and which coding agent to setup for? does that need to be part of the CLI or in the skill? ", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You are implementing TASK-13 for the selftune project at /Users/danielpetro/conductor/workspaces/selftune/budapest-v1.\n\n## Task: Improve invocation taxonomy classifier accuracy\n\n**Files to modify:**\n- `/Users/danielpetro/conductor/workspaces/selftune/budapest-v1/cli/selftune/eval/hooks-to-evals.ts`\n- `/Users/danielpetro/conductor/workspaces/selftune/budapest-v1/tests/eval/hooks-to-evals.test.ts`\n\n## Context\n\nRead both files first. The current `classifyInvocation` function is at the top of hooks-", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Search Reddit (r/ClaudeAI, r/ChatGPTCoding, r/LocalLLaMA, r/artificial) for posts and comments where people complain about AI agent skills/tools not working, not triggering, being unreliable, or needing manual fixing. Look for: \"skills not working\", \"tool didn't fire\", \"MCP tools broken\", \"Claude Code skills\", \"custom instructions not triggering\". I need the EXACT language real users use to describe these problems - their words, not marketing language. Include who these people are (their roles, ", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "TASK: Implement Age Verification Screen for Hikayatna v2\n\nThis Flutter app needs age verification to comply with 2026 Texas SB2420, Utah, and Louisiana state laws (effective January 1, 2026). The app uses:\n- Riverpod 2.x with code generation\n- GoRouter for navigation\n- SharedPreferences for simple storage\n- Feature-first architecture: lib/src/features/\n\n**REQUIREMENTS:**\n1. Users must confirm they are 18+ OR get parental consent\n2. Age verification should happen ONCE (stored locally)\n3. If under", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "/Users/danielpetro/conductor/workspaces/selftune-cloud-app/casablanca-v2/.context/attachments/Summary of Create Docs Guides-v1.md \n\nFinish creating the docs", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Thoroughly explore the selftune cloud app codebase at /Users/danielpetro/conductor/workspaces/selftune-cloud-app/beirut-v1. I need to understand:\n\n1. The project structure (main dirs, package.json, framework used)\n2. How the API server is set up (Express? Hono? Fly.io?)\n3. How routes are registered and organized\n4. How auth/middleware works (API key validation, user lookup)\n5. The database setup (Neon Postgres? Drizzle? What ORM?)\n6. The existing push endpoint (POST /api/v1/push) - its route han", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Thoroughly explore the repository at /Users/danielpetro/Documents/GitHub/blooma-v2. I need to understand:\n\n1. What is this project? What problem does it solve?\n2. What's the tech stack (languages, frameworks, libraries)?\n3. What's the architecture and codebase structure?\n4. What are the key features/functionality?\n5. What's the current state — is it complete, in-progress, abandoned?\n6. Read the README, package.json, any config files, and key source files to understand the full picture\n7. Look at", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Goal:\nReduce polluted/unmatched query noise on real host datasets, especially environments where wrappers or systems like PAI inject non-user content into Claude/Codex histories.\n\nCurrent problem:\n- source-truth sync is now much better\n- but unmatched query counts are still inflated by wrapper/system/meta noise\n- this makes the product less believable on real machines with polluted logs\n\nWhat success looks like:\n1. actionable query filtering is tighter on polluted host data\n2. obvious wrapper/sy", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Research CliQ payment integration options for a web application in Jordan. Specifically:\n\n1. **CliQ by Jordan Payments** — Is there a developer API for CliQ? Can merchants/apps initiate CliQ payment requests programmatically, or is it only user-initiated (user scans QR / enters alias)? What does the merchant integration look like?\n\n2. **Ahli Bank Jordan** — Does Ahli Bank (Arab Bank Group / Jordan Ahli Bank) offer a payment gateway or e-commerce API for accepting online payments? What is it call", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "the agents finished the plan. belows is the summary. please review it.\n\n════ PAI | NATIVE MODE ═══════════════════════\n🗒️ TASK: Real-time improvement signal detection for selftune\n\n📃 CONTENT: All 3 workstreams completed and merged cleanly.\n\nFiles modified (5):\n\ncli/selftune/types.ts — Added ImprovementSignalRecord interface\ncli/selftune/constants.ts — Added SIGNAL_LOG and ORCHESTRATE_LOCK paths\ncli/selftune/hooks/prompt-log.ts — Added detectImprovementSignal() with regex patterns, integrated i", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Please provide a concise summary of your findings about the blooma-app repo. Focus on: architecture/stack, what's complete vs incomplete, where it got stuck, and how sites are generated/deployed.", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "I just merged the PR but I don't see the new release tag", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Reply with only the word pong", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "do we have an easy way to do it locally outside of this project? for example I just wrote one with another instance of claude and saved it here: /Users/danielpetro/Documents/Projects/FOSS/selftune/gtm/blog/01-authoring-time-vs-runtime.md", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "did you update all the @docs and other files like @AGENTS.md @ARCHITECTURE.md etc.?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Explore the coach/therapist booking and profile system in this codebase. I need to understand:\n\n1. How coaches are represented - check packages/backend/convex/schema.ts for coach-related tables\n2. Any coach profile UI components in apps/web/src/\n3. The coach booking system - look for coachBookings, availability, calendar-related code\n4. How coach profiles are displayed anywhere in the app\n5. Any existing \"book a session\" functionality or CTA components\n\nSearch for files matching patterns: *coach", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "sure", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You are a documentation writer for the SelfTune project (selftune.dev). Review this staged diff and generate documentation for any new user-facing features, APIs, or changes. ## Changes being committed - **CLI commands** (docs go in `sites /`): oss ## Staged diff ```diff diff --git a b index .. 100644 --- a +++ b @@ -321,30 +321,38 @@ function adaptReplayResultToValidationResult( proposal: EvolutionProposal, replayResult: ReplayValidationResult, ): ValidationResult { - const perEntryResults = re", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "I'm really curious why some of this code is in this pr, can you review our last merged PRs and tell my why we have some of these changes?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Search the codebase at /Users/danielpetro/conductor/workspaces/Ithraa-v1/vienna/apps/web to understand the existing UI infrastructure:\n\n1. **UI component library** - Check `src/components/ui/` for all available components. List them all.\n2. **shadcn/ui usage** - What shadcn components are installed? Check for `components.json` or similar config.\n3. **Tailwind config** - Read `tailwind.config.ts` or similar to understand existing animation/transition configurations\n4. **Design tokens** - Check `p", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Code Reuse Review of changes in /Users/danielpetro/conductor/workspaces/selftune-cloud-app/manila-v1.\n\nReview these specific changes for potential reuse of existing code:\n\n1. **packages/api/src/services/skill-service.ts** — Added two new batched queries for trigger_rate and avg_confidence from canonical_skill_invocations. The query pattern (batched Map lookup) matches existing sessionCountMap and evidenceMap patterns in the same file. Also added a similar single-skill aggregation in getSkillDeta", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "create a plan to add posthog to the project using the posthog mcp", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You are a documentation writer for the SelfTune project (selftune.dev). Review this staged diff and generate documentation for any new user-facing features, APIs, or changes. ## Changes being committed - **Hono API routes** (docs go in `sites/docs/api-reference/`): packages/api/src/routes/community-bundles.ts - **Shared types/validators** (docs go in `sites/docs/concepts/`): packages/shared/src/validators.ts ## Staged diff ```diff diff --git a/packages/api/src/routes/community-bundles.ts b/packa", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Design an implementation plan for the kolkata-v3 cloud dashboard based on a dashboard audit. The audit compares the CLI dashboard (tunis-v1) with the Cloud dashboard (kolkata-v3) and identifies gaps to close.\n\n## SCOPE: Cloud-side changes only (Tier 1 items from audit)\n\nThe audit identifies 5 Tier 1 high-impact items. Items 3, 4, and 6 target the CLI (tunis-v1) which is a DIFFERENT repo — skip those. We're implementing the CLOUD-SIDE items only:\n\n### Item 1: Unmatched Queries — Add to PushPayloa", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Create the Legacy Messages API endpoints in Xano for the Hikayatna app. Legacy messages are future-dated messages that unlock based on triggers (age, wedding, graduation, etc.).\n\n**Endpoints to create:**\n\n1. **GET /legacy-messages/sent**\n - Get messages sent by current user\n - Auth required\n - Return list with: id, recipient_id, recipient_name, title, unlock_trigger_type, target_age, target_date, is_unlocked, created_at\n\n2. **GET /legacy-messages/received**\n - Get message previews receiv", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You are generating test queries for a coding agent skill. Given the skill description below, generate realistic user queries.\n\nYour job is to create a SMALL, TARGETED benchmark for cold-start routing quality.\n\nFor POSITIVE queries (should trigger this skill):\n- Generate a balanced mix of:\n - Explicit: directly names the skill or uses $sc-model syntax\n - Implicit: describes the task without naming the skill\n - Contextual: realistic natural language with domain context, proper nouns, filenames,", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "From the other agent that created the plan: ```` ════ PAI | NATIVE MODE ═══════════════════════════════════════════════════════ 🗒️ TASK: Verify gwangju-v1 cloud API ingest changes 🔧 CHANGE: Audited all 4 files across schema, validators, push-service ✅ VERIFY: 17/18 criteria pass — one gap: no tests for new record types 🗣️ AVA: Cloud ingest is correct and field-aligned with CLI — missing tests only gap ```` ### Results **17/18 checks pass.** The cloud implementation is correct: - **Schema** — ", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Design a comprehensive SQLite migration plan for the selftune CLI. This is a PLANNING document — do NOT write any code. Write the plan as a markdown file.\n\n## Context\n\nThe selftune CLI currently stores all data as flat files. We're migrating to SQLite (bun:sqlite). The cloud version uses Neon PostgreSQL with Drizzle ORM (24 tables). The CLI syncs data to cloud via `selftune push`.\n\n## Current File Storage (what needs to migrate)\n\n**JSONL Logs** (all in ~/.claude/):\n1. session_telemetry_log.jsonl", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "RED TEAM ANALYSIS — ATTACK VECTOR 3: Market Size Risk\n\nYou are The Skeptical Systems Thinker. You trust nothing. Analyze this specific risk:\n\n\"What if the market never gets big enough? Agent skills remain niche and selftune stays a hobby project forever.\"\n\nThe recommendation: \"selftune should remain MIT, focus on adoption pre-1.0, then monetize hosted services at $8-12/dev/month.\"\n\nContext: selftune's TAM depends on: (a) AI coding agents becoming mainstream, (b) those agents using skills/plugins", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Explore how PAI packs are structured by looking at:\n\n1. Are there existing packs in ~/.claude/Packs/ or similar?\n2. How are related skills grouped together?\n3. Is there a pack manifest or index file?\n4. How are packs installed/activated?\n5. Where should a new \"MarketingAutomation\" pack live?\n\nAlso check ~/.claude/skills/CORE/SYSTEM/ for any pack-related documentation.\n\nThoroughness: quick", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "just commit your work", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "why did the subagent say that? was it code or docs that are stale? We need to fix it so that doesn't happen again", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "I just tried to add repo and got this error: Snapshot vyvi-sandbox-ready not found. Did you add it through the Daytona Dashboard?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "go ahead", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Explore /Users/danielpetro/conductor/workspaces/selftune-cloud-app/hanoi-v1 thoroughly to understand ALL cloud data ingest paths. I need to understand:\n\n1. **Push endpoint**: Read `packages/api/src/routes/push.ts` or wherever POST /api/v1/push is handled. What does it accept? V1 vs V2 payloads? How does data flow from CLI to cloud?\n\n2. **Alpha upload path**: Search for alpha-upload, alpha-inspect, alpha-projections in the API routes. How do alpha users push data? Is it the same endpoint?\n\n3. **C", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "can it actually be in the same div as the main header so we have a little more vertical viewing space?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You may continue with yellow warnings, but you should review them before continuing.\nFederal Tax Return Alerts\n- yellow warning messageEnter Identity Protection PIN\nYou entered an Identity Protection PIN on your return last year but didn't enter one this year. If you had an Identity Protection PIN last year, the IRS should have sent a new one for 2025. The IRS will reject your tax return if you received an Identity Protection PIN and don't enter it.\n\nIf you need to enter an Identity Protection P", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "did you review this already?\n/Users/danielpetro/conductor/workspaces/docs/taipei-v4/.context/attachments/pasted_text_2026-04-04_11-48-07.txt", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "I can't see it, is it white?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "b89obn5w9 toolu_01RrEmMoPYGtPwVdTtDpBCR2 /private/tmp/claude-501/-Users-danielpetro-conductor-workspaces-selftune-site-abu-dhabi/47d4acde-1e57-42e8-9883-e2267aa1bda7/tasks/b89obn5w9.output completed Background command \"Scroll to features section\" completed (exit code 0) Read the output file to retrieve the result: /private/tmp/claude-501/-Users-danielpetro-conductor-workspaces-selftune-site-abu-dhabi/47d4acde-1e57-42e8-9883-e2267aa1bda7/tasks/b89obn5w9.output", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "## Error Type\nConsole Error\n## Error Message\nEncountered two children with the same key, `Art`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.\n at (packages/ui/src/components/skill-health-grid.tsx:598:23)\n at Array.map (:null:null)\n at SkillHealthGrid (packages/ui/src/components/skill-health-grid.tsx", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You are working in /Users/danielpetro/conductor/workspaces/selftune/osaka-v1 on branch WellDunDun/audit-improve-selftune.\n\nFix these PR review comments. Read each file first, verify the issue exists, then fix it.\n\n## Comment #24 - dashboard/index.html line 1291\nSilent error swallowing in SSE data handler. Replace empty `catch {}` with `catch (err) { console.warn('[selftune] SSE parse error:', err); }`.\n\n## Comment #25 - dashboard/index.html line 1408\nFunction reassignment of `openDrillDown` is f", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Full transcript available at: /private/tmp/claude-501/-Users-danielpetro-conductor-workspaces-selftune-miami/cdb69d62-c5d2-42cd-b6b9-804e2f2ddeeb/tasks/a879e2ff18185a858.output", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Read the output file to retrieve the result: /private/tmp/claude-501/-Users-danielpetro-conductor-workspaces-selftune-lincoln/71001b6f-037f-4693-8ced-0cf7ad6595f7/tasks/bjemic78q.output", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "create the docs for selftune. \nhttps://github.com/selftune-dev/selftune\nhttps://github.com/selftune-dev/selftune-cloud-app", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "now do you still recommend option A now that you have more context?", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "You are a documentation writer for the SelfTune project (selftune.dev). Review this staged diff and generate documentation for any new user-facing features, APIs, or changes. ## Changes being committed - **CLI commands** (docs go in `sites/docs/cli/`): oss/selftune/cli/selftune/dashboard-server.ts ## Staged diff ```diff diff --git a/oss/selftune/cli/selftune/dashboard-server.ts b/oss/selftune/cli/selftune/dashboard-server.ts index c767eab..25ba668 100644 --- a/oss/selftune/cli/selftune/dashboard", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "review PR#87", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - }, - { - "query": "Full transcript available at: /private/tmp/claude-501/-Users-danielpetro-conductor-workspaces-selftune-miami/34d8f6d2-1940-4290-bd85-a468de027fe3/tasks/aa513d41d16f3c684.output", - "should_trigger": false, - "source": "log", - "created_at": "2026-04-15T13:01:11.881Z", - "invocation_type": "negative" - } -] diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md deleted file mode 100644 index 12b46991..00000000 --- a/apps/cli/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# @selftune/cli - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/local@1.0.1 - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 - - @selftune/harness-cline@1.0.1 - - @selftune/harness-codex@1.0.1 - - @selftune/harness-openclaw@1.0.1 - - @selftune/harness-opencode@1.0.1 - - @selftune/harness-pi@1.0.1 - - @selftune/orchestration@1.0.1 diff --git a/apps/cli/package.json b/apps/cli/package.json index efb75704..735d4d08 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/cli", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "SelfTune command-line composition root", "license": "MIT", diff --git a/apps/cli/src/commands/lifecycle.ts b/apps/cli/src/commands/lifecycle.ts index a6fa3db4..d11c7199 100644 --- a/apps/cli/src/commands/lifecycle.ts +++ b/apps/cli/src/commands/lifecycle.ts @@ -131,8 +131,13 @@ Run 'selftune evolve --help' for subcommand-specific options.`); } case "improve": { - const { cliMain } = await import("@selftune/orchestration/improve"); - await cliMain(); + const [{ runImprove }, { runHistoricalSkillImproveCli }] = await Promise.all([ + import("@selftune/orchestration/improve"), + import("@selftune/local/historical-skill-improve-cli"), + ]); + await runImprove(process.argv.slice(2), { + historicalImprove: runHistoricalSkillImproveCli, + }); break; } diff --git a/apps/cli/src/effect-cli/argument-compatibility.ts b/apps/cli/src/effect-cli/argument-compatibility.ts index 5d85434d..cc5e8c3e 100644 --- a/apps/cli/src/effect-cli/argument-compatibility.ts +++ b/apps/cli/src/effect-cli/argument-compatibility.ts @@ -316,6 +316,21 @@ const EVAL_LEAF_SPECS: ReadonlyMap = new Map([ values: new Set(["--skill", "--tests", "--skill-path", "--eval-set", "--model"]), }, ], + [ + "run", + { + booleans: new Set(["--json"]), + values: new Set([ + "--skill-path", + "--evals", + "--workspace", + "--baseline-skill-path", + "--feedback", + "--agent", + "--model", + ]), + }, + ], [ "import", { @@ -358,7 +373,7 @@ function validateEvalNumericValue(action: string, flag: string, value: string): } } if ( - action === "generate" && + (action === "generate" || action === "run") && flag === "--agent" && value !== "claude" && value !== "codex" && diff --git a/apps/cli/src/effect-cli/commands/eval.ts b/apps/cli/src/effect-cli/commands/eval.ts index c7a6dda7..c46cc787 100644 --- a/apps/cli/src/effect-cli/commands/eval.ts +++ b/apps/cli/src/effect-cli/commands/eval.ts @@ -182,6 +182,37 @@ export function makeEvalCommand(action: EvalAction = runEvalAction) { }), ).pipe(Command.withDescription("Run or generate skill unit tests")); + const run = Command.make( + "run", + { + skillPath: optionalString("skill-path", "Skill directory or SKILL.md path (required)"), + evals: optionalString("evals", "Override evals/evals.json"), + workspace: optionalString("workspace", "Override the sibling evaluation workspace"), + baselineSkillPath: optionalString( + "baseline-skill-path", + "Previous skill version to use as the baseline", + ), + feedback: optionalString("feedback", "Human feedback JSON to copy into the iteration"), + agent: optionalString("agent", "Agent CLI for isolated runs and grading"), + model: optionalString("model", "Model override"), + json: Flag.boolean("json").pipe(Flag.withDescription("Print benchmark result as JSON")), + }, + (input) => + action({ + action: "run", + input: { + skillPath: Option.getOrUndefined(input.skillPath), + evals: Option.getOrUndefined(input.evals), + workspace: Option.getOrUndefined(input.workspace), + baselineSkillPath: Option.getOrUndefined(input.baselineSkillPath), + feedback: Option.getOrUndefined(input.feedback), + agent: Option.getOrUndefined(input.agent), + model: Option.getOrUndefined(input.model), + json: input.json, + }, + }), + ).pipe(Command.withDescription("Run paired Agent Skills output-quality evaluations")); + const importSkillsBench = Command.make( "import", { @@ -257,11 +288,18 @@ export function makeEvalCommand(action: EvalAction = runEvalAction) { ).pipe(Command.withDescription("Detect sibling-skill overlap and consolidation pressure")); return Command.make("eval").pipe( - Command.withSubcommands([generate, unitTest, importSkillsBench, composability, familyOverlap]), + Command.withSubcommands([ + generate, + unitTest, + run, + importSkillsBench, + composability, + familyOverlap, + ]), Command.withDescription( `Evaluation and testing tools -Actions: generate, unit-test, import, composability, family-overlap +Actions: generate, unit-test, run, import, composability, family-overlap Recommended creator loop: 1. selftune eval generate --skill diff --git a/apps/cli/src/effect-cli/commands/registry.ts b/apps/cli/src/effect-cli/commands/registry.ts index b184970d..704837bd 100644 --- a/apps/cli/src/effect-cli/commands/registry.ts +++ b/apps/cli/src/effect-cli/commands/registry.ts @@ -150,9 +150,25 @@ export function makeRegistryCommand(action: RegistryAction = makeLiveRegistryAct (input) => action({ operation: "install", target: decode(input.target), global: input.global }), ).pipe(Command.withDescription("Install a skill from the registry or GitHub")); - const sync = Command.make("sync", {}, () => action({ operation: "sync" })).pipe( - Command.withDescription("Pull the latest versions of installed skills"), - ); + const suggest = Command.make( + "suggest", + { + name: optionalString("name"), + version: optionalString(REGISTRY_INTERNAL_VERSION_FLAG), + summary: optionalString("summary"), + }, + (input) => + action({ + operation: "suggest", + name: decode(input.name), + version: decode(input.version), + summary: decode(input.summary), + }), + ).pipe(Command.withDescription("Submit local skill changes to the workspace creator")); + + const sync = Command.make("sync", { automaticOnly: Flag.boolean("automatic-only") }, (input) => + action({ operation: "sync", automaticOnly: input.automaticOnly }), + ).pipe(Command.withDescription("Pull the latest versions of installed skills")); const status = Command.make("status", {}, () => action({ operation: "status" })).pipe( Command.withDescription("Show installed skills and version drift"), ); @@ -183,7 +199,7 @@ export function makeRegistryCommand(action: RegistryAction = makeLiveRegistryAct { internalHelp: Flag.boolean(REGISTRY_INTERNAL_PARENT_HELP_FLAG) }, () => Console.log(REGISTRY_HELP), ).pipe( - Command.withSubcommands([push, install, sync, status, rollback, history, list]), + Command.withSubcommands([push, suggest, install, sync, status, rollback, history, list]), Command.withDescription("Distribute team skills"), ); } diff --git a/apps/cli/src/effect-cli/compatibility/registry.ts b/apps/cli/src/effect-cli/compatibility/registry.ts index 082b3542..99f923a0 100644 --- a/apps/cli/src/effect-cli/compatibility/registry.ts +++ b/apps/cli/src/effect-cli/compatibility/registry.ts @@ -10,6 +10,7 @@ Usage: Subcommands: push [name] Push current skill folder as a new version + suggest [name] Submit local changes to the workspace creator install Download from the registry or install github:owner/repo[@ref][//path] sync Check for updates and pull latest versions status Show installed entries and version drift @@ -18,8 +19,9 @@ Subcommands: list Show all published entries Options: - --version= Set version explicitly (push) - --summary= Change summary (push) + --version= Set version explicitly (push, suggest) + --summary= Change summary (push, suggest) + --automatic-only Apply only entries with automatic workspace rollout (sync) --global Install to ~/.claude/skills/ (install) --to= Target version (rollback) --reason= Rollback reason (rollback) @@ -27,6 +29,7 @@ Options: const REGISTRY_LEAVES: ReadonlySet = new Set([ "push", + "suggest", "install", "sync", "status", @@ -69,6 +72,7 @@ export function prepareLegacyRegistryArguments(args: ReadonlyArray): Rea const normalized: string[] = [subcommand]; switch (subcommand) { case "push": + case "suggest": appendValue(normalized, "--name", firstPositional(leafArgs)); appendValue( normalized, @@ -90,6 +94,8 @@ export function prepareLegacyRegistryArguments(args: ReadonlyArray): Rea appendValue(normalized, "--name", firstPositional(leafArgs)); return normalized; case "sync": + if (leafArgs.includes("--automatic-only")) normalized.push("--automatic-only"); + return normalized; case "status": case "list": return normalized; diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index a77b2354..b383b17c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -37,7 +37,7 @@ * selftune export-canonical — Export canonical telemetry for downstream ingestion * selftune recover — Recover SQLite from legacy/exported JSONL * selftune telemetry — Manage anonymous usage analytics (status, enable, disable) - * selftune registry — Team skill distribution (push, install, sync, status, rollback, history, list) + * selftune registry — Team skill distribution (push, suggest, install, sync, status, rollback, history, list) * selftune alpha — Alpha program management (upload) * selftune hook — Run a hook by name (prompt-log, session-stop, etc.) * selftune codex — Codex platform hooks (hook, install) @@ -46,8 +46,6 @@ * selftune pi — Pi platform hooks (hook, install) */ -import { dirname } from "node:path"; - import { CLIError, handleCLIError } from "@selftune/runtime/utils/cli-error"; import { INTERNAL_PACKAGE_BUNDLE_SMOKE_COMMAND, @@ -57,16 +55,6 @@ import packageJson from "../../../package.json" with { type: "json" }; import { isEffectCliInvocation } from "./effect-cli/selection.js"; -declare const SELFTUNE_DESKTOP_SIDECAR_BUILD: boolean; - -if ( - typeof SELFTUNE_DESKTOP_SIDECAR_BUILD !== "undefined" && - SELFTUNE_DESKTOP_SIDECAR_BUILD === true && - !process.env.SELFTUNE_DESKTOP_RESOURCE_DIR?.trim() -) { - process.env.SELFTUNE_DESKTOP_RESOURCE_DIR = dirname(process.execPath); -} - process.on("uncaughtException", handleCLIError); process.on("unhandledRejection", handleCLIError); @@ -137,7 +125,7 @@ Advanced / Stage Commands: export Export SQLite data to JSONL snapshots export-canonical Export canonical telemetry for downstream ingestion recover Recover SQLite from legacy/exported JSONL - registry Team skill distribution (push, install, sync, status, rollback, history, list) + registry Team skill distribution (push, suggest, install, sync, status, rollback, history, list) alpha Alpha program management (upload) telemetry Manage anonymous usage analytics (status, enable, disable) hook Run a hook by name (prompt-log, session-stop, etc.) diff --git a/apps/desktop/build/dmg-background.png b/apps/desktop/build/dmg-background.png new file mode 100644 index 0000000000000000000000000000000000000000..6adae9c9a7f021c16fc17b2f978f4610518ad638 GIT binary patch literal 237300 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%0A0{X4_%Nrz2rmbMmv_YVjjtxM9{R1v7Le+ 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 AQUCw| literal 0 HcmV?d00001 diff --git a/apps/desktop/build/icon-mac.svg b/apps/desktop/build/icon-mac.svg new file mode 100644 index 00000000..6969ac6b --- /dev/null +++ b/apps/desktop/build/icon-mac.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns index 214baeab2f03dd5728915284a3099989cdaa1c8e..1951c6f9812a6438ac55ee1536f0bd64252efb50 100644 GIT binary patch literal 309320 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 z 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
fkVFo$e2u> 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%0ApabvBL7^aP9rar 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 z
Vci1CEqbSCckb8r=5whrq+yvxl{FE7^SR(Z85X!$y$?ajfN$W|wH6N7!>_eQm+ zYs_;et9dvGCbi;#;-zg@`~Kz`gU@%^uhwbsY)%cH_6}8S;T$h;&S8wKkNB*+F|W=q z+ksCwHW96eW~j55tc5atzwS>T7#J8OeEit2XHaVj+7P}-QvN2zNeXYt8#m1OO&1+* zji-+?=kI#250UGH#aL!_gNuN$w&x5Vtq9UB4e>6ZAzS$4>3@V!a0&FP?{DE7^Y;Ri zJ71L+;uhCxG9PEgp+2@yHXXgHrb-E$MTE_pc6N=9H?Cj*wb1(f!QTcS+wH^Q zb-L};hK%E4eUCN5Z_2G8la-d1@_r|GOSlH#`2l0$3yV~<{mq!~wr!xvR1=jD609i| zSY-jOi3Lir&o_ZoAyBXg9CAp1zf&;4>KpT4pB_|C04=Yggx8A#GUkxKV9xHphdz@N zfyg*L>whUB-}Xy^xdpLwi>(P~ zEIhd+Btf!_sx`ki5HH($7k>qKunq^^J$%2pl0}n-5!25m<&S)zRsl|RwA^>@#0&Fnnw^;8;j<0lj5}x7GnaXnsQ!2i@&lUanTe z6Iqjy?ca5$r{ujR(d3;WRySYY;l!e1wK=!~P#}D?f3#OLt}FAEg88>ZL`{bLhWmZf zOq3d=87$OyB>bjQ37vsWC?Qj0Eq8M+;LJLO|MC%gg;{0&_r+N;X0{3WJ%y>D80l@U z?@m9TO+PNf)o_S443b&y<6B2c?bvF5Ry*kr4raG7OONNr>o#13}4>^G9t!*^NG((Cn7f+PrWo?&6Wo? zuRAWj@&lQsSJ6Ano#6Jqj==p9b)=2&3asTAUzJ?sLo6-C>${AgJGiyykC{2UDPjk! z@(*+LZ~LmDptUBji&vJIs5||UcL#!zgmMa+dg@J?o73-D=O)NMz=C3$8y^_Nzd`?l z{FAP63~Ycz@%n%4TRw!9A~CVYI$0Q&MMeaH6o&8%*AfoQq|%N=8%#cxVCRd^{HM

<|?T!2(XzC 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 Xh8|vBkUO`tesjlh% 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≻A^-;()#LAr#hJE=Gwk#e2L}F&q3^2YgLMCxK)WbT^PqT^p4#7c zx5uzW;}sXbN!Wk%WVF%_%ynFrx!ce)qXn$Qvy!1tkL7t;Pnkc6PI1Mmt1finpD{CM zFaM^cBkjtIHE5zpA<=MVxm63xEH7~Em8zrrIHZ@|?O#m2^`B#S%d_^jw(qj3l|#G? zXgyCHs7ijLX9Y@SQQbF~Z=Q)0V#nesmZlDy;-HGHPmWbI@6JRw;4@1)e$kQrtcQ0lDfCGsH@bDvOAJZF}w3^N9Pv zy)!(ukcqojnS7)=WwM?cYbCP{I+HdYWl!ycmPxI`aN2@7irDb0qiV z*>UQ%e4(7}=kTQ~PATWRT-eO$t4ORZY2v9cX{f5hVfy+2J9T9AnpnKx$&c8xAgA^!+)_t@NGSe@v6*ptw{9C6_h%Nk_%CG&LDY)Kf;g z-}o@SUb3s{z^7Oit*(6%w|kLhy#v;m2Lyg*{DKiWS&)*w_VKQj?$!2e<&A`ktE?MlK)$BE~Ut5K;U9Y(ts&rD`W|};k1cR zFWwk6A?(n6-&q_Nmuea7c!)-ENNc%wo2Y+K67nw5Pw2gK?R;sEM*=fQLG8Qe?sE+g z&3eppY&}%b8v?TTWnVphgWFr*6)-z%`-F`3ALWM2=bt+ zCVaC>MMcOd=~#roy!tW_)g_aoF z$H}bL)X9J@9AKY7oA=yA_u^=f|1W+-#Bl(V1&MdlA*O7jKbJjrKoq7M2I3B8+m+fA z*Bv*Z6=+GhL*u|Uzwh498<&%1cw3De>A&--d#*&+t3HeFQD=6NnN|KXG`-J#B{}}5 zU^M@=UEQlBnHcm!3=|;F)R*|0aqTJ1_Zl9V)sMP}Wug(-(aC^7C67F9($=gVvwCE5X(F^#Q z5D^04ppb&VJ_Z-4+}G1>CO>of6*)^b_;h2ZPX;VNu#8 z-uPsq>+TIMq0G^l)7&@p@!-LEuf@n{q}mNjA6SI=t;@XFyg4~?tLRm`>utvCp%gcC z8IHj=I<$uo+z3}}GZBmyIOK<*viH!Avj{$-V}qHZDp3)4u`+05jYW9d_npP)kK$EZ zX|hHA&b9hTto6ZVto89_&A?CP;2|N98=hEK6FW=ej$_;Ms$ro8V2I*?i3!cZY?2LM zqA8=*$Wd=mgF)gDKRhXLoEnF|HW0S^>r0Z1vpU3{#AI9mEvr@dDEa!(xHQCr$vG3w#-4Mbprmq#>NN6rgx(V z+^`oZN)h(rzL4NYOTo~JV}Z^>Mi?VF+fh8Avt(!SyS7`bx=)8hol9-6U<71VjsrP+ zyXR(H=gyiDqpYR_WcJ6}c+g0-@p{+s3I`ki7iG6&eYqS0#l#qZ6vIm(q&V{`H-+c} z)#1r%!%0bc3SxQwzLQt=FfJv#&Z~wkEcW@k5TvzVLi*tIkVAZYOgzPjif5JOzs^@hZw_AGC7*pfN0us01cXwBn z=l;uz?vESr#}7B3@X-gw1+w1k;i?W}n|~M<{ot@#x@7No`K|0@{0Hx= z_lO*E@S1TV(X5EENY4aod5DN)k~@?fL_;cZK+>!MA~6;b20W<1$`HpPM@R~*`ZCUN z6s{YoUm5bpq3-xeK99INhGgOsLHmP*QW3#mb}AE^1|K8@o=~8qjJYGqKnk!&|F-Jl z^NK&lW>T?M{%);5x?k>liD?)GPIQgu{g~h1WlHLMYGt5+afPtX!`u%!Gv`W$H4cvF5BZiEjk4q6xd0}u{yYf3u*hj^}Re@KR!A6-Qd zDd7mB&$uuP9W2?fc7{+(^|b(~LBGchYcuA|0C!!}KjIPb2jSDbQ1o6W+qX%>Sb@O7 zKqXT~PBUm3n`x^Tj-o0X$KqBY&q2-!Y+TFqh3eYogL$DV$@$ft>lnX2g#<)3_==rJA!MX>hala?SKkKke>zev=McfV|hhHZl#6%(LmEs>!W><%o zYP%oR_ROvj7$}V&fuXnbHRCsa=r%#nyBiZS~20+i`tc zyHm}z|MtL4@aRAsGS^{e}0EDX0~DWh`|JOKq%TUjx| zYq21_iuP}e@#)67412~@H`P~CL!4d5+Bxc@?5Gsx@1HD=gAeX%7&PKH)Gg&}6NE!^ zdxEU#m{r{1xJ`^QxC#C-D%9i)OcVnpFgV?&=ilQ(GV)`}ZvaJQxvG6mo5uys--Z?i8&kay{Q=HHF7M>KE#%8yv}c{uaogk<*R)kGRmBjIGni#Bh1X8%rx-9q9E zy7tx}ZN-#fYOTL7kNdWI|FA2n2VOf`fZ^9Q4aVArhCeC3M7PpX|LmVHy>X`aezA7j zsh|<}PWvoc`rGeXDPiz!^PmKJLDz@zkL2BT(p{%l>}0jMg}E$&j%x!oCjxQ}ow(al z8+b=k!wWC{*&L1DI#jh9&!2I-;?evOmZN(9*kGIgn_A25)gwhMpe{;IHmHXpK0Vcd zJKY)A$RH4#+f5n@8lokX0;cAmYZXlZ?B;|a^bZMno;*1cQNMn{jj(7{XY?Qi@A-xB z3EJa53Et$gl~EkEbBeZJ`>y=gNz@u!_ZMY!Kn1wwM;?4g3mf4|B+o^=x*;yH_}U@K z{9DkZ5|vRxy5?u+*&j-N{68~|F`t&d&eO0e8M88uQ2!l^-gM^)MqeoFaargpCcu}- zd&3*^X2((*BmL?d^Iod*!Y1<`!P3N^nl>fq28)^cvx~2-d4(bxta)~a-=5yLA8p;7 zZS#A;=MxA552T89@C6O zoEoQYEe<Q(N77zv6wGXvkfR;#rvXnXTIOFH128o?&|8Q@2MJ17iazTIT(|_V2KL)dgB0;^hGtYaIF0{~&fuMqph_He)eEMQNibR{>p=Hp$^lTy z^R@uInXU)%hBV=I$rvR#rUR;I2qpj9(7sCWUBA>J zO5sHF^ZsB&rTL&1&h}t#kkngn%xWx6mM2-$*J$~-?2ln)8U*uNYRh7@{>zA9+y4H8x{s= z#h~d-NpHMfE!ep4X>q9;fbFejmqjcB)a#}m;UzBv`@FTbbXr=@!7RVmU@o_ZMXay}fO&g4xp}kdkKxsZ(Iqv4f^XvYW#+pO&sJm9MZ`RP+ z9Gtl^KKSv&E4{_mvGY%4!$AzBJJ>pNR19Ce=|Mg$qUzyGChmQfPGyEtm`U*j3C(Y@ z%Rsl{Lq%975dFPaa$Gq%qYr=a9#Sp03#gIp;N>whQ`cwblbEYF;K7GdlFwsCYqgT;sLCu*`4)@Qw7d!-UdC~u7)c9#k!=Zp&ue*kuh5DX%>z0i? zpUWgrOG#phHrBKxEBnrXf{|9$$hvgReGN`nSg0x7fSR1|COAcR$k%yw?iF3pis=3~ za%E0=etz~OZ1>J)_ogy)PcdJzoa8ybE^F!e2Xo;l*^T&NGKLK$41Zv=3Fvf^?D(_v z^VnCEb3dHRb{tM1Ly}8iRh|u_{SkfNtYr6|E6FkTGqf+i6mut&l~(wC{gZd&W999A zywwj0vsWa&P}jLP)a5+Ep~MDg=+(qntb~A?$Xl(cR`pRf5$CRJS_ZEaofF!B1_%78 zlR!J9lrdQoIRhQ%v3j+AiToQ4V8CwHr5Nx(qqH2uZ+}J~+P@14S7$RLd~n1t=7i%5nKj zLaj~}8qs4!Z2V1-z(K`HSs2)+E zWoW27l~-ipBtGE`ZKQAaz5M2N=Lbr3X9ymsqHd(TN=+9hPxqP)bmL;pYz$&!XZarR zHx>ViaHu;jme4Lx*c@PNPt{|pbr#iurH15PH?93+^esRJbr~^&Bk5hw`@MXFtG1U?xWG^07R2Kj z*V|Q^Q1)V}M#lS^XH9CMCU=ZV;1MOF?Y^r|7^-8K{mdtK_u?}85{1q81aGbSO5T`R z%2~g#KyTw3{vyjN3YJJw);QIpl3M1`vY{EL&?ZpD^+-cGb>&okyCp4QbXizmp#qjU zf6`UQ!K3^kbAKHMYvIXB67^4>YWe3eN$9Yh}gkbY%V|yQye#oSR#i3Ony%4;0Zcdkf=UZUz z3K6$Afe-|T09_bvLs9j#zpm5+B7yQGZRR5l1&x(|s9$Ze&0(+(SHn}kbrO@>#B%UM}OA5miGjR7s3Qn zcavT<$&>Zh6aG+u$fqwjNfSOxzr{9Qe&c@a(zxU6Vl?ohNx*#%p(`K<&2eqrcrDIB zyLM~a&3YHxe!GDWHH*#edMA#d_0Aw{x6GRLF$K~lQbn;R+EdPnzu8#S2vN5 zn1lZ6#;x7N=M_nqt5DEq|7o5Gb?diej9?1O-4*((<%m@I3n20d4S|YNaxHZ#;OtGb}{n=x_5?{$^eI`uASMGWj z<@m_JRBdI95R;HiB~p8zenVkoGv-WWoMI1z$y~>8U40@sK1%5om5)J#01o@3$)m$l z)WhZ5y=e`m#DCvM;>@?!c8q5Bpw~bhy(XVWjo}@gGw9Q#-}O=q6a1EY`eilKNsW{; znN_(!mNnm1c}xy^HF)KYR-6U}Or=>Wb_}wksqN6vX+Iul(!2T@Z4*j)7A5(Gp!@GEweRa0 z$a8Hei2M;%KodbE3O&hPEo}=$^i)6d@0_Y>W>y7`=1uvl4O6(p!qJUtP*41}gj( zDUiGri*lw*xStx2$jB|IM*I0+OhlHmnSnx!VanGZM|brGk{!_G@v;*I0h*e|d&+MdUhcqc7u7FEIq=!{wlyATNB054y8%bF? zYO`ZG5y+18)|=Dqtv2u{CqSSnLm>%BX_wk*V17QM;Yt7tt{acWH^c|mZnC0B2(cGE^a=fj z*&v+Pic(Gx&Yib4GA};$ET6RnZiFQsp#yPPD~$T`J&_o;qhW?2V}d?O8+GE!uW3=8 z2*T(Un-9VHQQ&HeiFts@2tCg=$THvt6ZV~84t@+(i~A9UPvwV%@k6!-N)<&W9Pb$o ze;TF&_!NjU!b6F0qaEiXDsBb|nt}jcVrAkJZ~Gwqv&MXzPtg?-m?rFAu}R5sy2fl4 z`sui0T9Zo*`>=4|=(!#WpD_6X2?v7Z9ppr+%O9$q0YX3|DSfdvJJ~b#6rJ{(CqDuiBsr;m>*;xMzM{rg>#M$UYIkm z`ZPSho=8yO{#$8-LmX`RH}QVA-hSeKCJ>!^rZa~3OZ!)K(N|g)V{UpDSH)wu`d=Sw zz1Cle=W;CsnGZboefOJ+l?&J1MHT_nk@%u=7|=}?rgMvFM-!Px8tYqX>h;w?1ohkP zU^FLgp*+NbG1xam>sWhX7CK;0O;w<&xw(iU*&zaLZa~Pw<*hS^e6C%Aw z&*S}tYf__urt3W~C=Pcewqbrs_6UgshB4W68E9^ZHZ^p}jm56PIwm(X1g0&&s(2HY z1l|X>j^?wv6CY?BJ8wRPE9q3@M*n<%ddgY5{p$xhK;Q0@0GC9qysZ@;+Sytq$IFFf1L|mr%$8tob~|xer-csm=YKMggu^u?eMmn6j^A ze8f}o8=u)OqzwLPZ9l8v=+$V#7bO-{e1;yTc4(~ZvLfZagt;#GetSnPA93F+(0v6Z zAlw4vXmpPYG3!<@{8M{K;}EYf!f2V16t1;Zfhbt2Fz^r*uMu+b)m8>o6x*LrC6R~n zv|M}CA8r${bRi#5nm7;ZlSz2b#bCpnvsU`5ONqy}$Cj}z1jf#(CWl)h6OoZ8D~dWQ zJ273S#7V$(#Qtyor}tD3b>iGLvDSM~7UzNlB=!f|rYp95n_CNt=bB#*7z?G{UVo$in_?iUCvG??Ts%=gxi)gVaQKpP8K^}tBxEu9J|)YI_0_O zsJUC2RKzEp&~|?38M6F`=)EhLy_$Fma@QPFX#walCzSc{5}~43yOuZXsRtH-LHG(t zOdz107)-5%IBIZiYxR?(^KW6r?OXD&O0zYBJ52ifYNi#VklfR@utE(T=efOm;Ta5z zaZk>h=7#Y*IHZ(>-vNEr-dbk4A2P#D4A4RPaj3J|VeJc>G;5lY=v~fG+3uz^mSvhC zJB1FXy<^i7V|XkgqF6zJm68pi2}*?rR1_^pgPTt51*bIbl?_<%{Q)u*v%>F2j~x2v ztmMs)O%|-Sqa?gVRvB}YG*pa%1%l{P+BZdcC^r6(Sy7gG9I&K)=T5E-bE;lhJ}z5wiMPBE)Bg3;-Ov<9TEi(SDQbO7W^-Zg6rMk0uN8oTID7|%z>jtqJ&Qu2W8 z`z7cdD6Ae=rC$?2j0g*7O(nQdkB>EY)Rv25mnaO;=^yG@>^+tx@2=@h0=K0hdgI}Y zfFa=!WQf;HIgP|Afx6@84)@L7-;Y}(6 zT`iA|{+nR;;@nI_IIN=*78;8N_F>Ra=BimHG`BKmZw=b%x&e+LLRQrHl2(DgpktF`WHaYYV4+=CmRk9fZ zNGInX&%1FdO%@V7N=f%sQ9jL~U}(-c{5cVA^m2z{0=ZldC(P8#Fj*GZRwIFKW>oic zRhz-!npS(7flh||v0%bN1Msi_WjtWQBx5PhVnHrRpQsU>FMXVjeDPV}Guogvm&?7~|?Eg@Rfkln+Vq3rN#qep>701%+3vig{n4${K7 zv4ccjuLB^NIvcopw@n1jmY6@h;?Adldf2B_X7|Wlx&9moymX$ixVP5vt86J-;83}RNf)L%fJNTnWUaZYTPh6Z^58zX zV>azg&$v4b2YfDASnaGfNj8^ng%bB=IS3x@*_=xDxo#&WK5p2G_FU2(eQGYB6ns(= zpA>qN=UzCjss4O5I*gtGR=tqP=68;)HAp6e!89T&jyGRS2A zbUtf0Ec9s<&fXgKS1N_%uC)dNN^Ov^!3MoSag8pt2Br05pPJzV1t@SR& zDim5pkw44c2*uPltmxBu@no56Iu%LO+&USWaZ*gf7rZSjv8n`-I1uX4qyXfLW z@7(*JStFV9DHCWjCdpv*0L0N!#`_l8#mq0*1V>t7?5Ye}?p{jPc6On5US|lXJRgC5 zxvTtvMt*W!j4%fu)a_5?CdUe5Ql9eIA>Texkxps;F=KZSp)PG+KO@)+><&~D6GDPy z5YEIA?`T`Ti5P(vG>EWl=pQ0ePwBumzjSt6=*0ON@pYw!s43Hrm7B0#AdVQ4hwgT_-wKXf_b7Xb<@X7-?Si(lQ0cEbVZC~VDXe>2c1)xA zfjVV?^HYmY-aLtBQtV9nxI$TO~)6FaPZHOZg%;Q5!E9DOh+23=DP}uE1F| z+It7#KF$LRcGEAj59Myo@Az@CZ#*kIcQP^Mxtv{kxlxfL4wl``1Gs;Kvvk530Z>ct zb(F?*ysk1-I%-G)as4yE284hVjrj|A9nUBfYd&n26}MsyYuOE;J3EytSR($?3x5Sl zlV6eq`J`8!wj?Jzhu^#rw7DzSi&syTTa3FAq$uj_w4M@yDX_ou&0;WOrN?P?ux$76 zPd=FeU~JQ~8jaHot;9k@NRY3!&A=J6(6CuW_KUU#!Z&C?2A75IB+4DbaAjRrMBi5d z?f|()>S3_i$JfcsjBY>YEmk$(=7mK!9n9lj$8v8Yzt5q9R$Ouu?dxztGa?-q`_!bc|=sc^r={V`> z*6P0X%Z=!cM3TJw^B@2Hi@pmK&MC3WD^Ago1#AUoQG_H4&^Yq~1wtj;_C&~urDul@wL~40j zsW#l^1q?}nN15H_YB*k{XQ)5U^c1b6xJ&r!bPl!NmShxP386yX0Y5T=Vp>qei>nL- z0vu#=Ui*_b4Ud;0AcdQ^y0IX~-cZ51>O?l4uL1*&|JJX5s1gS_GcaJm1uC>5yk10E z+%&b>K?Xvf44Lg4J%cLQGnr_|t2IAYwA5X&e!qp6}>T3iq+~W4D?_VPIK*;YEL33abV# zq_uRnQZqs$91-iPgX}+z_U@7{8PjY{c)Zx7b?#8Ukvlr>LC~7p4OZLwzSgUuMD{nN z6!@no5Uz}mXvZ3G&DEj|Yd>Q|t{I6U(JoL5fSFFJz~5-C%^|Az3nWG+d=kM{LIeL0 za-G|pe3a!^{9fsJXOFZ9=%&&jU*vJ$V(am_!(oXeH`;#cYBO^*pQk4f2hBaM;agXQ zjNQ8?0+tgL6Dji6wtTF$>{epr*J7LB#2LV+_l(HA!N!(TT`af3!K{dJB2wNJe6?yX z0qx+DJumvPX*YHnO2&81@`H~)&m$u+j4+vb-?_Q?{bI?D|7S zATjAoS^8bTaA?O(Q(Zl#k<@w>+R?C;ip!WXY`Hx6d{i5(vjc{$>uv>NMc`k&k;g)Y$dmJ?wEK<`*rlMs{kJiq=J8PHH0- z8%5d~l+EWG&y6>3sod^r{B#H_V%KsdtlvK;To;GD*Oix<5U|c_@|6myY6DQ@Tcy?WP;!Vf#<{}q%XDr|J+lf`lAC6iWePs_|9 zDqv6>-^w>U+;@s}-LZ;@*0ZL`!Z3e=1X`L-cA|xaBN#C#sIk8)#tyP+-Y2PSbcj!d zD=xsTR~R+OJbufMA{;zYY3JS;@p{e4Kph~k`V)oy8ZFtlbC?U9myzpL>&L?i~rBZ;3*2ZE8{zC zJUjyNXBhCY=LQIk98y6N4$fjT0EX7MjoZ#G#m%f$1QuN03Muugj(U?8Zg)Xrjh+%2 zp^7?E|3fB`{pN=N8{1*up`Bi{cQI+y*AK*$GS~B#4ugWnXK#gG=d-7t-c}mU_qh9- zMecG~|HX%e5OnHy49|gI72q6!^=JSgGQ3wdVFwzVtP8%u0kAS{2m6jUjxe zjb+fb8U~56lj%pBxcqi$4Dh<;o~+GRH{M4l6jOELb^@F`UQ{$>ST}|LzPBgk`rZ8) z&h%4RfYLzCg>J@7oyesx-a!UG$@EMsbtReP{8GB16}tecIGHz-!yi?IGu+5@mrex~ zt*r>;-U?9ov`cwPDAQ^^Ul0dqyR%JQ`j9ILP*y)HN#ZJm91R@yyzz*FgdIYpuDxiv zleeGSb}kO#Y_w#$gT#h&xm)7s4sGbmxBY}Y^Wtg=jOX&} zgOJikeV2S8=tCkI;{4j4>l65c1s-`Bra|;{$Xmh2NLIX)~r(3v`9aR5{RqJj^<%6*1tBtsUuH&_dwdrOM@s-dtF zi@uQQ0i1*Bf2&Sen!O5twhTIeHus{6f6kpm2P?l(4!mVR1zgC zYwQm^q1IXz>lTnN16sKacLK@+eL0-G1X20ypX!UcUX?`&I^qz6&hhylZKFgZ9WKsh ze|{_`ADmt898I|qch%S*oO6j7;G$XBR?6;r@3M4Q=0vLRp~ek|Nait9gK&F`=T49T^>%kMV_!1rEz2zXgTN~0I(?SK7_tZt*yVu!g7f*0&8rEP?If*Axbdb!c z3DsHa7(v~ebzd--+&3?8a3|3~i~FvdnsG(nz_cL3Qg66@i#Y&Wr_wA^9#(3+`c8VQ z4^Z??%MvV!ovaTcMYV_hRrSKBl!8&*&yS4;+e$Gox;*k0qfji%w1RnNjP(2uVV%w{ z?#!VAJjm0A#M{qyYcy9yN2h~2m075;M+O++NrxmT2|bBRq+dFg=V>bXB5%>%{3Ne26+_W!wPAH1`c0M zKQ@ctU@0&*hA%eMMU4B_2X)(8Rcw27^e^)$D#bC$q~wp|puW&`mZ|CAUPmY$0iTVh z;{8-TJU*PMTu00MnzG9?+0r}WA4BxonSze(%lE(sHm2szmnC&wf99k#9;Ck_WD>9a zW?m{ZnSR!`3voJ$XO_{Fg%i23q%9=nz6*YP)Z>de{hVL!deFT#FWT@~8D_`3*s*V1LutTz@ z0m*x>mepupI3aD7iFDWw=SWrKG~jf@3!U%5R<^EoHA+tXZEIP+S(}Bl0uZunZ-S(a zwoagniKF6~iq7^?y|{B{9Mhs*FZAPwWTqfLXzPgHSeMjKbPE=)@PrE4kjJZNwQV$8 zAFUXmZ`M=6F-x(+xd8n{tVIqEYS~}!-wO^5%&Neh&3GhHJy6^`a!!ahu#mD@ru5-^Vl-=V9p7^GH9!Ku1%l?qr8t=CcSO!>*$PH#pEIf( zOmB64o6T}4GR=ohy8}1pJLT$8j~r+;l|zbNr5YqBk6DgNylEoA6v|t;zCHmwfMb}D z+6+qbw~Y<+SJeY@CpY3#AdZoi%8s6!x{SzzeHbl?+!9*fi&P<)?@TPMKLvR>{#)i_ zamLFGd#b||r5!Ao^MQ04D68MsLm5-5S^m7;UnpSd`xoxgU)%A$LqBC5uE$@ z!R;MXS2~QC>;(())>J3u1lPL!8<$Y#)Ho z3W-H87iZl(gQxtpKN%0=P!3l_<8u9%7sv8(@| zf58kXjsn1#lg^}2g+a^vOAT#W8nk|($f$qYqF?X~5J`zkJp-BUP+qYXoVvh_t^rD< zNPDi4rML`xul_B1~?O6rB?Q+l>ovV4dImR3(*e4FX= z?;j8a)JZCGy6v!*GTn8nc3V#KC|%kuB&p>gYHU6al|h_-sK`jZ-H!;oxc8M|A7-Ef zWfvk~6w0&$DD$3DZY@bkkHka6nta#B{|3TR{nD13_)Tgb9thBQ!%D|t1P7lpR7Dp` z*Fgi)$e{nz7D5?FQ5TZM0L#p*07(Sh>1>Zh%Rot>`)aG9`fsh2Oq!ixW277V9yNH} z3&^!-e{)|Ym0`lC>_COvH^1IHd4aXla=$p7`(M-_d5CNYdH~D~D~>6bErSPQ+9hRu%~+)Oxa)*w zF&oG=SdA6B)2LjA>JQ`VtfZ;Q;tWt0u=pQKHWhe(@D783qJ$4YdS)Y1ZF1RyOqA8<yRyM=z#^olEF{xKvotQ&XH*5-Gr8R!9z`1`GWkWRRRT~oxHA)QL##X!b3@-^~2nrRF~K*o8a?P^kkJEVC1K2 zi6;BWm7eb0yxQ6A_7ZD%M=Qzadt69T7zi}RR$TzWpRzk!+&a&B|A%!2VUvEQxP8lb zq>*9@`wqAa0OSIt`!H-DuUNs)I$ zT7(q9po9I;J8oFjZWt_<*eF^55=%~-?D_E(OAwp#1ieJuk3xLzm$9&NxY{F;1Anmm zrBeyZ=jbsCkp~Tb_5P=YAC2bMf3s97)U%J#QqkkkVNIz8EHfgdl#@902Q{~516Jml z)E1vsYpFt^_D8T#`Ga@HkkyC}(uYh({3ZwceyWZ&Ep9RVzz}abHQkQ5(cxqyf8tu< zUJ0^Wkx}D5kukLl#}<-m2)54kOkaW@uYM8p9ql`V&?3e=w{AE;i9sz^gLZx1KDzvl zop6kdKyloExLXa8|5bl!EV`lYewvyl6)Nr%SGOakYmoG|aWowFXmOEgjhR|4k)p`5 zNm*ACQUr(r3q>d=?yTt!{a;oX z-x}hq)w01ZZq=W>eigMR*~8^Hzw*o!(L1#7lW^C*qBwl|nl{p;ABjNCTr{AZ3xJll z!7nnQ-Z2W{??i;3RCKRS@KTpfHp`|H<>y`Nd-v1cEq|7`O z4k`JipmNR6CB+=@hN#5vLWu{04IK1en)JZm$-4Jsi(_07j`dFQkodLWvpgy_2Xbab zmpk+rY>+}SGGYM#Rps{5|HP5>Ai^YGV^3Q9N z7x5fwb>((~fBtjwieUL7cC`kszqaC&zI2fzf?@LnYE{2JkT9xFxKyZe9WfUE|t ztS5@2l*Q9f$1qiZe>fqWXzXZal+gl^nF`jwsVT!`lexO~Z(gs8h>?5lRBA=G>9SoCx;Vs2JBDiSY-Z)hdtp$-_?F?3H%(KanN06;0vi`Xk_I)Wnw9`(-hE9 z8_vQ<`eY^zzod9o$cn2|DVF zKh#bqG2K#kgIRIwz0DB~5!HN!$T^Rsya^*emg(OJx52p3L;cpocjr^18nHL441vWZzco6CR(+grZe5H5e;3`^`Ns&XMeu`bqL9HSSU_DRLpZMYNpaNEz0h5f^+dWmiD^mhWPQiY$l{{`KvYn*&_i95p|k}$g&q6LCGe^nA= zw5399I5_>w`}Aa!C+#h@1lY1Z!hh*1$$=>Xh#|Xq7Gig{^m4zbOJvvF(uuAS^|~>G z#dCD4GGUQhjxP>9c24JabI)t%%=}Eb7Sz)EuCEZ=m0?+Rgd6%QX-o8n-ap<%QnUYi z^Yq2|E8zOU%mTV4{tYWi^bq#Y(?av`a@7FH*%vG+~B&lm+8?1M;P2-_L|tlt~S zH{5tHBpATK`niPn2P^X9NUS+tiIfVq{RtrTy4H|kZ6D~&L?j-(sCRt7t&(s*hN(D5 zb~nr&3Ym&UbfZggpjXE7XrQ2hNsc&5nCZQ6^*aAAMmK;JMGwfzA|&~{&7}CVC}1`k z#bp|wE^4+fwe5f!@xSr7-D&IpxJ>?9W`Pww4@%-)R43t^^=V%9oks_=l~WbO6y4$G z&0?OHMcpEB16lf;vQFX(5S%(3`JJF-{Sm@d*8v9>cM^&P_;D1Q#%Dv|@XeJK++cu^ z+sD}(H`U_cB#>a8X~~b#^48ko0(p{$!KDJC3*kcM7&jmG@yqaCny_= znqG#{<~S8vG84!-$rN4L#?ar*K%hnlefa{J8`ID?PwEL|-iho*Sdt&1T{SC962U?* zlF_MKlPTkN zpA^2u&cvA~IY$W+l-jxG{O`^c#RvCwd)x5My{*Mc;8}qH)~rPkVGM4Gx9O8EFEHo=SNeeUtsgre| zK`Nmitt@;5aqk#B&yuwlX`eT6X=p;C=xrzJ!nwA5<@hRMR|NAyS7hq`x3qBq3F7|I zc?moZWQFusxMQ9}J8@O8iLh8j}_6E3xB9!idqtA%a+1(RaZQ-YM6f?9Y?=$nt+ z=?*07WI;*)N{r`zN=?3hD5#Z$h0cKO_JrIOy}I0t43?Vp?!?mX6~W>)g5c8eM^`y~ zF0RhkIyAYJjSC@(Kw|UZwSd(>wV7Ft>az`a^fL-H7(JJ!|LwQHp5*hDiUgV~>7QnZ zMO@Te=D3jZ2|ByEt2M-7MG$D^1NT-a{XHFv@MuDBaAFQ2A3dt!Mg$9ck+guG;2lW% zTgHI1TniT!strNnLYz5-NIKhz4~;v_6^67jPM0sQ{&ThPPu;0iEBpAqz<;?Hk1azh zweMS*2Q`3DzbPbT4iGx-za9XgSOE>|yExlVUpN)zU~%IvAazNLkp{12eeM1I?L^Cg z0CP<0B}Eyla?2D;drT6;2J?@QzcFUNzc1zRo+#ob5Q>vw(!n_Hj__}MlPstNV7u{+a$C5R^2KTeXq<(7>n01Ni+8+3XodG#Hew z!UZS=`SK+-NEAy(cXU6dHUz;;!Y_0my&}0 z8;lYgxa<}Uyag@Wt3Y|^%S7=MYZKjbPL0hBTLbX_zts(Y06Dr_Ac&>YM9NR76^eS1m7&4| ze~0^B#vIZFNU@on&&uu*V-T(x?7``&Y+r0A?xS}_@qTk>Tow2e3TBaAKZSw{eg)gT zh5w`=PF3@7ouaImVe4sO1y^G=?iS&9XBDG-<9iXtpzrYMUK?cAF=L#U*yM z-fjN#VwzY-G1W7hT=%+6PxqSgUBn6RGsj@G#|3+i)X#TO~nD zg(;{22v<&8j_KF6W+uLzC7xnsu6qjk)Na7K{Y*Pn42a+yKk8*C(Ry2#Q3&7M>wm$# z{@t@M6xWn_Eloc0_kphA&(T_XK*zVoV3t3+?c;}#wZdjJilr^H_dQ%E1T^#?|L+$` zpb1^pSI+KM{`aqME9qGsCAaLpEINp%d&trVDqondy|TXQH+@*P@d!9Nuf3#YdgB3A~U})7*o@Ov$-{ zvkOt+?|{O)W?^RnoH@WTdhlY>zUFFCYoAAlqw^%#y{%CFZsCiwBR4TWYqb7+Xdewt}pHZ#k^I1 zxeDO_jipWw+}ZtEJ3-PW2^K@h%6T8A#qg-<2){0-!T&=%KZ}9t z3)kMVwmBr6YzLq-i3j$LqLUmGeu7j%F+u#)uQc3Otqx?14HTTeI-h)AQEbj79kZZt z2(S!Hym&XcTu;)KdKLN5Vgff;kC;rq%*R1uTCYd*_%gn4-tvZj2CW%pB^pH~w^zMq zc|T-U*<`2vLMw`+9uk{SXo;g#zBOhHcebW6f32@}lLV|8z;xFit6R%XsGF1sf!iIL z)U|1H*(VoHtU=jNyWj@67=K9N@V7V@I;UwzbmH*IB}fiAVR-%gT3CyLC9u8PJaT=A zc5vvx+N2&0dKZ&OFr0|C5Wz;;pDRe)`cK~+5LNtXD;cu6s)4j5G)CooVyye$WX)9a zq_P4b>%oTesv-oU(#adk?Vv5+fLKby|x~SkQQ@r?(?!tsoo(Mg} zibyPvr5Hucu*m9H7mJszzVUK?^5Me!{<9;ZQt`*1X3QGAuLzjZ)!Rvy>SnrFzI)z< zVj~V21-Mu%U(xL!6@3FAoF*sH?eu);f0kHX&uX6k`+N9!$Fv1MYm>h~nTH(;b}-7j zvaoH3in{cn(OwIH^iQRx72yp}@k?<=W%8@K|734meyBd^;>zOFm4@*B-!(I#G!#Ay zSO$QIp!pdn07XR)Rl_}&>wd&rG5`oW6t?}Yf9M3YUP_xEk%=(8#mcRg z(qC&uMe6}$7`0H72z^X5eqA~kCXY@fW$IAZ7;BKtpcUU5^O+DFK`sVJy2l=D11t8I z*imm#FzSFga?6jCpWEi0-e4@2(p9o_rzTwOp?+q-fWL3#z+n^f1X!;+x+Z8eTnhJW zno7T(9B{<&vf?dOczJ1Y<&ePApyX8r*k0g|h52LlicIh4=&IG9dsI6(&Du}0gB3zw z?bTX~&Qr7J&;0dkzft{~9fgF$E-!iVKg*(R+nMEOCoV@rHnMz%`nXX06)*Edig!ekN*ch_C#d;OJ*au>*8H#N&A^Kn-RDc)H=y?{qP| zjDcc6aIm+yUy6HBZ@?Oxe)ul5p>>>qVKo;m_wg*ADBV%m@0?A;o0?ARmshLq z+jxbtFR>g7@F613`~E3HYW6PyDM}N)6a=2u{lb$_0{Fb{cc=*Rj61{vN$x z`?3(s)3rM;b>;ksc6`jYEG30Rg*T{0ADu+AEq|E1Vq?vv;R~nlmPP5}bH6_TrPTZ5 zKg0gakR+>dw4BDQuC4y!EPy9wZTXgb6R-BIgA2xGQXxlMx0?e_?5ji}08Rqj`^?7A zeD2*ai9yA*384L=S!0{+&)_Z2O(@^dUf%bneIb$yWfz?*+69U3g_-U~GmXWGS!gS!)Q zM)KYv>nzsx1_~tZES#*Y*_%w%r^iRpghp}Nc(=UuQ1_*~6{Go4coKN(H^xg8BToF| zSbtI+W{NW$F6>kd!CK6&|&cM)>uowQQR=!9VUtO7{PaO4E0jO#Z6NA!c zA+Nc#n&N863+3V-8)kt<8w^GekH674pi1Gsmfmax3*$PyMOoARIO)VAPL!)tH>f2RlbyA6kbUHK9c|ov+gJ{)V zV+>~DKHe<6#CtDvz3*`syS0ZV{bOUboIcpo5jGfQpJ91k#mP5<@uWYnlP}gpIJW2} z;Bi|@@gtZ1)I`#F)_vp_bC79xH577kkLYcW?aa_aM^YzGlE5)AqIob3nV%7G)&Gn+ zLJ38`U}t{!O#{*XB+i0qM8^}gxIw|XybV^~mGrhZABr`9bBeOcE>7+rA`90(l6(dt zE;9ViwzsmO@Uz+IPhtX^aKVkG8Sgy<^*g8L;hfTsix}ePZ@5JzVz;MB#jwa3xf=^J z-Z}!4Tx54|e`=qxJIUIDv_(3IuONPA`3Ub{@(L{rE)W$&_yfs&P!vm=|ZSQ8KksW}I0SYoh; z{rkayFi)<3c*pDao4r*NalI**H-UlEM?LOLL*^yZ#R>?-Jo9qJ;#=Gvttdz;+h8cA z^z=TE-rK^{pC)^gtd4;5QTtESBRPkX_q|GcF)&syh!8%UPJ@B z9v@^-XJ51P4NZf}zjcd#yQ(KG(o7Wa*^Iz; zI`w9KZxP`i#9kKh|H}rZ!?_iJKl-g-5dE%*@Lh4s9!WOpgWpm99yeTU3yiv-c9&3$ zpltodKr1Nn+?}tTW#kGxQK*wfpzy^X^QHieIrHN6vHA^jgbE7!J7=d|jrE+p!GsQs z$|jP*@zG#yo)Sy-DAfsX5w9h*1vz;;7hfM8d^`%|%fu&J&Bq`|dwa&z&Pc#^!&C%v zoMgOx@2^>j{G~E{7UhwLyuRFhGj2$EIjGDiYv)6zt6rT&p*3S(z6mI6@1E(GH`@Sy z$dS|WCi5jTihBc`z9B|-%CnH|_a`=UPGZzHQaIR*pymXwZj5gx$%g_P;l2_b&Ow^08WKcwqcyI+*Z~(qe@Mt6;*Cg(vfVN6DO$ zEIM%Fd-O_d5D$W^dhfuY_N5c|AJAH!LN@2-tg{{0okQf0(Cbs zanOxD+BGZInB5Fx|Jp+Ed5&iVwREJo{qc2wgjc83V&toWwEG(H0~c50v`mL%sSKPX?_5cFq!!(++slTq*gWYLYEegyFX;vU^>eeYg#Kj-zb zFo|g5cG!u#F|-oQJd0f?q=}{qr|y5*Xc)j`UYrf8Qrn3q{*eH#uOZ950XLUcBoQ6b z8EPcJ9vPz;kz`-m6RhrNXOQCcAeq6wGPE!FG=C)1*&~KDuDD83C6;iq>h0g$o(?gL z9&J3w@X&>$2*?EATfUnQNS|i@ti=Q2`Rc|7zx1utIMK*E#b-3&o;X;J7s| zoJ;xRWkFsnj>yKNswAy%bclus`IBOQAaPsM{9M?mnjdT>En& zKyBHc#X!v^%`t{lC^xr5>$Hh7DfY&XtW0QOLww(VZX_RL{KN%#o(LhQR>HuPALGwj+;$zFLYmVj6 z1X9Hx2cb75rExY{rXu|bU*Pi+!(snEvG{m5muhzy`=>|yx73h?u5dP|n(r1DN$OX% zt^}+8NG5VXZwEuooo)YfWAzyzLkeawE#6=NWFiK|g&WrU&>1--DXe``G3pquUfF`r zfh~7dv_jkW{ebsFxBU5CIqje%GU^ODs^^Va0BXHZ$m{L(M8)(%%Ud{is7qhA$%p!1 zgNlwte?5ig^Y*kGH$ILp3w`#xmFVa`t@Y)Iof{z@yAss3S*BS|Z5*xoVXkHKUmZLvRF>hQx^V2O?@$iKb!%$<} zfn_o-HR+S6F_L*up=Yy#Y`C-C3n1_c=JCn8yFmG=HxY6}OanLRhpW~{VOs#=^-^aJt# z0zXv3ie;3Furnv6MjqP`Wrg{F{}FS*M^;(qni);pSB|JWp0|{lg{5IO)B!)}dHKjD zxQaeYxSFiscPW|syd}!1P4Tq%np3Z}tO`yqVNMbG#u6ER%@{pMUa`aE$YOjcVOu1_ zB>I==(sIik1+sPRd_dQ8X!!~dTGmap*Lk~H#M?CYR@Vpii~KQn*j1l2BP#cnQey8N zrN~mUr8-H3waB${-_`qwnc^n`D?1RiZMepg58>exSY$8j39 z$i^%ef?7}oF~LZ%Tzg{eOq==+{R=DY*ZPllM#8+M{EMaA<;g&MKDt^OEsfBfSqv~@q&Wpxi*lSp zbc@m)mS?Z!v^#DVBykn@xfST{MZVh(%ykdi-4(jzJR(Hkab2#=y*_EyTcI3|>x@^V z*-LZU&(?fJ@PTuX+yL^&Odc4{2UJQ@t)K=xKhzY$ffuNHkW7^4qfLdb5aV7Uj zA0`SDJvhqMCtq^qw;a{Tk*JiocadgEUnXBG0_(&06iAl>u(h6~g@S(%1%e-ityQse zRuUP9RQ$Br!2$O$N@@QIU-DI|89%ACX4!ScH4RTr2B+4ls+Ezl>W1e^YLxSd(VBNg#IRju0=_lhME)tQ- zX#s(%uv?DAA4_YJRCNp?Lrxm(FNVR`15WO70?RmGh*-~n2m?T>Qb=GsWkqv66wwLJ zKJ2kf0nP@uZ~8E#^M~-holH`{#dK#dWm=>u-X3PIPJF-LL0HpiWmOGtDPFK-4N1ot zbUmSOErv5#NmavxrPU4ZGPtoK9*g}C)`1o>i%|Xn@{7o{k+4IdgGe&AQdZeoL<@Wr z&olFmGes=J`{x~h${sL~w}MEvJ&`sf91P8k$E53RfwKre&mSSFeRh6AU88F&vit$)*H(h9i22w3?8Bxx3w z+B?7m{CaT5hVrE0C*;qPQ2clCX#5!}&}P@j6rjlL15}^F4mO+Dd0hJg{2303n1IMk z%$Rb;`t{i>5acNXu*J&P%+DnijYtC=a&#zZssE+$a2*_rDn&F630#OXL*kFMbLK4e zF4Rbt>P4h~!U-HN%BM@sSP;YrmY9ft^eJFN0ZQr<;__d^!Qcl;YII^IJG$$H1Zx`n zC0&nyMAoCHl~yhNURmw(!J}dQzh6y(Ig1w`Dp^o}&-UF7RKHMUQzl6Q&Mn*2>7i%SnVufwX9S>E zbE3{9t`Y68>5M0GPha1Ig{X;MZ9*q1(nc5MXGCCq7@q>76zJRD{DzRKJ{S%L&Z0aV zPQH=kF)Zgkq|?RyoiRQ38I|j2K*jyj$*ks2rjL`~jg23UmoIF%zocv58{vd>1$;1` zr8Q8*=ACLBdbVWNK`3XP!lVbtB#;U|->;SIjsFXprAI^6E3K$GM*Wea~#831?#U5E!FwRl42mi zw8sNc&oXQ2M@r_x0di)lgcgYhO=>`BK%!jNTN3B|qfdcMDbUlp;cA7m`>}afr5oE% z(ii2{$Kg;k9=mOLIDTeHWy5VVPy;dfAuJf@iKrS!Ha}hGGQWRZOjBGK=xixP zZm~L85K=(B`Vu)Dg5y{}Z{8_;UVX*Azk;${hL5mFgfX#R4wLUN!l8cmFz|bttQ5ck z$Pga(SuLRc0z1LfbiMp7zZ6*^kMqArVzD>E#{Zf5iY^7xDZCDWbw|(@TX9t%tP!21CJXrzzn-4%TM_KhGF|pfRluHumFbLUxT78RX)5 zSs6NzmNae}krTlAm5DGL30y{`a{g%IZ!eBe{Z~M85_Et>H8NT1xNF`Ye5uNdhcSuz zBqt(l{8`d z<_lp8v6c*4fEIg>A!TpwU1X}fM#LA6@DFNQ5XY=AFrG00$R_%FnB>DWA-iU&|0U^> z;<}ZG?O?~e&g4^&yIg3arTeLzC{$z-uCR4u_UF<`lgqWjUE#e3skW_VtHsqA!kzno zlZ65tX}2q8skftS?{(yRBcgpenl_{zBL*NEi~mOF#>%p)rFYXJd^DW@w|5knzwo3N zix(`t(v_4o%xV$)KMg|aIxZQMUes?s1=6Cx zAfj#t^u0evzW8fmaVnkbe9xwh2DkOXj{06yS-YCDvZaNLe^bU3Af^l11@Ak2DDrv~ zO!_hQe?vr#Jl??M7XS&m1giAk^|m#=XFRxkJ3-#jX+Kj+_oB?(Ag8+regf-4h02f9K2dG1&%D?|)=VY%O3#X{rCa zyIW?${(qcku6-hyl`Kf+&sJ1apsu)g4Gw@6qKhy|YBJY+2Ee<=aZLA2PF`kU>MyW? zzk{Sb(f2Mv6jH5{jTLIv2lbfo#@IbilENI??mzfkk1RW9VfF{%9VEgX6rhamHS09;?iD#Xn-jS zmsfg?nX^d;F%kT|RRr$7_ zj;0&a=ptvgU^)FE!u^D_0N58GRIyL@^)(mf4B_N~gZlFjiiOMoq|wIaeDUhlt3BDH z6KHwTIebsggAp+?M#>{?#z@DLw)!8;#)*)6f}*xgQXe7V!hXf%%_$Mf@M5tNoeit$ zR1ryxaN-j0Q#tb;05hcfWV^kkW$hI6aQrUhz*v>e=1(bU!KC)UTj1>_H4Xn$TU#5Q zY&4um^Q~N5-#KUg(yOBJ==E4M4nfLI3w=vqf`v#^)Nk~)ZTwPNo#fyqVotF)ifIAh zfDZaJ$cLr3<^a{#3|Ur&L|I&VO2`a=+5m7?TYl}kE~$P^M=qXlodH1gy8^fXoGP** zle3W?i|>lXjI`AMqjWe~WRp$MY}Ee0ZAd+|D{;WcRm5M7 z7&I{FGr6jr;w6n;X8@!jcD$mn8X@&RqBY&oqcFk2Uk>3zcH8`C`Bm4V0U#B~M$`S?$T z{XZd3HN#WNsoY7(%6fazxs@s;bI);VmcQ`LNznpa@YK zc#fB&7}Iy@+0s9!jn+hpLehfT7yCM!zv%S-T)=Zv6V?J)d=ZyEP1@;P;F>(Tt*e6M+0JH}`gI z{J6v0vkqQXTlW&g$NNNT1W@Dygwo~8u=3`tBRbZAjQyR2U%N($gOK2^Dvr#6cChJQ zca;G+fRH>mvn>@-|Bt++{H2IGy**vz!9`TVju{YT0BB5bx;s%MqdV;}1#jfCJX_!y zTfJGi0!$NeqJHx!keUL!TAMCYgX*^lO7y)aSW-EHS&k#b@4B+;hMS;svR{GdM!!uM z3c!i;yU4uye#l>Y({T>8-Y1R_bXECIPwU2a<@>}@P4UO@VL5{G)+tdXCYj752+RIt zclWw*O5iM9gqHlNSTugvPFq8GLyN3&LwdbeXr-o{!%>HIeG$A$D?}IoD;8hkPghk{ zrEiPp@(E5PAS*e9BzF~8y}aAu)Et9zlg|{z&4Oc+&j2{4^5nV!>e*9~+yCcGVquYQ zEI}R1-pHh3sEI#Ty0HESiSz#P*rPys&9dLd4gO|S(CrqHj*q4Qy;pd@;D4sx(bv8) zuQLYDt!-$6K!+Lu_K^^j7XWJalLFC#3yz+1w*Q_L4|PCLZV@p9SYhOxor^uf)xA0~ zXSX{&MGI&`C3#2e_JM~Jh@^8gnOP^g$x{C#PBOfWu~kMo<2kwqmALSbg>IoaFiG_p z03m8kP9Q4ef{*ncsQzaorKm6wMxBk0Kn?n#@`cNOCVZn0IAJMJUcK}QZYq~z)9WDT zK&Oq~F9_9BVGj5uF`Z8D&n7&wr~U|85``)SqBaeKi{r-{E=q-~3*f+u*MRZbWHOV!S$tRy+X zqn#qS|66uk@ZEk6fOOvANpQ~qV*TOX^)tl!*E(_f8_{@4!=|o(7?R<=lYmtI6h|pg zTvfNx)cK|HSbS?bOaR%<@%|u}3BRYev#By)3A=Us!KdNZ^{9xpgK7l8FYByb?VDbg z>-lhlJq#Pat%OJ#8ANEzfs^%sR#^B9aK-Ycv2z+M4-N0R^5?yEPiE) zS`0P&Y|&i%gcPY$OqLx60E11neY&HR0uy60H9llZI3HlrQGoN+vJon}1->qCq!hoR z4bl!YUeYiY@@q@{&0myk#}phT&53jAGB|yV;D)Fdo|sR<~IUyFNE180Jiz`ekuIj$SsB4z;c6=0Hbra-rjL0n@1^aFA= zobu*VIEh^&RXlQ8Je`RuK4!2IsB>nDIzmzWKS~ZC*0|JVho39g8G{DEo&~U+Q#g+( zaqcXh%M~R3PV$`seI1v67=A$4Qk`?rW_kNdGW@~xPn9iPcDLw#KHRZKfw|T7TbZt3 z4(WF@4Ev5JNK7YkO{|Q`f$y$eyEdn)^YM@`t)B9zq3chJ)Ciy=S#WZ^0A-O^jMrZ_ z&X-g#>4$b@18M2(8U$UEUQg$IjXkhkXGdLkjRAlVyEGWkLLwV7g*Si&`1et`IQo+r zW+~+xKu&+5`xF_pgZICt%2~0s1FN9@_CM;Ez}T=Zv$)Vie4t4~fn9A)XQ-<7Ez(?x zn!1Rg1hdOyhH+zQwQuvEG%rhvi54$h(Z)>kE%2Cc6;YKR-4$~0O94%N|M3foK7aJJ zeE6HO+9(9dJ4L<`+MmEs7s90Wk(}c$v&Thnp#2iUZtecA+6obPCorxt0ANzUq1X>i z91kRuKHX4WvWHFyQAru}1ctOaA(beyD)dgDza%TRb{P>IDX|~Di1nJdJ3Sas>}@9^ z9r`fG1O*1WUMfRI%{%dqPZQCq(WGhfCzT>XjJ{P?UH_eI5dsUZYwJk1aGq@A6xS?k zMFq4g;d|vcI)FA-atu^d?aO@~&6ngO?IOyNzd^d+CQ>e$A_5Q-`{wTUrrMm&B`!UK zx->d{x2YQW0l;^m7)Fk|RB-8BbLGS3e7%NuWEn{+c8%=+@POP_UUOoGiEr3^Q7?N7 zClDEg?#4e+p22P^DX$~NF&F@mp$IR@7&;=tKBXMezj%yLfE-+PGy45-D0Bj;EJYeE zAtc?!_4qHIY-;)>dT{%uYkFHZ%t^Oj z|2}UiP*T0TSvSphU^5>Ou{|eaLy|MR)`N(qzhj{7g@wu2^5OS_1!wKUa(l0cN}V>; za40wv3-mj3iMw79XhUAQF4`0A8qF&%G1vV7TwwqPb_7T@u|P;t!ET2VjW1v$&Pd|L zi&)`g%OpFB$&C&>Lc4VxY=)2qfW|}mZM>7@#Ns!f0+KXuhH`CC3tZ{M=??`lrARdT zpgg2~YSk)l!d!_l>F?P1uAnh>9m0733ES`&f|_<2Y_D(B0)clywfa+}lYM+(NAp?! zbIeZ)l-D#o348f_A=Mug*({T1L6S4xvw5g%e@EVw*iAxER^{K|8`vw8w%4Xnlmi%= zckb)#EV2Rh?DD*M$M1pO?nPOE0iftQ`1YLUwzkclQUcTGIMCM`VObL^Gk;cV6oyq4s{cV??YIN`sqhM_tsJocu^Qv7yzcQ zdQ~9rJ-uv*OFaaJA){D)DuarQ4l_k^4kQR+F&?J8Gx4|AM=ae!*;+=Hc$5RODgt?p zmEV6pQ-Guqrm1&e6P+en;L5W3$Kw6*I5vNe3j6f5y>LOWAoMVp)bq&}Op@Su%n3y+ zgbRWtOqD-FOhP`>dotrzTGQ|gL`D6th|Tx7<0YH| zQ@?uj{*FMHA0QKUQWshM35VTv_@?yK*rKGd>g_ZQ?5m0aeG5h0^C0y zlN5-W`&UD)zFaif4>8pE?jjQ7ADy@8WKT-^_O-omj;3n&fwMz@-a! z?AV+$_3APCg19`XU3>Mffnoa%a!fetnKZqSS2Sp@zqhAtGns$$rgiT&*Q1KwlcFJk zNL_zwUsv#qTup%MMmsk59nkuvM1j6Le23UV+0vAN4licR0PxnOKSD;q%D% zt|{+2zHh|)izWLZrj{aGxUSz^QrU377xUZMy5TfM4g3Z+%9)nuwBR7tcgfh)ux`v2 zyu}C00Ro+2M1Hr(g z6=0_Ef#g22`Ma!k`5?5wtyYwp&A_DbHcZ3S)XW((00#oYbAea6kOLrj#mmz3HuJQ- z%v60EL=0gDfEW*K$OojjnGxe*i&zo>Xc8BZ|H%XZIF*Tbt|ayE|2%dmKp}tF&%XyL zTN5>Fk=-AQ@R~V0_IVQS*RyT&@entDuc&gFlgV1L(^s??C) zT}5Ep29e%L$j01G5P2{N)Djf0EuX(Y~_f@*>x z5iXB9r7VE3_#89}Vp(GVFk)290$>F*1|eys)&U7q#|NB96u>*obRPS9C=^;Ky3ta& zA5^!S;Z6GqHj)a})hYLE-8x%V&3jO3dp_BFowkt>GroEDg8r~=e-R0FB-=W|ZD&V#?DC{8vicL*^Phvj zXxj)^2)^?NcS`eZfdv6FW&n@^YPMbpQC=$n16(V^*H_qc8h^=OG2kYgYUT zIVWy)ss$jYP`oQ8s>E-jFwwl3nk@Y$7PEH-Bz#9Q)0*$>?CcEX1b;=@0!I6g9rHy( zHj3@ef)jm>TX<&70H7k@QoGHckfntS+)KA{KUH-Helu(Ob5qV#p76}O?CPtCfrD5}6PMiVdaB(-%)OOk>1R@!m1Zg<_oR~b@KY3pVE;wG76aP+u^BusYy5*%RaKGK zLilI$nF938m=gaaa!T}wsLvB~v6xq^VF=A$P5vy2^SM0M)z#r8X7`G`$Amu|a5b#L z@5%+vrKP1XO|vcJt7g|m=EGf!1h5}51xDMqZ<>L}UM5nKmn_|I0oa_PJMl{lA%No0 zBm9YJ9&mC?rm!ehv`>8hhlaxBJ_C>`(I?RTySthZUZ;LQRLu@&D{QXuHdWTI%cP3t zcqHm;-?T(lweRCP(t11kx~wkY!$6q`pVm#~t#jrt&oIsRcv$-9y{16P{Kj_9<{(Y#3z6p!rZIo_fTiu5zhKM3APq zU*rTl4&NxL)H7}wF#sB8r9h`d!~u}j6nMEcXN)mAfHNfQ<>HGJkqoBxta>DKS>7l5 z0a;FpjOPXC?8fu)`$$cJEnA+Pip0;iAn+p~ve{A?D?*!Mc#p52Q`wlY3GHa?T3TAB zz_9QWFc~GJ4t3f{WgLq|*P;~B73DQ`JDlF{!{-+TN-LNC)`;u(P^5{IMj@;St9xG` zm|G|Jc?DZ>T*EHxJCBL@HIRcH=35ev>TmK2pH%(olD=7l?;c4I#>;niHnn-?UR9g2S`5DJ`5@2k^BNn|3? z*rR$hzN)yYuFvWHK74*s0ABQ;k49o!;ovQdAZdbCSq@32^v(RK6=1H33o^BYKT_^q z<(70UOgI24rre1vxrgn1+i0(asQ`?+JkLr0YnpulU3SN7 z*)+or6aXWjoM$Zpz#=x2ccZ}1FdV$bqx~!AM|=gCuK{?4BFF9z%8`%omCuPTN|e!T zI2viziV9bIK@}ADIZsxVPf=nQr;Rp$WUzdCm_^@Jeq7^jr}z8t`AY#SlunX9Ynp~_ zx!UAeQ1!}SP&LdyzPMr=7Wi z7=R!=E*X~>x`Ctp?v!E_l)POmqKKy~N6-TF7VI8!XOUpcVaz;~r%ZM(-lUkj-6c70 z?!lmkM9ct|T8BxqTPdre-;P-dfKisQz}$?iYtu(!dQ(r8G(&pah(J4Z-K<%)t~;@I zcel*M_xpW(pCLOf-qDznLn1T=n-M?$_9~BR5F9=I&k`#!(0r;{Mz_fiyI%8#WPtk&<`%G@`w4CMbI-*Ti z=B`8nWW)fdGOHG`#g9tCT?}a&?dB$O9WR$-2MD~Fltm$HO_2`mW-=FI0IbEAOT|;9 zL&$LGw_}w81MM3xg}3pWMOE$O_=orQhh>!wYrUX}8js%yDSol2DI$Rbsf8i=?=YBr zReAOD?TPdL@z|pPmd%?G61r1l15M1HBxyJVEH}jSz-SX^SnOAOQMO1crT8Z!(8fK8tP6 z;xiAKCfje-A+Q;DR2rO%a*wlYK>f=TlmdJ9v`&K~%FT%P5Q_OH)jyo7HmOFz*S(l> zZ)?-pko-UHbn~}NekMN%JMa6-E0?_N1#W(y`9p#7n#LUnQC>|OlZZc40&@x_MYh(D z^tNuSOPtHqQ4ht|L&(}J;?F?L3*PZMb&h&+w(*rEa}#;_+cto}TVVlTyVjv5{Ixx& z%>Wcnk>>!}SwgBqAS^T9_S)goJ%jv%4x1s%0+e;wsSP+D(sq$5zoa(9OBZCS0*#8_ zjztQ@4jjG)s??=qBeiQ}HZpWG##HTdvufQ}{qJdgW}2+B+f-Qz3Vpw$2$O>n{^{^v z_%v+9kJ-(`zkX~`pk#siYdrG@DFwA?BQpTZL`6)Iz94#!S8(O!jYAMYA0|_@U8A)a zj6#4bfh+7_-M(&0$~FB{kG zERc?+c{pAbe)#fa6vXCqNpT z=maI(VlVLU`^+r`WTxH#TYISpcTe8RNT{~zT%9YzRln48VmhY;3i zuQjLI01C9R5&2M7%>Xd_?dFX2+c5xm4iaDsG4YI@EYoKIxH-=bQF<~X8=Eu`jRp4z zJt+YoxIhSTa{uiLTdT?KpZtGsaOD^5|Jx(ch#|rRkm?E4s>Lzuc)BuKzmqX3(9_zy z9A+RYw>swaF;5!&8}mKLrpNhO6^#2gpvpxQf;r~UW1UPeB}|t;>P9N>vBvPvzWs$cE)4B=kp~o5vt|vxW%J9Bnrt;?gf6N6VPJo( zq{g`03-tRsr(doGv`a*z5C{j0c}1C6EQ%j1S+umn3%vY3Ck6#5;sa~%cb!B8pm3F> zsY*zf%p33yY%CJ3}$NMNLIQ<7>keRI-g01yE% zpJZ0MW_iJLa{!ZaD7B~Tc}O(RKJ`N0tH2{`4>EyyG%$N7UdnakM>>Bw2 zpgP|ob%wg!4%W@<*i*N`Tq4jVT+Ahw@m`TO0|2Krq$0FObOeI!5SWoL2pFXyVc=0f zZWvRX-jgYOMMVX&5Av-d8UPxLUQ945D>5C|zd!dBup|e@Zo;z{4*P>`?`N|s>Yw!j z`?#!pI2a6_XcavX9DqU*Bazs%ht23W1aI-dj&TaqG&UZ+jK|!Dp+~)0mz7+X)ypG zjsgP&yb$df0Adngvv-vNkY$a^C)$wczn86-Rc6!wdnSv&IDbJT{Dd&FVywB@m__XT zp**4hpTVwzlkCYNarRWKBoc{sFc$q^FVI18mMUmpKSNTDXrp%#SqKlJruxTgYfp7O z3!x|;K3x7$K#pk0uODv`iK|awPv3!}%IEdAZ_MfX26Oa7c8==_oGq;^lP_q^Vun^Y zPVN-J52j`8fO1i5>oZdm_@;AAivc*WW5+a%K9!KLYs4dfQ|Y#Yb9CL*4?0OoK{f#V zSLA36mw@2_);yYB%C^T*FY)^PDWqYGvJWr@1@`PgoV_G}#{TOVxtZYboqWBd`qch0 zy370&=buN4(>Wqh2#LW))THJ&%WIaePn`3QJ_U#Z#dURu4K96GOaL6A8EtBzKroDT z_?lc&(ct`a=#BXWRBRGjVTfU6`PA@D}foA;FcBx zFl_Ffjzw!4Ie6GLdP!idaJ5Pp3L)&X8#np-$cxIdZUP`2G&#@~kHroOy>O7wz}Fqm zqw0cW{Qch(ivqFz;ddcy?gSBaJB3xj@$ae9YVBSxX1NP7*zg#BFUs`_cTYGWUt|#hfUX%Sr3aSzF~edC0nrpcYL{}r6qu9p_}2bg^Yjp%sLG~3s2n8534G_uDrb5 zv9FC|B>fvEBn2qDp25xk6Me5}lH^$*3WsLE)&0G>nsjY#ZPes!o#mV^fCSAC-}89_ zpss)%h)t!va-VFIwUC$-m|(&&lV_AZJ}nIZGmUA8r!25G))dpG#RM+L2T<(zPzo^T zmt^HI)R|Z~#ry!sRDsQPhOT#KWG``!s3bMwLt)%(5{;du2M@DBPQ7K*nWulx2nCc_ zrPN`LO)40F5s^JkY=r=R0UkdN*bE8r5q(3ClVWIxs?D@-{_W~!s&tr!I zN}=*syywkMd;vzZV)E7f9h*yXMF|9^KP;M+lgwt2$d_lUvSA&}E7&wq!O(thBi}OF zlrt(LpB4jP8f*rn3heEvA0chf`0s!Tn@eg)XID*T`2ZXiArkeBGK+L5nrZ}dVg>-2 zo)D=CKuB6IoXJn3eC_nN-zE+P247lV2FILFknPZ}5pxVR`@tYrzK-w2=1a2c?X}kj zT3V)HA-R#X45a=NX|#~T>wj~I@$ZVj`!JK60;Q$(M<81L%zDKHNkRTlV2~>e)3{vl z7T3WT(>9rgu~S4SqxBeh4CW#rw!w8o#_zBXJLmXU6IbwAg9pec9dKF<0Ho>JB5TU% z27O2mMGhx$cH^kYjO-+bMK@Ja;{cZ;NekzM0YDbw4a39HsEEdbJ`g0VSgdP-oLA9u z<5^9nKJ=jc5n9~Dq<=`-CckTD&C<nI9A=41|6UGjE58O+A6}$a*ZjoAdSJ4g`stT{sq3)%9WTY!(d%R5cC4u6d3N zLko2QabOpD&WSYaff*G9N{azNHpQ8O$RSyQbY$MV(;_19qA*54IRFCLdP*kxBilL> zvZz?(C~h+AP1A>8#`6^7I4wVbfGV4YoYnZ#tP|xJ8?dvpsS@=jJ}R=|(!ztHI^CMa zzvBfN`r4i<#`5tw(?JDna{NPapx>4+T>gODI8h4X!R?zb?(b;+PG9H7Ukr3^{=%*; z&mZUZIDUWGr@*v%$0Nl%|CNX|oxqO15hU4Z?Cq3KOPtI05nIA@qGLi^f#q9XNj^2A z_hbSGW@ER={lp?bw#_JWY+4Ke<4nl$z*1vyhLAmXRh*=Hf`a4#7F-cJSulhPm-I^E z8(ajOg*a-MLWKPQMiIsfwC^BSMyQ=-Zc0f9S}YC8{NWrA^8M4m zOfLK|q7bS?!%qpHXe>6UXy(`4{#N_9ZK@vVn)z3t^|x?1bVDc@dS56U_-3#m{P5*D+g6K><=Q?phI`WWr)Y-znfH@yacu=7P#sPGtFqVd{^F`Ygne zGy|@H%ZilasC>-Y&zGsW5dO_`AHE? zM52GvNZ_0Pc5%guw!}F%jt9D1W=fj*$8ackA$cti4G+V|AR-M74TD^)6iJ4DMx~P7 z7|ZV?cN8eEK4Cj_ zGAO#gB1S<-7#|OBaS=MH2T0#VB928g2}l%slWukGC)6a}LXHxnBLWY?CgbW!=}tgq zeBE#)a@a1ullr#`Xauw=kjO4fcRc^T>`_4B@`a&bU?y#HA`R-q+p3m%V`mSl`gKV9 zGh0LxLAY9uf6-Wz8VmkJ1a~JeL$Ua$fJ?*BaD<|8UUe&l@#0#UB5S@D;8m2Fn17fu zzZ;vnF1jX@a~(=#m&h!5NiJqwUUOnE_OPuYegK3Y?H{IYo|z3?5kkC2WG$g@3VQ7+ zt^fE(FkQUTVgMl33x?BK;dh>-5k*}6_K{6pZ0aKm5U*IjJ~xghCDn&s#tXMagyWsVw3`h^{LO8y78PAurIWe zAR29Qu;Q-be2tPgm#rh-eI4IxlPJStT`XSPTTs;~YVMG&$ruVp=+#0YIhb~W zJI4$FEi`z_ao(Wuc(%jm}Z!PkWh~0#04Q02m-Oro@fBLs`Y^-v(1^DK#?|7 zO;LhOQFE4udgy@kJo3JG34b6UAbcGz@6168;{@IOV`>V3nWMTK&!vikrVu+gzjY~^ za{bDc&;+^M$V_B#_9aC@0$+jel7=VgABg{=q>3Qhl z^r>MK?%?=m)9|fBi55Vjz3gcK!n0-tXi!o0yKG7*-RpiUtCsp;K-w*mV?Qyv6$!xE z(!}4{K9b`XLKh#T@~+22+bVO-q&CrV?c@g_adxuT zxJ5Ni8pVj6rkDh5w4fRsdmps*p5Y@(4!^e<+d zUsPVx@Z|VCq<^KQWi9-1_>FkCQ`;bth8Xz{%vqPKApYqIN`WC}u7kRKi>8Vt`Li^$loNnSIWYrZZQUrUHM2BYOseXp z@Kz7ozqI|YP;Ff0I2apc(SNkEYzT)P-WONJZem88IA838o&yT;!-Wu zYZtn~10Z!;;nT1McZ!+;0E_iqdeiRijmSssw|u2QadrKFq_B1->Q206I5PaD;YjQT ze4wu@sj9!cbYcCAUg146RP^p(Kod^+OPen=OV37F(OquiG`OJYa%9|0sfi%w17@%| z>au^45_-9fgWq3nDNs|>cmUGb-xXEdx@2v`eCq;Wwkd z+sEqDKbIdAm@~%;rTbI1uo>)nsmw5=pQrCkq|xSUp!_gW1h1}ISmi2X&bzzAeX%`X zP~o|x^YMKO$@c(RS*=YiZQGhdVI5HGl1VlEtj?8F>z=ij9Y>a|_DTg4^T7sk; zFGvZP;-Wa4HrffC`bhmxW`LB<;`9;rgvG{nr>DfgCS$Egt`noNL>e6GBxolK7}v9u z(P0tSzL7GBizMXC=HdB`jQHRgTg3s?Nh zZF2R;_k1uI)Rt3*xA7W%r%1mV=Zt@kvDIQ+%Xppn=kt~VhoouE;7OYU$a*Q#XkEs} z3RN~gPXyjg7|qoCvA%Z04?(zFh`-B)fzt*=tsB965owpU2EZ``pcq`m<}aiH0RARC z?>+m_;X&MmF@(cTh@KSwgk&ja(C=Ke!+PH{2tV1B%5yv!H(FG|={1;B`1#4_{oj+9 z0s~zemtjM_M%3YoBn9{*?2d8%Rkx{juxI^j^m!d8C#e+8GC%10!=;sNp7=wg7}~#- zMG90^k#@;^OmfH5o;`6_=k%vE=|uw*uy}9Ji)G1l%=T}cv%f~7(S4#JC=^x&j(X+5_UR{O8&`k4 z{G!0?8fz{J1k_7Jlm5W$1-pB!CCK$3xIGi9_J_#-kEYuEX-ty-;r#dTOC5V2cN+)4 zzx<;>&=_upxo4}UiK*6!kqw1Ig&NC#iD2v>TSOfJM@wv%9MWU+y`;Lrq`0W$%()>J zaxn(NLK}JVeeg;r9l9-BM*x7r=1&m~UN%#>t}|glswPO&#j%K<({MnN#PHHRM#We} z8tGDCV-0Ywd|U+HhdDMWKz<;22Ug3-u}Ru2yGEZX-ZO*>OJAEw9@=g<&HNnfw@7&Bsch&=z)~&R@iZsFz z%xs~`%?c6tbYVDC#54dHfblG90N~_Hap|@UK)MrmdLIP(m*7==glzkwZRDk7=ZM04 z2dDfJp@~8{0SE_@_>i{p@}BK4tndyezvq0W0Q^8c90~Ci=#jdvVL$GZBas8p<>Sb%)7}xJ(Vo-p~>>qZTq2!rZOeE)x-7V{8CZG5I zx4d0PY^;D>YoS6WXH@h&&QrX&Db4`M@X1TFiSL?813>byWdMku0#hXi0zq5Yq^=$R z48#L%&>rm+Ri)Da#O(p8Q+Z2E!0~`C-k=8#J%-%b&7zqB32%f^6);2nl#6KiT}==Q z40JcuDvbTlsJ+w{Yg(wl93$U9|B~Bwcd7{@Dbkm)xu+~XM2jkH|GKJZ-@)(k)daS) z@c||p1r!$AfK9z$bZsUE0Bd2n8VsP8ME06)4(-E+FrYxR6=|eU0|#(|2yD7ASnGuu z08|AOQzMUojAI6Xt5AZ7=pHc`=qV2mN5qUkV<0DTs<^HWm6P~p8TuyCM)CJpjF$9- z^(xVOynq|23dinpqP-w#3oq&wGG zlziTe--Fw)xB$5)#`XJ{I24#&v8f+>#YTr&0W~fwr)P7P za^d{F(CiP$f}wyUh`}Fgp_e#9(~fvrF|&kaY)=yz#|(f54gedw5GN1xVXAbXn@@KV zioBK#6QYeAPEfacs>XO$U$n9ClbQNQsP=b?mI)@@p#^~Vw{*i;{rTsPO$zjOTynXl z1XhVU{?XZ zNb7`E`AOO^MH^`W7-w$~g?9t4yu6%BQum8)RhF-U#7p+HdYDlbMYXW83r$aEBPj}n zfR6%l&H#+09kaIqj0}Wr*?WHhyk9RM#yRSQ37{BNtVlVJt1qcu(hod-FX{|PWVL(& zQ2KSCqxr0(o~QrkF-U>_j?E`jw%p5mtDb zko0dF_mxzxc-Czk{Qf2e1t7^j3O@kkY!lneXnR2-yu~}VY@lqdS!=*bl@V>hFa&i-VN?p%3!MBu^M?W^*S``B zg%*lBR0UC$FdjGlFsJITuBRpLZQU>jjK~-4)^n1k$T^pB1-5@-U1t6e-N(cg3dD{a z-ipw<4$)`;inCW0bxuI#i(NrLuoJwaJM0o!CAuOF073_6a5d`P-oeEg0AltNxH&yK zW&kuK_r;D59>9KIr-v>_V`+ORicW^|>jU;V(U*++rSz$AIGK;S2A-F9w42B5s-;`- z^4&*8u|yk5U$DWR+q+%8So9trZsJj(uVcfdh~xhtHd7(wDPdGJ8tIK0@h^IkZdaCX zK+>OTt4LJ}jL7znrQ+wMHA^?A0P`;$;}ozoP>j7u@1;m1;Rf%HYBGiD0%Wa0s@+M> zZ6b|u#5bTAIxiK0PX`9+vW2Bt6xPCioL+Ox00b17yc2~u4w3@kE|GSrLMKx~+2Mx- zc#>CjNiU_r_@%EpnWI18Abkh40Eb2GQ1lYQ8IXd(zc?_^G9^c7`@?!jfqk7#MfiSh zfhrC4H|!UuSJbSg`PBuBPU>(QwZ8WCOJLLc2<-YPyCy~)?{YsmJ49&4>ARkjv0W%aVtX+VkT4c zntX}zv?~=QcVqWk;qvp5NCe3z#HTb8PQ}jNzkSmTyZ*htUQ)ec6KW^^F$l>?v{6I= zxaAebk=V7O_xNxVhXOHEc@y4;#daHRVn+1tz_j(Z?tSK6iF0lp!GOT*seT3Kh3Qs0 z)D#_4SSuC$DX(6>Jq4J5X|hm2m!1}>e3~E!y++Wd4k)cvDlEP7JyJ8CI()!pB-%9xX44){H}9B0foO_)Gx$R z{m0_HvZh_%*w~o1L@?eo`#YN7q-x4#ct6JjvCfk->u@x>H7v__rUdjaP8tfRLB17I z{h(;g7E4>eq;ey|v$m!M4D&EI%tNne(QKX$MZ$wgnQH@<{$-PiYtA_vPIn;33_wts z7K8VkkOsiwU`#118UR}Kz?eKJ`jRCG20J<`7MO2I{GH2VdCijbNHXzzD%&dBNL_|h ztg}(!U^Pi;qWAf5`A-4(g_Mm1-K55KbkTw((EL(yMO|BJXy;e^`W`HVk@03Y*`^Hp zBcXsKBP=RkFIup4pYxIYJB~#PboJ{4Fde-t>IXm!0Br1qvMJSOiylKUegr%Ee&I+m zSmf}*m3xIBqOY$uJA#z6J*d34>Z(Pv66+H?Yc0rlBCGK7$M8Sr z>O|iuWU|wu<56#)_B;4q2TM+_(ZxmBeoRbeIXxd^gY9H|qWB>uNzSQBORM*3(i1i& zAO0t)J2r-lU)&-L|Xkxqgw0thtcqlPz4(!w0*{!kACi zWdDxXl94lhh1AKY01YGXLT|VK;|P3N0U+lpY;33ZS&*7UJlY4(2%+yq`wCI*z=5BN zaWO&<1WrPRj#K&m!^9--r7&2p6~Y5QEc3m_*?q7I^?+}D>@e8ayyLs1%RcTAsMbc_ zU;aY?825cUnz029 z5bLtB$zY)l%Uafe-vn6bNWX^xF~wD2zwJ8^Sq{bI^K;k`c12yy-h+s(IB3mR*x_Yz zmKpJw6~`TLQh_e`EW%%OwEZ=m)L)k6nr}08^|bxJzSE!F?Rz(FhEQ*hgh`cDkBCB| z{f!hzS*B6XGbbf(go)X8#v1u5?1hmFontH^+jRSZqc|Mp(pugaJ+1Qdtm}w z#|jQW0OgbM?Z)OzBu~S)5xuUwh!V3Dm{6d7%V8o33QRtTK($UF&|4mCKclWjI zQmEfWD>-;u3CA;TjO5yz$I9a!h2_<{;x<0bm~AiE*5Qp4;%d)V&&^^a2jDjGsoy>`~FrHUT_OoOqsPxrEiRu?*e1_ zaOMD7o?GHwQfxvWEqK1=MI#2A3-_HpQ6j*xiYK8zVRa`PxDdlbdcX+IF@ui(0PnnX zj#?i2it*OZSN;9uw*LP3^*q#%fp$p*o!8zib7a4Ge;rfeeTG4I$P1QKBJ{INEge(& z`VgJLA|fy{)FyngQvk(h)j0!u_EOyQpS*QADv1UZ-#-_xo-1KC`+yy$c^4eE+x| zPQL!yVZbD89PBU|mYjzKRh--wj9Q8`)jJ6S`%Ol;%Y$R@@r6*aX_mV!3S?;x!s;1M zi1sb3QvWzIXfQPViPtRYeYI=s7u4#tO23;-{>Cbmwfb^mthf}L9nsv4;?>AY7N&yk z1!Rs9X1**=g}aiSpAw|+Mk{*i7v!MZ%dRUxfTA06Ok+RenPzo}4|matf80rSUQbfk z2T1nzC8L64(CxBmc|QLrXq!5~x#%kLWve4E@}@y&0)#%0@7J2*L>k@4@n(?9X}dGii@4-0dmj4`W3Tc6eHy z-pSqb5U~Jj=McqN##w*PTWoC5YC6hz$KK}+OBBH;+jG$qt+pD*!oQ!Sa7V?+=~c|6 zVU1MOg+loO(=R3-3)~|ToF~0hMls8NoEaI<8ThcBQ90rSdd1GzPChGN%mqY;nY3tz z-h%c0TP(36dK=A9SQ{^M0s@|qkQFxYga+-uOLVN2&_M~syY7t+8Tc? zNjKt?H@IQ5z8i(_tiO|TEU@BDd`mQu9Er2vheLi^z)zn@u!qtVrdh{MiXEdfO%J;t zVNQ1aU9AzoO&^xb|34Q1BJE1;j3BoE^k2WdtMk&YBe!V^kG$9<{1GVNblF-MsbV;I z*t6C4(}yFy@g6b(f7;3E_DhBDLx$9DWf?~zNV6hqd1OgnSiETc%{NTh<7lCG35Aj9f8S-`t7qU@`XMlJ_N4b3)oMAX>p+u53xLj32!@fV&>#rWb{R^Aq-%`2 zj|JdaXbby6=DdX_Q{3q)x|AJLS63G{zjtGaF|fEEPPcQ*OxhM zrp-q<6PjrzXRxlHA$ZVqkqyYq{9TLp{Y6VP$;y3GI{*S9j23&Mk~bWq9)FKjX~0-5 zpTQ>O_dNipAdZXVCPyHw$ve1sPGzu}-h2Wifp#saO=wjplwgJjjBg}IM{*aUN$$jW zU3$`>_=rpadZI(?s_3f4CQ?5mfYyAM=jk@wT>rw`C!6;OD5c2a6EJHXAM#s-o-P)x zM~^*EP{r}tB~l?-*ME+oz0!Y(znMHF_Prma>zuEg*D|}m&Bo{bJARy1^C!plmVALo zg!>&PaDJ1;_wdOMGpzpHt!4}x7NF>&!3m)8D1`yE0C?be$o5`e^rwv~GGxu)wbMsw zoxZF~Y<9wyY@AdS^Z`_gVqZPN7U;tAy1U~@Fzh22<2k@{l>+3OU~5sR>BrL`13`Fx z7taub4(YPJz!T4mT{HV&w_}ST6wftC=sQlZ^`)0%%#m#jW z)ICW*hwRZjn;iOaqOqjBd9&#qJ@TsHEDB?*B~;fC!5s+is*wwzl34q(l>{}7UBKyB z<(0xDPKP=4|C#aA%#39l(Ty&X2mz3Pgukf7gvgmPw!^n#c`y24Eb}idogcQ}WD;mO zOr%_#>DEAmIX@;m{LY`YU-PWGkR1|RKjt*QbAgBUeXXMZb|vmqAu2lV#=$WO*hWI6 z3D0>mazTw%^E8lmDf07#>!6#!HV43k~J1eHz?|yo|kg^tJMP z!J@ir8ab4bWywO}0*GbWZwS6&A110ZOC|Bal}%kBjxn(b6N^Rw(R0yF%-H{RqiCUDM`&baM-qv!hDz=Z4pGM$#~a)#T+| zNaP=|$Z~{26r#-_T}kpIab)Yu=G_H`Xk}D_dgg%6#9y7t@&#?5)F}~t52S8CsB6TF ze_LX*L+DH_(5Dhz5R$fuGoG?ljC1J+fZU)1ezD#yQ&Yg=t0veCj&a$vVbV3NTd|kB zF1Y82_*!1OfAMiYVGCkKI4A25Lj^~;h|;%?dn}{^GERv;bRC}0hxKI2uG^PNDHIr| zN`trbKEH1yRD7v=<@%2oDr~?LEv+9*6#>c%s45{RP2cqHZ_9JiIK^Hj_Je&zaEX5u zC*02bQbh2%?0Yf@0JBOu$;R~|%bHbBJ`DzfkS`iMLL#-)ohUv@6GX5r=J<{8x3a@` zxYT|jC2=6K{}d2P)?zF~T%@MNjrd`a-uxidTj&`kQ}Gvac9k@)`@!(S7G?fFOl6IXj)+LqxZf8XA#OSggfR}IciSZ$ldxp=4 zVZ@a=GFt!MN0nSoBk7^t)Qh`&wkcz#!io14NEaVp4Q@BKSKV0YZSv*U_%d_K<+|;s zN+pzwVyBc=fDBaz93iwOpkJ?_thdt@EfE&oKV6j2LGWG!ySfc~E?wXy0~WYUsEkP; zC1t-JpSoAQx@7^CXj!Q!;}WTmZ2*>bf#l%9o8-Gdq7hzXz78t&B z0w}d=PE(?7JA7g|x)b9I9ECSpBnbdpmYtO|(bMX4O20JbJaQ8^+Y-lTWNYgH^m;id z5AbV?hu(XiJ~c)n>b6_KKYr~@pTeAdg1Q=ajzHpRl9<|J@!}R zJRNv?dR&BvigGSR{5FhqB(}h{^bB*CMLyJ;$!2DMou&-)fet)Uo4bG+{1Kp&vVlM# z!I-^|2GE>|%{$G4@)5cJ5TxX@7Lxul280m&mW(y55@;kM& zDiZ6rjTt>EQ~VtiF46&9rG`FilpB28kkiC+uCA>q-F=%geXKXq%EfKTP^!IxvkY+I zqQ1X-=V`Q&DZ+fqe9i{gy?&nQtgL!4q^NmFm^P#$T<&Uo2uMxshkal}CHr9-4opf) z+7Kh*hmli*5=HE94qtR)3VZ=<{B@isfNqI#qXudr`#PuzJ7YIv4U=eoY5GK+-P!lg zJ^XJpUxM!GAOF}eQ!$0q5wA151D7)8r3l`Ub^cl4Wa1Ea#4~Ev`uyS-D#&s=G?Cf1 zaboi3Er&6Rq9k=dEdC14h(ik-ErgW7(q}WU+UVKkK@rAVx@d4oNK~;UGP6jZsASQY zbc5aB!D*~UL%{Q`SvkO!7Rr?<@z^-n9(O=?$EuA}4o+;Oi=wP6nodsvfPo`ZDum)u zUZ`7R5p2_HJ_w!IB_3tozK)~O#Ob49FwkErWl$iXKpYE&_Ht^q1+_+Prpx|SkJ}%P zsqYf?Apwus$562p zwhi<>3-`5F#W1D&VEJm&J&~iwL3?}qiqVgwT**-xn+&htSaDzZKElWEP-IRe5xV0( zME&9U^4icY=tfO;S?<8wVD+120_VBk(D=fG5OZJICkR7Jakc}q`%<|Fi)K7k4GkC) zoj&brfxT#m)eY;B=?Vlid%$gOrfI_E7MC8T+Q9+RXDu#?XZ82J3gKEiC@9iC+_g*UQrLY zz)k5+d0VTt@fLN zv$=(GB^WrPkmV~+OROUr9@I=LJ(7U~1Iaa$!NCuYKR!Q2-+eP}!YL9}BcD|J>_loh zWy36)I@a?rZ6-*n^^6##{MC}u{!<`Z363kj2Y3=5hW}SNScd|DAO;T*?6+H%w4nRa z&CgL6+~f$9r{ens#!a<=q0;5-F4%fsw*Ddp2XW_?04Z1opY^&jV-5F%)ItE+$f2|1 z9U-$m3F?3NBJ7JBcmC!UuPb7XyIuxq1ytv%s1UwIsel`SLxx*4KuhEua_HDEc;;(5 z2@KzI^7^hl9b!>e)^r)j<{+02>Fr7}{iEjMxC+YN~O%KR)pXL3e`+jP`; z;-&k#3N&>b8XLPQI>%T?+PMHpibc)~Kp!cIki__75%f4rf^F1w^>_KIozf=)zIR54 zaA}{SAHTt=ue>zp_#7B*&>4W=Af0c~M<|9|CB+9|Wul^JSeO$TOb1JV9PC!L#k21snPou z6!W2%uKoAFdoN~ppuc5(d{{aHC2-k4(_l=t9&R6prBfTeC`8u^6loKKEbWT5a#1V% zy;slVkuKAyZ^P05et%xabG(7_*tY4Iu;o7Mb0AR8!^Ij^X z6mnb&KvlbJSSl-5z{Ap-tFLI6umoaj3>trPG5;CcAQK4j;@`N@h3-leLN$e?`^dkf zwU1wB&&91xwYZ=sd^eT~Ug7}(8=5Pmhi%c1H->OdM$O?qx7*Kqp`CkU=P^gcWv$94 zTMV&!V^Wqj@q>zyyoV54`LQjS>N?+z1FDUr72vcCXG&X%}) zoLwj|fN)WZY_BXPY=C2Y)hCYq{J!Gmwj85&p@3#=8h`|igE-QX1_uy&3j$$m(S%WR zpBDwUEaN%8P?wjyQT?zigD2*4n&z<&g>RT`2vQdj==f*QXl@X5i5c%AMe$w2+{_h0 z7OI@syDm<6u{8cTY>It2ZM7@_r&Bhc!8$H;dgmMyVNI%` z`p4DP-g39lKjNN;QwS6jIEtMBvEKgl4wXBzGALUM#-VIxt^V>kZeFoZ4ak@jsb znk^tyo2@wgo3*5a zR{#W?9KdNI3lD%p{@@ou65ASx)d{2zn5_Hq4o3Kltt-zBS0(!JkFnya0u z8~uBFMy#aGVV+qI#ng}i#l_d<#n6%pbAkpCK;f55oRcuoNBt*we%VFYReMVvw+=j} z6@_F#b~SWiUNVZ}i7e#=0D;}9pY!@W<%f23`~;%#6bNPNH%2WOBVL_6skpTxLcwTB z*%D#D+Asgr-M=UUyavhwJ-_go%Tt^sM#5EpqM@z1va4i%Yx&*2HE{SKG zb!5Ncz(CG9EpzbW;QwforZXE@F~Hm>+9ybzQRe#&On37_FtO}ZA1I=ddeS< zmmgfgu}E;;#$j41yn*Yh7|tyH&&;yQr7iT64__F7DpkI~1T9mFs<3)^M*K?|L)o!t zC!$Xe_)F%UHmJGm-PR<$8|I`WC?rbk9QeKXS=L$P^Em@pd=5#=cB{M<4BVZPO!e}= z3YMBj_dbUiu|n|gAgy(1G(3v9)Z-Omn+Y#ie?+;FVJzF3htDv>+{i7?zGdwey#sd z)D9c412GB=ZG!eSQUmgJ5bTlJXEQi~oy8UX#ujE*atktQAT;onhL-X|W=Pn#awm+L zgF--z3<&|HRZB`$XI-Thye<-8ipfMx*9#q{Yc;<-$TwEzv;)z&|Xn&i+_WPvINJ{`d{YgY{lFa417x!`9v9K(ZR!WHWk?r8g#J7EG z&fR}fN7qSOSG*vmHqBVo4v!+HzDqYZul?!+jQR_l+AnFaSE@YzME-uvKKfmtWe}D1 z=c>;_pC~I&VtRlo;}juyaAK)QW@Roo0IG26_cQ=mmJ*O!2a5v@u9Yzv`b;~^&wJTM zZho>+vTjJC?AeP8-UP;j(|(3QGyF_+CdB?b+Ix;UH}J1&6h)gKs64*%*>HlyW3gPh z<=g^){E4+(D&auuG>!d06c6xY{352jK{4r(557QG{3@6QZuWx2Qv>nj0{ftyJGU;6 z8<-fb#C+YTf7cUhMiRR@(u@3O=6Bjs+pFV4bWkMMY8X1r)+m3M}B5Rv1c|GuXT|7LU&EGR`sJ)Zl@yFBt|B)kKn`*vjGAxcxv9 zn0fNd$IBVfuz%rTj+$)tZEMoMeW=};P%I@a+{zzPaa+z&+zG;y$pH{rFuN(oJQW|q zV%|v%N#qj8SSqram$~RZ5Xr6)e0pD>g)@<{1w1Ff*w){|ZVf>$G`$m``OE2i~2c770O0)7y{u=Sh&z_~FcqDYm{hdQM<@D}$c)RM_w8yU@rYlXe%h z%@8QnV4y~pOpJQTj<}EuxK9_X<5c?du5M{H1I0XPSxHX~Sb%u>b!?4U{VLdGNbXuY z3d!S!#!3K`?SmWrQz_5F{D2bE+O$T?nTGoOE5gps?rIh1Ij!3dqYokfK0DTuneR{3 z_i3528UcZOJཽ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 + + )} + +

+ Some agents discover newly installed skills only after starting a new session. +

+ + )} + + {step === 1 && (

Import session history

@@ -237,7 +309,7 @@ export function SetupWizard({ settings }: { settings: DesktopSettingsResponse })

)} - {step === 1 && ( + {step === 2 && (

Install live hooks

@@ -288,7 +360,7 @@ export function SetupWizard({ settings }: { settings: DesktopSettingsResponse })

)} - {step === 2 && ( + {step === 3 && (

Choose operating mode

diff --git a/apps/local-dashboard/src/dashboard-host-collaboration.test.tsx b/apps/local-dashboard/src/dashboard-host-collaboration.test.tsx new file mode 100644 index 00000000..49a82760 --- /dev/null +++ b/apps/local-dashboard/src/dashboard-host-collaboration.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + DashboardTeamCollaborationActions, + DashboardTeamCollaborationContribution, +} from "@selftune/dashboard-core/host"; +import type { TeamCollaborationSnapshotModel } from "@selftune/dashboard-core/models"; +import { + fetchLocalTeamCollaboration, + localDashboardModules, + localWorkspaceSkillSetPolicyInput, + selfHostDashboardModules, +} from "./dashboard-host"; + +const snapshot: TeamCollaborationSnapshotModel = { + entries: [ + { + id: "entry/one", + name: "release-review", + rolloutPolicy: "manual", + currentVersion: "1.0.0", + pendingContributions: 1, + installations: 2, + conflicts: 0, + }, + ], + contributions: [], + installations: [], +}; + +function availableCollaboration(): Extract< + DashboardTeamCollaborationContribution, + { access: "available" } +> { + const collaboration = localDashboardModules.teamCollaboration.collaboration; + if (!collaboration || collaboration.access !== "available") { + throw new Error("Expected the local collaboration adapter to be available."); + } + return collaboration; +} + +function queryWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return function Wrapper({ children }: PropsWithChildren) { + return {children}; + }; +} + +describe("Desktop team collaboration adapter", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("loads the canonical snapshot through the local sidecar", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(snapshot), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { result } = renderHook(() => availableCollaboration().useSnapshot(), { + wrapper: queryWrapper(), + }); + + await waitFor(() => expect(result.current.data).toEqual(snapshot)); + expect(result.current.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledWith("/api/v2/team-collaboration"); + }); + + it("exposes admin actions and sends the exact sidecar requests", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const path = String(input); + if (path === "/api/v2/team-collaboration/access") { + return new Response(JSON.stringify({ currentRole: "admin", readOnly: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(null, { status: 204 }); + }); + + const { result } = renderHook(() => availableCollaboration().useActions(), { + wrapper: queryWrapper(), + }); + await waitFor(() => expect(result.current.updateRolloutPolicy.access).toBe("available")); + + await act(async () => { + const rollout = result.current.updateRolloutPolicy; + if (rollout.access !== "available") throw new Error("Rollout action was not available."); + await rollout.execute({ entryId: "entry/one", policy: "automatic" }); + }); + async function executeDecision( + name: string, + action: DashboardTeamCollaborationActions["adoptContribution"], + ) { + await act(async () => { + if (action.access !== "available") throw new Error(`${name} action was not available.`); + await action.execute("candidate one"); + }); + } + await executeDecision("adopt", result.current.adoptContribution); + await executeDecision("reject", result.current.rejectContribution); + await executeDecision("rollback", result.current.rollbackContribution); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/v2/team-collaboration/registry/entry%2Fone/rollout-policy", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ policy: "automatic" }), + }, + ); + for (const decision of ["adopt", "reject", "rollback"]) { + expect(fetchMock).toHaveBeenCalledWith( + `/api/v2/team-collaboration/contributions/candidate%20one/${decision}`, + { method: "POST" }, + ); + } + }); + + it("keeps member and viewer collaboration read only", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ currentRole: "member", readOnly: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { result } = renderHook(() => availableCollaboration().useActions(), { + wrapper: queryWrapper(), + }); + await waitFor(() => + expect(result.current.adoptContribution).toMatchObject({ + access: "unavailable", + reason: expect.stringMatching(/admins and owners/i), + }), + ); + + expect(result.current.adoptContribution).toMatchObject({ + access: "unavailable", + reason: expect.stringMatching(/admins and owners/i), + }); + expect(result.current.rejectContribution.access).toBe("unavailable"); + expect(result.current.rollbackContribution.access).toBe("unavailable"); + }); + + it("keeps an admin read only when the Cloud workspace is read only", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ currentRole: "admin", readOnly: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const { result } = renderHook(() => availableCollaboration().useActions(), { + wrapper: queryWrapper(), + }); + await waitFor(() => + expect(result.current.updateRolloutPolicy).toMatchObject({ + access: "unavailable", + reason: expect.stringMatching(/workspace is read only/i), + }), + ); + + expect(result.current.adoptContribution.access).toBe("unavailable"); + expect(result.current.rejectContribution.access).toBe("unavailable"); + expect(result.current.rollbackContribution.access).toBe("unavailable"); + }); + + it("keeps mutations unavailable when workspace authority cannot be verified", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Cloud workspace unavailable", { status: 503 }), + ); + + const { result } = renderHook(() => availableCollaboration().useActions(), { + wrapper: queryWrapper(), + }); + await waitFor(() => + expect(result.current.updateRolloutPolicy).toMatchObject({ + access: "unavailable", + reason: expect.stringMatching(/permissions could not be loaded/i), + }), + ); + + expect(result.current.adoptContribution.access).toBe("unavailable"); + }); + + it("surfaces an offline local service as a query error", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); + + const { result } = renderHook(() => availableCollaboration().useSnapshot(), { + wrapper: queryWrapper(), + }); + await waitFor(() => expect(result.current.error).toMatch(/could not be reached/i)); + + expect(result.current.data).toBeNull(); + await expect(fetchLocalTeamCollaboration()).rejects.toThrow(/could not be reached/i); + }); + + it("mounts the shared contribution in both Desktop compositions", () => { + expect(localDashboardModules.teamCollaboration.collaboration?.access).toBe("available"); + expect(selfHostDashboardModules.teamCollaboration.collaboration).toBe( + localDashboardModules.teamCollaboration.collaboration, + ); + }); + + it("marks whole-workspace Skill Set sharing as required", () => { + expect(localWorkspaceSkillSetPolicyInput("engineering")).toEqual({ + skillSetId: "engineering", + action: "require", + }); + }); +}); diff --git a/apps/local-dashboard/src/dashboard-host-selfhost.test.ts b/apps/local-dashboard/src/dashboard-host-selfhost.test.ts deleted file mode 100644 index e13e3309..00000000 --- a/apps/local-dashboard/src/dashboard-host-selfhost.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { localHostAdapter, mapSelfHostLibraryInventory, selfHostAdapter } from "./dashboard-host"; -import { mapLocalLibraryInventory } from "./local-library-model"; -import type { LibrarySnapshot } from "./types"; - -describe("Self-host dashboard adapter", () => { - it("keeps remote inventory contributions separate from Desktop", () => { - expect(localHostAdapter.host).toBe("local"); - expect(selfHostAdapter.host).toBe("selfhost"); - expect(selfHostAdapter.queries).toBe(localHostAdapter.queries); - expect(selfHostAdapter.features).toBe(localHostAdapter.features); - expect(localHostAdapter.library.access).toBe("available"); - expect(selfHostAdapter.library).not.toBe(localHostAdapter.library); - expect(localHostAdapter.projects.access).toBe("available"); - expect(selfHostAdapter.projects).not.toBe(localHostAdapter.projects); - }); - - it("keeps Library and Skill Sets readable while failing closed on mutations", () => { - expect(selfHostAdapter.library.access).toBe("available"); - expect(selfHostAdapter.projects.access).toBe("available"); - if (selfHostAdapter.library.access !== "available") { - throw new TypeError("Expected the Self-host Library contribution."); - } - if (selfHostAdapter.projects.access !== "available") { - throw new TypeError("Expected the Self-host Skill Sets contribution."); - } - - const libraryActions = selfHostAdapter.library.useActions(); - expect([ - libraryActions.updateCategory.access, - libraryActions.openLocation.access, - libraryActions.previewSourceUpdate.access, - libraryActions.applySourceUpdate.access, - libraryActions.prepareMerge.access, - libraryActions.applyMerge.access, - libraryActions.archive.access, - libraryActions.remove.access, - libraryActions.decideRemoval.access, - libraryActions.restore.access, - libraryActions.create.access, - ]).toEqual(Array.from({ length: 11 }, () => "unavailable")); - expect(libraryActions.mergeConnections).toEqual([]); - expect(libraryActions.primary).toEqual([]); - expect(libraryActions.archiveMany).toBeUndefined(); - expect(libraryActions.consolidate).toBeUndefined(); - - const projectActions = selfHostAdapter.projects.useActions(); - expect([ - projectActions.create.access, - projectActions.update.access, - projectActions.derive.access, - projectActions.export.access, - projectActions.remove.access, - projectActions.plan.access, - projectActions.apply.access, - projectActions.resolveConflict.access, - projectActions.decideConflict.access, - projectActions.rollbackConflict.access, - projectActions.rollback.access, - projectActions.reviewSuggestion.access, - ]).toEqual(Array.from({ length: 12 }, () => "unavailable")); - expect(projectActions.provision).toBeUndefined(); - expect(projectActions.share).toBeUndefined(); - - expect(localHostAdapter.decisions.access).toBe("available"); - expect(localHostAdapter.correctionStudies?.access).toBe("available"); - expect(selfHostAdapter.decisions).toEqual({ - access: "unavailable", - reason: "Durable local decisions are unavailable on this read-only Self-host dashboard.", - }); - expect(selfHostAdapter.correctionStudies).toEqual({ - access: "unavailable", - reason: "Correction studies require connected local agent data.", - }); - }); - - it("keeps remote skills readable without advertising local-only reports", () => { - const modifiedAt = "2026-07-30T10:00:00.000Z"; - const origin = { - kind: "registry" as const, - label: "SelfTune Remote Library", - url: "https://selftune.example.com", - }; - const location = { - sourceKind: "remote" as const, - packagePath: "selftune-remote://objects/abc/packages/team-helper", - skillPath: "selftune-remote://objects/abc/packages/team-helper/SKILL.md", - harness: null, - scope: "library" as const, - projectRoot: null, - active: false, - modifiedAt, - lastUsedAt: null, - origin, - updateStatus: "untracked" as const, - }; - const snapshot: LibrarySnapshot = { - generatedAt: modifiedAt, - counts: { total: 1, active: 0, library: 1, draft: 0, archived: 0 }, - skills: [ - { - skillId: "team-helper", - name: "team-helper", - lifecycle: "library", - revisions: [{ contentHash: "abc", locations: [location] }], - locations: [location], - lastUsedAt: null, - lastModifiedAt: modifiedAt, - origins: [origin], - updateStatus: "untracked", - }, - ], - }; - - const localInventory = mapLocalLibraryInventory(snapshot, null, null, []); - const selfHostInventory = mapSelfHostLibraryInventory(snapshot); - - expect(localInventory.skills[0]?.detailHref).toBe("/skills/team-helper"); - expect(selfHostInventory.skills[0]).toMatchObject({ - id: "team-helper", - name: "team-helper", - lifecycle: "library", - status: "Stored", - detailHref: null, - }); - expect(selfHostInventory.skills[0]?.locations).toEqual([ - expect.objectContaining({ - sourceKind: "remote", - path: location.packagePath, - removable: false, - }), - ]); - }); -}); diff --git a/apps/local-dashboard/src/dashboard-host.test.ts b/apps/local-dashboard/src/dashboard-host.test.ts index a63ecc84..fbfc8bf5 100644 --- a/apps/local-dashboard/src/dashboard-host.test.ts +++ b/apps/local-dashboard/src/dashboard-host.test.ts @@ -2,13 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ProjectSkillSetInput } from "@selftune/dashboard-core/models"; import { - localHostAdapter, + localDashboardModules, localProjectSkillSetInput, localProjectSkillSetTargetInput, localProjectSkillSetUpdateInput, mapLocalSkillSetPlan, mapLocalSkillSetReceipt, previewsCloudSharingGate, + selfHostDashboardModules, } from "./dashboard-host"; import { projectCaptureCandidatesFromLibrary } from "./project-capture-candidates"; import { @@ -198,7 +199,7 @@ describe("local Library location identity", () => { }); }); -describe("localHostAdapter", () => { +describe("localDashboardModules", () => { afterEach(() => { vi.restoreAllMocks(); }); @@ -211,7 +212,7 @@ describe("localHostAdapter", () => { }), ); - const result = await localHostAdapter.mutations.updateOverviewWatchlist?.([ + const result = await localDashboardModules.overview?.mutations.updateOverviewWatchlist?.([ "selftune", "playwright-cli", ]); @@ -224,6 +225,20 @@ describe("localHostAdapter", () => { }); }); + it("composes Self-host and Desktop from the same journey modules", () => { + expect(localDashboardModules.capability.host).toBe("local"); + expect(selfHostDashboardModules.capability.host).toBe("selfhost"); + expect(selfHostDashboardModules.capability.features).toBe( + localDashboardModules.capability.features, + ); + expect(localDashboardModules.skills.library.access).toBe("available"); + expect(selfHostDashboardModules.skills.library).toBe(localDashboardModules.skills.library); + expect(localDashboardModules.skillSets.projects.access).toBe("available"); + expect(selfHostDashboardModules.skillSets.projects).toBe( + localDashboardModules.skillSets.projects, + ); + }); + it("passes the selected merge connection and optional model to the runtime", () => { expect(connectionNames(mergeHarnesses).get("codex")).toBe("Codex"); expect(connectionNames(mergeHarnesses).get("cline")).toBe("Cline"); diff --git a/apps/local-dashboard/src/dashboard-host.ts b/apps/local-dashboard/src/dashboard-host.ts index cfaea2eb..17cf8622 100644 --- a/apps/local-dashboard/src/dashboard-host.ts +++ b/apps/local-dashboard/src/dashboard-host.ts @@ -1,17 +1,18 @@ // oxlint-disable max-lines -- Host capability composition is intentionally centralized here. import { - capabilitiesFromAdapter, + capabilitiesFromModule, type DashboardLibraryActions, - type DashboardHostAdapter, + type DashboardHostModules, type DashboardHostKind, + type DashboardPluginsActions, type DashboardDecisionsActions, type DashboardProjectsActions, + type DashboardTeamCollaborationActions, type ServerProfileController, } from "@selftune/dashboard-core/host"; import type { - AnalyticsModel, DashboardDecisionModel, - OverviewModel, + PluginInventoryModel, ProjectPlanModel, ProjectConnectionId, ProjectProvisionInput, @@ -21,17 +22,21 @@ import type { ProjectSkillSetModel, ProjectSkillSetTargetInput, ProjectSkillSetUpdateInput, - RuntimeHealthModel, - SkillsModel, + TeamCollaborationSnapshotModel, + TeamRolloutPolicyModel, } from "@selftune/dashboard-core/models"; -import { useQuery } from "@tanstack/react-query"; -import { fetchAnalytics, fetchOverview } from "./api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { fetchAnalytics } from "./api"; +import { useManagePlugin, usePlugins } from "./hooks/usePlugins"; import { useApplySkillSourceMerge, + useApplyLibrarySkillLicense, useApplySkillSourceUpdate, useLibrary, usePrepareSkillSourceMerge, usePreviewSkillSourceUpdate, + usePreviewLibrarySkillLicense, } from "./hooks/useLibrary"; import { useBulkQuarantinePortfolioSkills, @@ -52,9 +57,16 @@ import { useApplySkillSet, useApplyProjectProvision, useCreateSkillSet, + useDeleteSkillSet, useDeriveSkillSet, - useExportSkillSet, + useExportSkillSetPlugin, + useImportSkillSetPack, + useInstallSkillSetPlugin, usePreviewSkillSet, + usePreviewSkillSetPluginInstall, + usePreviewSkillSetPack, + useRevokeSkillSetPack, + useSkillSetPacks, usePreviewProjectProvision, useRollbackSkillSet, useShareSkillSet, @@ -68,13 +80,12 @@ import { useUpdateSkillClassification, } from "./hooks/useSkillIntelligence"; import { fetchTraceCandidateTargets, submitTraceCandidateTarget } from "./api"; -import { useSettings } from "./hooks/useSettings"; import { - executeLocalShare, - LOCAL_SHARE_CAPABILITIES, - LOCAL_SHARE_LINK_ONLY, - useLocalLibraryTransferActions, -} from "./local-library-transfer-actions"; + useSettings, + useUpdateWorkspaceSkillSetPolicy, + useWorkspaceMembers, +} from "./hooks/useSettings"; +import { useLocalLibraryTransferActions } from "./local-library-transfer-actions"; import { localSkillSetSuggestionReviewInput, useLocalProjectsIntelligence, @@ -92,11 +103,7 @@ import { } from "./local-library-model"; import type { ApplySkillSetRequest, - AnalyticsResponse, DurableDashboardDecision, - HealthResponse, - LibrarySnapshot, - OverviewResponse, SkillSetManifest, SkillSetPlan, SkillSetReceipt, @@ -203,7 +210,7 @@ export function mapDurableDecision(decision: DurableDashboardDecision): Dashboar }; } -const LOCAL_FEATURES: DashboardHostAdapter["features"] = { +const LOCAL_FEATURES: DashboardHostModules["capability"]["features"] = { analytics: { access: "available" }, registry: { access: "upgrade", href: "https://selftune.dev" }, signals: { access: "upgrade", href: "https://selftune.dev" }, @@ -254,6 +261,8 @@ function useLocalLibraryActions(): DashboardLibraryActions { const updateCategory = useUpdateSkillClassification(); const transfer = useLocalLibraryTransferActions(); const preview = usePreviewSkillSourceUpdate(); + const previewLicense = usePreviewLibrarySkillLicense(); + const applyLicense = useApplyLibrarySkillLicense(); const apply = useApplySkillSourceUpdate(); const prepare = usePrepareSkillSourceMerge(); const applyMerge = useApplySkillSourceMerge(); @@ -267,6 +276,16 @@ function useLocalLibraryActions(): DashboardLibraryActions { const names = connectionNames(harnesses); return { + previewLicenseDraft: { + access: "available", + isPending: previewLicense.isPending, + execute: (input) => previewLicense.mutateAsync(input), + }, + applyLicenseDraft: { + access: "available", + isPending: applyLicense.isPending, + execute: (input) => applyLicense.mutateAsync(input), + }, updateCategory: { access: "available", isPending: updateCategory.isPending, @@ -482,67 +501,12 @@ function useLocalLibraryActions(): DashboardLibraryActions { }; } -const LOCAL_LIBRARY: DashboardHostAdapter["library"] = { +const LOCAL_LIBRARY: DashboardHostModules["skills"]["library"] = { access: "available", useInventory: useLocalLibraryInventory, useActions: useLocalLibraryActions, }; -const SELF_HOST_LIBRARY_READ_ONLY_REASON = - "This Self-host dashboard exposes the Remote Library as read-only."; -const SELF_HOST_LIBRARY_ACTION_UNAVAILABLE: { - readonly access: "unavailable"; - readonly reason: string; -} = { - access: "unavailable", - reason: SELF_HOST_LIBRARY_READ_ONLY_REASON, -}; -const SELF_HOST_LIBRARY_ACTIONS: DashboardLibraryActions = { - updateCategory: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - openLocation: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - previewSourceUpdate: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - applySourceUpdate: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - mergeConnections: [], - prepareMerge: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - applyMerge: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - archive: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - remove: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - decideRemoval: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - restore: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - create: SELF_HOST_LIBRARY_ACTION_UNAVAILABLE, - primary: [], -}; - -export function mapSelfHostLibraryInventory(snapshot: LibrarySnapshot) { - const inventory = mapLocalLibraryInventory(snapshot, null, null, []); - return { - ...inventory, - skills: inventory.skills.map((skill) => ({ - ...skill, - detailHref: null, - })), - }; -} - -function useSelfHostLibraryInventory() { - const query = useLibrary(); - return { - data: query.data ? mapSelfHostLibraryInventory(query.data) : null, - isLoading: query.isLoading, - error: - query.error instanceof Error ? query.error.message : query.error ? String(query.error) : null, - refresh: async () => { - await query.refetch(); - }, - }; -} - -const SELF_HOST_LIBRARY: DashboardHostAdapter["library"] = { - access: "available", - useInventory: useSelfHostLibraryInventory, - useActions: () => SELF_HOST_LIBRARY_ACTIONS, -}; - export function mapLocalSkillSet( manifest: SkillSetManifest, policy?: NonNullable[number], @@ -637,11 +601,12 @@ export function localProjectSkillSetUpdateInput( export function localProjectSkillSetTargetInput( input: ProjectSkillSetTargetInput, ): ApplySkillSetRequest { - return { + const request: ApplySkillSetRequest = { set_id: input.skillSetId, project_root: input.projectRoot, - ...(input.policyApproval ? { policy_approval: true } : {}), }; + if (input.policyApproval) request.policy_approval = true; + return request; } function useLocalProjectsInventory() { @@ -690,11 +655,45 @@ export function previewsCloudSharingGate(search: string, isDevelopment: boolean) return isDevelopment && new URLSearchParams(search).get("preview") === "cloud-sharing-gate"; } +interface LocalWorkspaceSkillSetPolicyInput { + skillSetId: string; + action: "require"; +} + +export function localWorkspaceSkillSetPolicyInput( + skillSetId: string, +): LocalWorkspaceSkillSetPolicyInput { + return { + skillSetId, + action: "require", + }; +} + +function downloadBase64File(filename: string, content: string): void { + const binary = atob(content); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "application/zip" })); + try { + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = filename.replace(/[^A-Za-z0-9._-]/g, "-") || "selftune-plugin.zip"; + anchor.click(); + } finally { + URL.revokeObjectURL(blobUrl); + } +} + function useLocalProjectsActions(): DashboardProjectsActions { const create = useCreateSkillSet(); const update = useUpdateSkillSet(); + const remove = useDeleteSkillSet(); const derive = useDeriveSkillSet(); - const exportSet = useExportSkillSet(); + const exportPlugin = useExportSkillSetPlugin(); + const previewPluginInstall = usePreviewSkillSetPluginInstall(); + const installPlugin = useInstallSkillSetPlugin(); + const previewPack = usePreviewSkillSetPack(); + const importPack = useImportSkillSetPack(); const shareSet = useShareSkillSet(); const plan = usePreviewSkillSet(); const apply = useApplySkillSet(); @@ -718,6 +717,13 @@ function useLocalProjectsActions(): DashboardProjectsActions { settings.data.remote_library.configured && syncDestinationFromUrl(settings.data.remote_library.url ?? "") === "cloud", ); + const remoteSharingConfigured = Boolean( + !previewCloudSharingGate && settings.data?.remote_library.configured, + ); + const packInventory = useSkillSetPacks(remoteSharingConfigured); + const revokePack = useRevokeSkillSetPack(); + const workspaceMembers = useWorkspaceMembers(cloudSharingConfigured); + const updateWorkspacePolicy = useUpdateWorkspaceSkillSetPolicy(); return { create: { access: "available", @@ -749,26 +755,104 @@ function useLocalProjectsActions(): DashboardProjectsActions { }, export: { access: "available", - requiresProjectRoot: true, - label: "Save to Project", - isPending: exportSet.isPending, + label: "Export", + formats: [ + { + id: "claude", + label: "Claude plugin", + description: "Claude plugin manifest plus every pinned skill.", + }, + { + id: "openai", + label: "OpenAI plugin", + description: "Codex plugin manifest plus every pinned skill.", + }, + { + id: "agent-plugins-v1", + label: "Agent Plugins 1.0", + description: "Portable root plugin.json using the official versioned schema.", + }, + { + id: "all", + label: "All plugin formats", + description: "One archive with Claude, OpenAI, and Agent Plugins manifests.", + }, + ], + isPending: exportPlugin.isPending, async execute(input) { - if (!input.projectRoot) throw new Error("Choose a project folder before exporting."); - const result = await exportSet.mutateAsync({ + const format = input.format && input.format !== "portable" ? input.format : "all"; + const result = await exportPlugin.mutateAsync({ set_id: input.skillSetId, - project_root: input.projectRoot, + target: format, }); - return { outputPath: result.output_path }; + downloadBase64File(result.filename, result.content_base64); + return { outputPath: result.filename }; + }, + }, + installPlugin: { + preview: { + access: "available", + isPending: previewPluginInstall.isPending, + execute: (skillSetId) => previewPluginInstall.mutateAsync(skillSetId), + }, + execute: { + access: "available", + isPending: installPlugin.isPending, + execute: (input) => installPlugin.mutateAsync(input), }, }, - share: cloudSharingConfigured + importPack: { + preview: { + access: "available", + isPending: previewPack.isPending, + async execute(packUrl) { + const result = await previewPack.mutateAsync(packUrl); + return { + packUrl: result.packUrl, + packId: result.preview.packId, + name: result.preview.name, + description: result.preview.description, + mode: result.preview.mode, + expiresAt: result.preview.expiresAt, + skillSetRevisionSha256: result.preview.skillSetRevisionSha256, + objectSha256: result.preview.objectSha256, + components: result.preview.components.map((component) => ({ ...component })), + }; + }, + }, + execute: { + access: "available", + isPending: importPack.isPending, + async execute(input) { + const result = await importPack.mutateAsync(input); + return mapLocalSkillSet(result.manifest); + }, + }, + }, + share: remoteSharingConfigured ? { access: "available", - ...LOCAL_SHARE_CAPABILITIES, isPending: shareSet.isPending, - execute: (input) => executeLocalShare(input, shareSet.mutateAsync), + execute: (input) => shareSet.mutateAsync(input), } : { access: "upgrade", href: "/settings?section=remote-library" }, + usePacks: remoteSharingConfigured + ? () => ({ + data: packInventory.data?.packs ?? null, + isLoading: packInventory.isLoading, + error: packInventory.error instanceof Error ? packInventory.error.message : null, + refresh: async () => { + await packInventory.refetch(); + }, + }) + : undefined, + revokePack: remoteSharingConfigured + ? { + access: "available", + isPending: revokePack.isPending, + execute: (packId) => revokePack.mutateAsync(packId), + } + : undefined, shareGatePreview: import.meta.env.DEV ? previewCloudSharingGate ? { href: "/projects", label: "Exit Cloud gate preview" } @@ -777,14 +861,30 @@ function useLocalProjectsActions(): DashboardProjectsActions { label: "Preview Cloud gate", } : undefined, - useShareRecipients: () => [], - shareWithWorkspace: { - access: "unavailable", - reason: LOCAL_SHARE_LINK_ONLY, - }, + useShareRecipients: () => + workspaceMembers.data?.members + .filter((member) => member.user_id !== workspaceMembers.data?.current_user_id) + .map((member) => ({ + email: member.email, + name: member.name, + avatarUrl: member.avatar_url, + })) ?? [], + shareWithWorkspace: cloudSharingConfigured + ? { + access: "available", + isPending: updateWorkspacePolicy.isPending, + async execute(skillSetId) { + await updateWorkspacePolicy.mutateAsync(localWorkspaceSkillSetPolicyInput(skillSetId)); + }, + } + : { + access: "unavailable", + reason: "Connect a cloud workspace before sharing with everyone in it.", + }, remove: { - access: "unavailable", - reason: "Delete local Skill Sets from the SelfTune CLI.", + access: "available", + isPending: remove.isPending, + execute: (skillSetId) => remove.mutateAsync(skillSetId), }, plan: { access: "available", @@ -803,7 +903,7 @@ function useLocalProjectsActions(): DashboardProjectsActions { }, }, provision: { - chooseFolder: typeof chooseFolder === "function" ? () => chooseFolder() : undefined, + chooseFolder: chooseFolder ? () => chooseFolder() : undefined, preview: { access: "available", isPending: previewProvision.isPending, @@ -919,72 +1019,42 @@ function useLocalProjectsActions(): DashboardProjectsActions { }; } -const LOCAL_PROJECTS: DashboardHostAdapter["projects"] = { +const LOCAL_PROJECTS: DashboardHostModules["skillSets"]["projects"] = { access: "available", useInventory: useLocalProjectsInventory, useIntelligence: useLocalProjectsIntelligence, useActions: useLocalProjectsActions, }; -const SELF_HOST_PROJECTS_READ_ONLY_REASON = - "This Self-host dashboard exposes shared Skill Sets as read-only."; -const SELF_HOST_PROJECTS_ACTION_UNAVAILABLE: { - readonly access: "unavailable"; - readonly reason: string; -} = { - access: "unavailable", - reason: SELF_HOST_PROJECTS_READ_ONLY_REASON, -}; -const SELF_HOST_PROJECTS_ACTIONS: DashboardProjectsActions = { - create: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - update: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - derive: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - export: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - remove: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - plan: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - apply: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - resolveConflict: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - decideConflict: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - rollbackConflict: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - rollback: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, - reviewSuggestion: SELF_HOST_PROJECTS_ACTION_UNAVAILABLE, -}; - -function useSelfHostProjectsInventory() { - const skillSets = useSkillSets(); - const library = useLibrary(); - const data: ProjectsInventoryModel | null = - skillSets.data && library.data - ? { - skillSets: skillSets.data.sets.map((manifest) => ({ - ...mapLocalSkillSet(manifest), - ownerScope: "workspace", - })), - receipts: [], - captureCandidates: [], - connectedHarnesses: [], - availableSkills: projectSkillOptionsFromLibrary(library.data), - } - : null; - const error = skillSets.error ?? library.error; +function useLocalPluginInventory() { + const query = usePlugins(); return { - data, - isLoading: skillSets.isLoading || library.isLoading, - error: error instanceof Error ? error.message : error ? String(error) : null, + data: (query.data ?? null) satisfies PluginInventoryModel | null, + isLoading: query.isLoading, + error: + query.error instanceof Error ? query.error.message : query.error ? String(query.error) : null, refresh: async () => { - await Promise.all([skillSets.refetch(), library.refetch()]); + await query.refetch(); }, }; } -const SELF_HOST_PROJECTS: DashboardHostAdapter["projects"] = { +function useLocalPluginActions(): DashboardPluginsActions { + const manage = useManagePlugin(); + return { + manage: { + access: "available", + isPending: manage.isPending, + error: manage.error instanceof Error ? manage.error.message : null, + execute: (input) => manage.mutateAsync(input), + }, + }; +} + +const LOCAL_PLUGINS: NonNullable = { access: "available", - useInventory: useSelfHostProjectsInventory, - useIntelligence: () => ({ - access: "unavailable", - reason: "Skill Set intelligence runs on connected local agent data.", - }), - useActions: () => SELF_HOST_PROJECTS_ACTIONS, + useInventory: useLocalPluginInventory, + useActions: useLocalPluginActions, }; function useLocalDecisions() { @@ -1021,164 +1091,177 @@ function useLocalDecisionActions(): DashboardDecisionsActions { }; } -const LOCAL_DECISIONS: DashboardHostAdapter["decisions"] = { +const LOCAL_DECISIONS: DashboardHostModules["skills"]["decisions"] = { access: "available", useDecisions: useLocalDecisions, useActions: useLocalDecisionActions, }; -const SELF_HOST_DECISIONS: DashboardHostAdapter["decisions"] = { - access: "unavailable", - reason: "Durable local decisions are unavailable on this read-only Self-host dashboard.", -}; +const TEAM_COLLABORATION_QUERY_KEY = ["team-collaboration"]; -function localHostIdentity(host: Extract) { - return { - host, - plan: "oss", - features: LOCAL_FEATURES, - } satisfies Pick; +async function localTeamCollaborationRequest(path: string, init?: RequestInit): Promise { + let response: Response; + try { + response = init ? await fetch(path, init) : await fetch(path); + } catch { + throw new Error( + "Team collaboration is unavailable because the local SelfTune service could not be reached.", + ); + } + if (!response.ok) { + const detail = (await response.text()).trim(); + throw new Error( + detail || `Team collaboration request failed (${response.status} ${response.statusText}).`, + ); + } + return response; } -function mapOverviewModel(data: OverviewResponse): OverviewModel { - return { - version: data.version, - summary: { - totalSkills: data.skills.length, - avgPassRate30d: data.skills.length - ? data.skills.reduce((sum, skill) => sum + skill.pass_rate, 0) / data.skills.length - : null, - unmatchedCount30d: data.overview.unmatched_queries.length, - sessionsCount30d: data.overview.counts.sessions, - pendingCount: data.overview.pending_proposals.length, - evidenceCount: data.overview.counts.evidence, - }, - autonomy: { - level: data.autonomy_status.level, - summary: data.autonomy_status.summary, - attentionRequired: data.autonomy_status.attention_required, - skillsObserved: data.autonomy_status.skills_observed, - pendingReviews: data.autonomy_status.pending_reviews, - lastRunAt: data.autonomy_status.last_run, +export async function fetchLocalTeamCollaboration(): Promise { + const response = await localTeamCollaborationRequest("/api/v2/team-collaboration"); + return response.json(); +} + +interface LocalTeamCollaborationAccess { + currentRole: "viewer" | "member" | "admin" | "owner"; + readOnly: boolean; +} + +export async function fetchLocalTeamCollaborationAccess(): Promise { + const response = await localTeamCollaborationRequest("/api/v2/team-collaboration/access"); + return response.json(); +} + +export async function updateLocalTeamRolloutPolicy( + entryId: string, + policy: TeamRolloutPolicyModel, +): Promise { + await localTeamCollaborationRequest( + `/api/v2/team-collaboration/registry/${encodeURIComponent(entryId)}/rollout-policy`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ policy }), }, - skillCards: data.skills.map((skill) => ({ - name: skill.skill_name, - scope: skill.skill_scope, - platforms: skill.skill_scope ? [skill.skill_scope] : [], - passRate: skill.pass_rate, - checks: skill.total_checks, - status: - skill.pass_rate >= 0.8 - ? "HEALTHY" - : skill.pass_rate >= 0.6 - ? "WARNING" - : skill.total_checks > 0 - ? "CRITICAL" - : "UNKNOWN", - hasEvidence: skill.has_evidence, - uniqueSessions: skill.unique_sessions, - lastSeen: skill.last_seen, - })), - watchlist: data.trust_watchlist.map((entry) => ({ - skillName: entry.skill_name, - bucket: entry.bucket, - lastSeen: entry.last_seen, - passRate: entry.pass_rate, - checks: entry.checks, - })), - attention: data.attention_queue.map((item) => ({ - skillName: item.skill_name, - severity: item.severity, - title: item.category.replace(/_/g, " "), - body: item.reason, - })), - decisions: data.recent_decisions.map((item) => ({ - skillName: item.skill_name, - kind: item.kind, - timestamp: item.timestamp, - summary: item.summary, - })), - activity: data.overview.recent_activity.map((item) => ({ - id: `${item.session_id}:${item.timestamp}`, - type: item.triggered ? "evolution" : "unmatched", - skillName: item.skill_name, - timestamp: item.timestamp, - title: item.skill_name, - summary: item.query, - })), - jobs: [], - signals: null, - }; + ); } -function mapSkillsModel(data: OverviewResponse): SkillsModel { +type LocalTeamContributionDecision = "adopt" | "reject" | "rollback"; + +export async function decideLocalTeamContribution( + contributionId: string, + decision: LocalTeamContributionDecision, +): Promise { + await localTeamCollaborationRequest( + `/api/v2/team-collaboration/contributions/${encodeURIComponent(contributionId)}/${decision}`, + { method: "POST" }, + ); +} + +function useLocalTeamCollaborationSnapshot() { + const query = useQuery({ + queryKey: TEAM_COLLABORATION_QUERY_KEY, + queryFn: fetchLocalTeamCollaboration, + staleTime: 10_000, + }); return { - items: data.skills.map((skill) => ({ - name: skill.skill_name, - platforms: skill.skill_scope ? [skill.skill_scope] : [], - status: - skill.pass_rate >= 0.8 - ? "HEALTHY" - : skill.pass_rate >= 0.6 - ? "WARNING" - : skill.total_checks > 0 - ? "CRITICAL" - : "UNKNOWN", - passRate: skill.pass_rate, - totalChecks: skill.total_checks, - uniqueSessions: skill.unique_sessions, - evidenceCount: skill.has_evidence ? 1 : 0, - lastSeen: skill.last_seen, - })), + data: query.data ?? null, + isLoading: query.isLoading, + error: + query.error instanceof Error ? query.error.message : query.error ? String(query.error) : null, + refresh: async () => { + await query.refetch(); + }, }; } -function mapAnalyticsModel(data: AnalyticsResponse): AnalyticsModel { +type LocalTeamCollaborationMutation = + | { kind: "rollout"; entryId: string; policy: TeamRolloutPolicyModel } + | { kind: "decision"; contributionId: string; decision: LocalTeamContributionDecision }; + +function useLocalTeamCollaborationActions(): DashboardTeamCollaborationActions { + const queryClient = useQueryClient(); + const access = useQuery({ + queryKey: ["team-collaboration", "access"], + queryFn: fetchLocalTeamCollaborationAccess, + staleTime: 10_000, + }); + const mutation = useMutation({ + mutationFn: async (input: LocalTeamCollaborationMutation) => { + if (input.kind === "rollout") { + await updateLocalTeamRolloutPolicy(input.entryId, input.policy); + return; + } + await decideLocalTeamContribution(input.contributionId, input.decision); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: TEAM_COLLABORATION_QUERY_KEY }), + }); + const role = access.data?.currentRole; + const canManage = access.data?.readOnly === false && (role === "admin" || role === "owner"); + if (!canManage) { + const reason = access.isLoading + ? "Checking workspace permissions." + : access.error + ? "Workspace permissions could not be loaded from the connected Cloud workspace." + : access.data?.readOnly + ? "This Cloud workspace is read only, so collaboration changes are unavailable." + : "Only workspace admins and owners can review suggestions or change rollout policies."; + const rolloutUnavailable: DashboardTeamCollaborationActions["updateRolloutPolicy"] = { + access: "unavailable", + reason, + }; + const decisionUnavailable: DashboardTeamCollaborationActions["adoptContribution"] = { + access: "unavailable", + reason, + }; + return { + updateRolloutPolicy: rolloutUnavailable, + adoptContribution: decisionUnavailable, + rejectContribution: decisionUnavailable, + rollbackContribution: decisionUnavailable, + }; + } return { - summary: { - activeSkills: data.summary.active_skills, - totalChecks30d: data.summary.total_checks_30d, - totalEvolutions: data.summary.total_evolutions, - avgImprovement: data.summary.avg_improvement, + updateRolloutPolicy: { + access: "available", + isPending: mutation.isPending, + execute: ({ entryId, policy }) => mutation.mutateAsync({ kind: "rollout", entryId, policy }), + }, + adoptContribution: { + access: "available", + isPending: mutation.isPending, + execute: (contributionId) => + mutation.mutateAsync({ kind: "decision", contributionId, decision: "adopt" }), + }, + rejectContribution: { + access: "available", + isPending: mutation.isPending, + execute: (contributionId) => + mutation.mutateAsync({ kind: "decision", contributionId, decision: "reject" }), + }, + rollbackContribution: { + access: "available", + isPending: mutation.isPending, + execute: (contributionId) => + mutation.mutateAsync({ kind: "decision", contributionId, decision: "rollback" }), }, - passRateTrend: data.pass_rate_trend.map((point) => ({ - date: point.date, - passRate: point.pass_rate, - checkVolume: point.total_checks, - })), - skillRankings: data.skill_rankings.map((skill, index) => ({ - skillName: skill.skill_name, - passRate: skill.pass_rate, - totalChecks: skill.total_checks, - rank: index + 1, - })), - dailyActivity: data.daily_activity.map((day) => ({ - date: day.date, - checks: day.checks, - })), - evolutionImpact: data.evolution_impact.map((entry) => ({ - skillName: entry.skill_name, - passRateBefore: entry.pass_rate_before, - passRateAfter: entry.pass_rate_after, - improvement: entry.pass_rate_after - entry.pass_rate_before, - })), }; } -async function fetchRuntimeHealth(): Promise { - const response = await fetch("/api/health"); - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); - } +const LOCAL_TEAM_COLLABORATION: NonNullable< + DashboardHostModules["teamCollaboration"]["collaboration"] +> = { + access: "available", + useSnapshot: useLocalTeamCollaborationSnapshot, + useActions: useLocalTeamCollaborationActions, +}; - const payload = (await response.json()) as HealthResponse; +function localHostIdentity(host: Extract) { return { - workspaceRoot: payload.workspace_root, - gitSha: payload.git_sha, - dbPath: payload.db_path, - processMode: payload.process_mode, - watcherMode: payload.watcher_mode, - }; + host, + plan: "oss", + features: LOCAL_FEATURES, + } satisfies DashboardHostModules["capability"]; } async function updateOverviewWatchlist(skills: string[]): Promise { @@ -1192,77 +1275,51 @@ async function updateOverviewWatchlist(skills: string[]): Promise { throw new Error(`API error: ${response.status} ${response.statusText}`); } - const payload = (await response.json()) as { watched_skills?: string[] }; - return Array.isArray(payload.watched_skills) ? payload.watched_skills : skills; + const payload = z + .object({ watched_skills: z.array(z.string()).optional() }) + .parse(await response.json()); + return payload.watched_skills ?? skills; } -const LOCAL_AUTHENTICATION: DashboardHostAdapter["authentication"] = { - useSession() { - return { - status: "authenticated", - user: { - name: "Admin Node", - subtitle: "Active", - }, - }; - }, -}; - -const LOCAL_QUERIES: DashboardHostAdapter["queries"] = { - async fetchOverview() { - return mapOverviewModel(await fetchOverview()); - }, - async fetchSkills() { - return mapSkillsModel(await fetchOverview()); - }, - async fetchAnalytics() { - return mapAnalyticsModel(await fetchAnalytics()); - }, - fetchRuntimeHealth, -}; - -const LOCAL_NAVIGATION: DashboardHostAdapter["navigation"] = { - upgrade: "https://selftune.dev/pricing", - docs: "https://docs.selftune.dev", - cloudDashboard: "https://selftune.dev", - openUpgrade() { - if (typeof window !== "undefined") { - window.open("https://selftune.dev/pricing", "_blank", "noopener,noreferrer"); - } - }, -}; - -const LOCAL_MUTATIONS: DashboardHostAdapter["mutations"] = { +const LOCAL_MUTATIONS: NonNullable["mutations"] = { updateOverviewWatchlist, }; -const LOCAL_PERMISSIONS: DashboardHostAdapter["permissions"] = { - can(feature) { - return LOCAL_FEATURES[feature]?.access === "available"; - }, -}; +const CorrectionStudySignalSchema = z.object({ + candidate_id: z.string(), + evidence_level: z.enum(["E0", "E0.5", "E1", "E2"]), + observed_failure: z.string().optional(), + correction_intent: z.string().optional(), + proposed_change: z + .object({ diff: z.string().optional(), summary: z.string().optional() }) + .nullable() + .optional(), + evaluation: z + .object({ summary: z.string().optional(), regressions: z.array(z.string()).optional() }) + .nullable() + .optional(), + limitations: z.array(z.string()).optional(), + manifest_digest: z.string().optional(), + provenance: z.array(z.string()).optional(), + terminal: z.boolean().optional(), +}); -const LOCAL_CORRECTION_STUDIES: NonNullable = { +const CorrectionStudyPageSchema = z.object({ + items: z.array(CorrectionStudySignalSchema).optional(), +}); + +const LOCAL_CORRECTION_STUDIES: NonNullable = { access: "available", async list(limit = 25) { const response = await fetch( `/api/v2/correction-studies/reviews?limit=${Math.min(Math.max(1, limit), 128)}`, ); if (!response.ok) throw new Error("Correction studies are unavailable."); - const page = (await response.json()) as { items?: Array> }; + const page = CorrectionStudyPageSchema.parse(await response.json()); return (page.items ?? []).map((signal) => { - const rawEvidence = String(signal.evidence_level); - const evidenceLevel = ["E0", "E0.5", "E1", "E2"].includes(rawEvidence) - ? (rawEvidence as "E0" | "E0.5" | "E1" | "E2") - : "E0"; - const proposed = - typeof signal.proposed_change === "object" && signal.proposed_change !== null - ? (signal.proposed_change as Record) - : null; - const evaluation = - typeof signal.evaluation === "object" && signal.evaluation !== null - ? (signal.evaluation as Record) - : null; + const evidenceLevel = signal.evidence_level; + const proposed = signal.proposed_change ?? null; + const evaluation = signal.evaluation ?? null; const available = Boolean(signal.manifest_digest) && !signal.terminal; const availability = available ? { available: true as const } @@ -1271,31 +1328,27 @@ const LOCAL_CORRECTION_STUDIES: NonNullable = { - access: "unavailable", - reason: "Correction studies require connected local agent data.", -}; - -export function createLocalHostAdapter( +export function createLocalDashboardModules( host: Extract = "local", profiles?: ServerProfileController, -): DashboardHostAdapter { - const isSelfHost = host === "selfhost"; +): DashboardHostModules { return { - ...localHostIdentity(host), - authentication: LOCAL_AUTHENTICATION, - queries: LOCAL_QUERIES, - navigation: LOCAL_NAVIGATION, - mutations: LOCAL_MUTATIONS, - permissions: LOCAL_PERMISSIONS, - library: isSelfHost ? SELF_HOST_LIBRARY : LOCAL_LIBRARY, - projects: isSelfHost ? SELF_HOST_PROJECTS : LOCAL_PROJECTS, - decisions: isSelfHost ? SELF_HOST_DECISIONS : LOCAL_DECISIONS, - correctionStudies: isSelfHost ? SELF_HOST_CORRECTION_STUDIES : LOCAL_CORRECTION_STUDIES, - profiles, + capability: localHostIdentity(host), + skillSets: { library: LOCAL_LIBRARY, projects: LOCAL_PROJECTS }, + skills: { + host, + library: LOCAL_LIBRARY, + decisions: LOCAL_DECISIONS, + correctionStudies: LOCAL_CORRECTION_STUDIES, + }, + plugins: { plugins: LOCAL_PLUGINS }, + recipientShares: {}, + teamCollaboration: { collaboration: LOCAL_TEAM_COLLABORATION }, + overview: { mutations: LOCAL_MUTATIONS }, + chrome: { profiles }, }; } -export const localHostAdapter = createLocalHostAdapter(); -export const selfHostAdapter = createLocalHostAdapter("selfhost"); -export const LOCAL_CAPABILITIES = capabilitiesFromAdapter(localHostAdapter); -export const SELF_HOST_CAPABILITIES = capabilitiesFromAdapter(selfHostAdapter); +export const localDashboardModules = createLocalDashboardModules(); +export const selfHostDashboardModules = createLocalDashboardModules("selfhost"); +export const LOCAL_CAPABILITIES = capabilitiesFromModule(localDashboardModules.capability); +export const SELF_HOST_CAPABILITIES = capabilitiesFromModule(selfHostDashboardModules.capability); diff --git a/apps/local-dashboard/src/hooks/useLibrary.ts b/apps/local-dashboard/src/hooks/useLibrary.ts index 42bf8daa..69049d36 100644 --- a/apps/local-dashboard/src/hooks/useLibrary.ts +++ b/apps/local-dashboard/src/hooks/useLibrary.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { applySkillSourceMerge, + applyLibrarySkillLicense, applySkillSourceUpdate, backupLibrarySkill, fetchLibrary, @@ -9,6 +10,7 @@ import { shareLibrarySkill, prepareSkillSourceMerge, previewSkillSourceUpdate, + previewLibrarySkillLicense, } from "../api"; import { reactiveMutationOptions, @@ -39,6 +41,20 @@ export function useShareLibrarySkill() { return useMutation({ mutationFn: shareLibrarySkill }); } +export function usePreviewLibrarySkillLicense() { + return useMutation({ mutationFn: previewLibrarySkillLicense }); +} + +export function useApplyLibrarySkillLicense() { + const queryClient = useQueryClient(); + return useMutation( + reactiveMutationOptions(queryClient, { + mutationFn: applyLibrarySkillLicense, + resources: sourceUpdateResources.apply, + }), + ); +} + export function usePreviewSkillSourceUpdate() { const queryClient = useQueryClient(); return useMutation( diff --git a/apps/local-dashboard/src/hooks/usePlugins.ts b/apps/local-dashboard/src/hooks/usePlugins.ts new file mode 100644 index 00000000..3842fb3e --- /dev/null +++ b/apps/local-dashboard/src/hooks/usePlugins.ts @@ -0,0 +1,17 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { fetchPlugins, managePlugin } from "../api"; + +export const PLUGINS_QUERY_KEY = ["plugins"] as const; + +export function usePlugins() { + return useQuery({ queryKey: PLUGINS_QUERY_KEY, queryFn: fetchPlugins }); +} + +export function useManagePlugin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: managePlugin, + onSuccess: (receipt) => queryClient.setQueryData(PLUGINS_QUERY_KEY, receipt.inventory), + }); +} diff --git a/apps/local-dashboard/src/hooks/useSkillSets.ts b/apps/local-dashboard/src/hooks/useSkillSets.ts index 5e1a7f2a..b8a28d71 100644 --- a/apps/local-dashboard/src/hooks/useSkillSets.ts +++ b/apps/local-dashboard/src/hooks/useSkillSets.ts @@ -4,12 +4,20 @@ import { applyProjectSkillSet, applyProjectProvision, createProjectSkillSet, + deleteProjectSkillSet, deriveProjectSkillSet, exportProjectSkillSet, + exportProjectSkillSetPlugin, + fetchProjectSkillSetPacks, fetchSkillSets, + importProjectSkillSetPack, + installProjectSkillSetPlugin, previewProjectSkillSet, + previewProjectSkillSetPluginInstall, previewProjectProvision, + previewProjectSkillSetPack, rollbackProjectSkillSet, + revokeProjectSkillSetPack, shareProjectSkillSet, updateProjectSkillSet, } from "../api"; @@ -42,6 +50,16 @@ export function useUpdateSkillSet() { ); } +export function useDeleteSkillSet() { + const queryClient = useQueryClient(); + return useMutation( + reactiveMutationOptions(queryClient, { + mutationFn: deleteProjectSkillSet, + resources: projectSkillSetResources.remove, + }), + ); +} + export function useDeriveSkillSet() { const queryClient = useQueryClient(); return useMutation( @@ -62,6 +80,46 @@ export function useExportSkillSet() { ); } +export function useExportSkillSetPlugin() { + return useMutation({ mutationFn: exportProjectSkillSetPlugin }); +} + +export function usePreviewSkillSetPluginInstall() { + return useMutation({ mutationFn: previewProjectSkillSetPluginInstall }); +} + +export function useInstallSkillSetPlugin() { + return useMutation({ mutationFn: installProjectSkillSetPlugin }); +} + +export function usePreviewSkillSetPack() { + return useMutation({ mutationFn: previewProjectSkillSetPack }); +} + +export function useImportSkillSetPack() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: importProjectSkillSetPack, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["skill-sets"] }), + }); +} + +export function useSkillSetPacks(enabled: boolean) { + return useQuery({ + queryKey: ["skill-set-packs"], + queryFn: fetchProjectSkillSetPacks, + enabled, + }); +} + +export function useRevokeSkillSetPack() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: revokeProjectSkillSetPack, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["skill-set-packs"] }), + }); +} + export function useShareSkillSet() { return useMutation({ mutationFn: shareProjectSkillSet }); } diff --git a/apps/local-dashboard/src/lib/sync-destination.ts b/apps/local-dashboard/src/lib/sync-destination.ts index 15851c7f..81edeb78 100644 --- a/apps/local-dashboard/src/lib/sync-destination.ts +++ b/apps/local-dashboard/src/lib/sync-destination.ts @@ -1,4 +1,4 @@ -export const SELFTUNE_CLOUD_SYNC_URL = "https://api.selftune.dev"; +export const SELFTUNE_CLOUD_SYNC_URL = "https://cloud.selftune.dev"; export type SyncDestination = "cloud" | "self_hosted"; @@ -18,7 +18,7 @@ export function syncDestinationFromUrl(url: string): SyncDestination { if (!url.trim()) return "cloud"; try { const hostname = new URL(url).hostname.toLowerCase(); - return hostname === "api.selftune.dev" || hostname.endsWith("-api.selftune.dev") + return hostname === "cloud.selftune.dev" || hostname === "api.selftune.dev" ? "cloud" : "self_hosted"; } catch { @@ -38,9 +38,15 @@ export function syncDestinationCopy(destination: SyncDestination): SyncDestinati notConnected: `Not connected to ${name}`, checking: `Checking ${name} integrity...`, unavailable: `${name} unavailable`, - synced: `Synced to ${name}`, + synced: destination === "cloud" ? "Cloud inventory updated" : `Synced to ${name}`, connectFailed: `Could not connect to ${name}`, - previewFailed: `Could not preview backup for ${name}`, - syncFailed: `Could not sync to ${name}`, + previewFailed: + destination === "cloud" + ? "Could not inspect the local inventory" + : `Could not preview backup for ${name}`, + syncFailed: + destination === "cloud" + ? "Could not update the Cloud inventory" + : `Could not sync to ${name}`, }; } diff --git a/apps/local-dashboard/src/local-library-transfer-actions.test.ts b/apps/local-dashboard/src/local-library-transfer-actions.test.ts deleted file mode 100644 index e5b28e4d..00000000 --- a/apps/local-dashboard/src/local-library-transfer-actions.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - executeLocalShare, - LOCAL_SHARE_CAPABILITIES, - LOCAL_SHARE_LINK_ONLY, -} from "./local-library-transfer-actions"; - -describe("local Cloud share capabilities", () => { - it("advertises reusable copy links as the only supported share mode", () => { - expect(LOCAL_SHARE_CAPABILITIES).toEqual({ - supportedDeliveryMethods: ["copy_link"], - supportedShareModes: ["reusable_unlisted"], - }); - }); - - it.each([ - { delivery: "email" as const, mode: "private_single_claim" as const }, - { delivery: "copy_link" as const, mode: "private_single_claim" as const }, - ])("rejects unsupported $delivery/$mode execution", async (input) => { - let calls = 0; - - await expect( - executeLocalShare(input, async () => { - calls += 1; - return { shareUrl: "https://cloud.selftune.dev/shared/unexpected" }; - }), - ).rejects.toThrow(LOCAL_SHARE_LINK_ONLY); - expect(calls).toBe(0); - }); -}); diff --git a/apps/local-dashboard/src/local-library-transfer-actions.ts b/apps/local-dashboard/src/local-library-transfer-actions.ts index 93b7c0bd..68ee343d 100644 --- a/apps/local-dashboard/src/local-library-transfer-actions.ts +++ b/apps/local-dashboard/src/local-library-transfer-actions.ts @@ -7,28 +7,6 @@ import { } from "./hooks/useLibrary"; import { useSettings } from "./hooks/useSettings"; -export const LOCAL_SHARE_LINK_ONLY = - "Cloud sharing currently supports reusable copy links only. Email, member, private-claim, and workspace sharing are not available yet."; - -export const LOCAL_SHARE_CAPABILITIES = { - supportedDeliveryMethods: ["copy_link"], - supportedShareModes: ["reusable_unlisted"], -} as const; - -interface LocalShareRequest { - readonly delivery: "copy_link" | "email"; - readonly mode: "reusable_unlisted" | "private_single_claim"; -} - -export function executeLocalShare( - input: TInput, - execute: (value: TInput) => Promise, -): Promise { - return input.delivery === "copy_link" && input.mode === "reusable_unlisted" - ? execute(input) - : Promise.reject(new Error(LOCAL_SHARE_LINK_ONLY)); -} - export function useLocalLibraryTransferActions(): Pick< DashboardLibraryActions, "backup" | "share" | "install" | "installTargets" @@ -60,9 +38,8 @@ export function useLocalLibraryTransferActions(): Pick< settings.data?.remote_library.configured === true ? { access: "available", - ...LOCAL_SHARE_CAPABILITIES, isPending: share.isPending, - execute: (input) => executeLocalShare(input, share.mutateAsync), + execute: (input) => share.mutateAsync(input), } : { access: "upgrade", diff --git a/apps/local-dashboard/src/pages/Settings.tsx b/apps/local-dashboard/src/pages/Settings.tsx index 18c88f1e..60b5e8c9 100644 --- a/apps/local-dashboard/src/pages/Settings.tsx +++ b/apps/local-dashboard/src/pages/Settings.tsx @@ -99,13 +99,13 @@ type SettingsSectionId = const SETTINGS_SECTIONS: Array<{ id: SettingsSectionId; label: string; - availability: "always" | "remote" | "cloud" | "desktop"; + availability: "always" | "workspace" | "self_hosted" | "desktop"; }> = [ { id: "connections", label: "Connections", availability: "always" }, { id: "billing", label: "Billing", availability: "always" }, - { id: "remote-library", label: "Sync & Backup", availability: "always" }, - { id: "workspace", label: "Workspace", availability: "cloud" }, - { id: "private-sharing", label: "Private sharing", availability: "remote" }, + { id: "remote-library", label: "Cloud & self-hosting", availability: "always" }, + { id: "workspace", label: "Workspace", availability: "workspace" }, + { id: "private-sharing", label: "Private sharing", availability: "self_hosted" }, { id: "background-service", label: "Background service", @@ -303,10 +303,11 @@ function SettingsSkeleton() { export function Settings() { const [searchParams, setSearchParams] = useSearchParams(); const settingsQuery = useSettings(); - const cloudWorkspaceConfigured = Boolean( + const selfHostedWorkspaceConfigured = Boolean( settingsQuery.data?.remote_library.configured && - syncDestinationFromUrl(settingsQuery.data.remote_library.url ?? "") === "cloud", + syncDestinationFromUrl(settingsQuery.data.remote_library.url ?? "") === "self_hosted", ); + const connectedWorkspaceConfigured = Boolean(settingsQuery.data?.remote_library.configured); const updateSchedule = useUpdateScheduleSettings(); const updateRemote = useUpdateRemoteLibrarySettings(); const linkCloudAccount = useLinkCloudAccount(); @@ -322,10 +323,10 @@ export function Settings() { ); const createShare = useCreateRemoteLibraryShare(); const shareAction = useRemoteLibraryShareAction(); - const workspacePolicies = useWorkspaceSkillSetPolicies(cloudWorkspaceConfigured); + const workspacePolicies = useWorkspaceSkillSetPolicies(selfHostedWorkspaceConfigured); const updateWorkspacePolicy = useUpdateWorkspaceSkillSetPolicy(); const resetWorkspacePolicy = useResetWorkspaceSkillSetPolicy(); - const workspaceMembers = useWorkspaceMembers(cloudWorkspaceConfigured); + const workspaceMembers = useWorkspaceMembers(connectedWorkspaceConfigured); const inviteWorkspaceMember = useInviteWorkspaceMember(); const updateWorkspaceMemberRole = useUpdateWorkspaceMemberRole(); const removeWorkspaceMember = useRemoveWorkspaceMember(); @@ -439,8 +440,8 @@ export function Settings() { const availableSections = SETTINGS_SECTIONS.filter( (section) => section.availability === "always" || - (section.availability === "remote" && remoteLibraryConfigured) || - (section.availability === "cloud" && cloudWorkspaceConfigured) || + (section.availability === "workspace" && connectedWorkspaceConfigured) || + (section.availability === "self_hosted" && selfHostedWorkspaceConfigured) || (section.availability === "desktop" && hasBackgroundService), ); @@ -489,11 +490,11 @@ export function Settings() { onSuccess: ({ first_backup: firstBackup }) => { if (firstBackup.status === "completed") { toast.success("SelfTune Cloud connected", { - description: `First backup complete: ${firstBackup.uploaded} uploaded, ${firstBackup.unchanged} unchanged.`, + description: `Cloud inventory updated: ${firstBackup.uploaded} reported, ${firstBackup.unchanged} unchanged.`, }); } else { toast.warning("SelfTune Cloud connected", { - description: `The first backup did not finish: ${firstBackup.message}`, + description: `The first inventory update did not finish: ${firstBackup.message}`, }); } }, @@ -562,7 +563,7 @@ export function Settings() { setSearchParams(nextSearchParams, { replace: true }); }} title="Settings" - description="Manage connections, Sync & Backup, and background automation." + description="Manage Cloud, self-hosted backups, and background automation." >

@@ -626,7 +627,7 @@ export function Settings() { @@ -638,7 +639,7 @@ export function Settings() {

- Sync & Backup + Cloud connection & self-hosted backup

- Your Library is local. Sync & Backup stores a copy in SelfTune Cloud or on your - self-hosted server. + Your Library stays local. Cloud receives only a privacy-safe inventory; a + self-hosted server can store a backup when you choose it.

- + {remoteDraft.destination === "self_hosted" ? ( + + ) : null}
@@ -830,36 +835,41 @@ export function Settings() { )}
- {REMOTE_PREFERENCES.map((preference) => ( -
-
-

{preference.label}

-

{preference.description}

-
- - setRemoteDraft((current) => - current - ? { - ...current, - preferences: { - ...current.preferences, - [preference.key]: checked, - }, - } - : current, - ) - } - aria-label={`${preference.label} backup`} - /> -
- ))} + {remoteDraft.destination === "self_hosted" + ? REMOTE_PREFERENCES.map((preference) => ( +
+
+

{preference.label}

+

{preference.description}

+
+ + setRemoteDraft((current) => + current + ? { + ...current, + preferences: { + ...current.preferences, + [preference.key]: checked, + }, + } + : current, + ) + } + aria-label={`${preference.label} backup`} + /> +
+ )) + : null}
- Raw transcripts are never synced. + + {remoteDraft.destination === "cloud" + ? "Skill contents, paths, prompts, sessions, and evaluations stay local." + : "Raw transcripts are never synced."}
{previewRemote.data ? ( @@ -893,7 +903,7 @@ export function Settings() {
- {remoteStatus.data ? ( + {remoteStatus.data?.diagnostics ? ( <> {remoteStatus.data.diagnostics.objectCount} objects ·{" "} {remoteStatus.data.diagnostics.snapshotCount} snapshots ·{" "} @@ -971,7 +981,7 @@ export function Settings() {
- {remoteStatus.data?.diagnostics.missingObjects.length ? ( + {remoteStatus.data?.diagnostics?.missingObjects.length ? (

{configuredDestinationName} is missing{" "} {remoteStatus.data.diagnostics.missingObjects.length} objects. Restore from a @@ -984,116 +994,121 @@ export function Settings() {

- {cloudWorkspaceConfigured ? ( + {connectedWorkspaceConfigured ? (
-
-
-

- Workspace Skill Sets -

- Shared with every member -
-

- Workspace Skill Sets sync automatically. Admins can allow them, require a reviewed - install, block them, or mark them as required. -

-
-
- {workspacePolicies.isLoading ? ( -
- - + {selfHostedWorkspaceConfigured ? ( + <> +
+
+

+ Workspace Skill Sets +

+ Shared with every member +
+

+ Workspace Skill Sets sync automatically. Admins can allow them, require a + reviewed install, block them, or mark them as required. +

- ) : workspacePolicies.data?.policies.length ? ( - workspacePolicies.data.policies.map((policy) => ( -
-
-

- {policy.skill_set_name} -

-

- {policy.reason || "Available to all workspace members after sync."} -

+
+ {workspacePolicies.isLoading ? ( +
+ +
- - -
- )) - ) : ( -

- Sync or create a Cloud Skill Set to make it available to this workspace. -

- )} -
+
+

+ {policy.skill_set_name} +

+

+ {policy.reason || "Available to all workspace members after sync."} +

+
+ + +
+ )) + ) : ( +

+ Sync or create a Cloud Skill Set to make it available to this workspace. +

+ )} +
+ + ) : null}

Members

diff --git a/apps/local-dashboard/src/pages/Team.test.ts b/apps/local-dashboard/src/pages/Team.test.ts new file mode 100644 index 00000000..a20cb368 --- /dev/null +++ b/apps/local-dashboard/src/pages/Team.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { teamFailureContent } from "./Team"; + +describe("Team failure messaging", () => { + it("does not tell a linked user to reconnect when the overview endpoint fails", () => { + expect(teamFailureContent(true, new Error("Cloud returned an invalid response."))).toEqual({ + title: "Team data is temporarily unavailable", + description: "You’re connected to SelfTune Cloud, but we couldn’t load your workspace.", + detail: "Cloud returned an invalid response.", + action: "retry", + }); + }); + + it("offers connection setup only when Desktop confirms there is no Cloud link", () => { + expect(teamFailureContent(false, null)).toMatchObject({ + title: "Connect Cloud to see your team", + detail: null, + action: "connect", + }); + }); +}); diff --git a/apps/local-dashboard/src/pages/Team.tsx b/apps/local-dashboard/src/pages/Team.tsx new file mode 100644 index 00000000..ee0b907b --- /dev/null +++ b/apps/local-dashboard/src/pages/Team.tsx @@ -0,0 +1,343 @@ +import { Badge } from "@selftune/ui/primitives"; +import { PageHeader, PageScaffold } from "@selftune/ui/components"; +import { useQuery } from "@tanstack/react-query"; +import { + ActivityIcon, + ArrowRightIcon, + CheckCircle2Icon, + CircleAlertIcon, + CloudIcon, + LaptopIcon, + RefreshCwIcon, + Settings2Icon, + SparklesIcon, + UsersIcon, +} from "lucide-react"; +import { Link } from "react-router-dom"; + +import { fetchWorkspaceTeamOverview } from "@/api"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSettings } from "@/hooks/useSettings"; +import type { WorkspaceTeamOverview } from "@/types"; + +type TeamSkill = WorkspaceTeamOverview["skills"][number]; + +const recommendationCopy: Record< + TeamSkill["recommendation"], + { + label: string; + detail: string; + tone: "default" | "secondary" | "destructive" | "outline"; + } +> = { + update: { + label: "Update available", + detail: "Bring every installation onto the current revision.", + tone: "destructive", + }, + review_usage: { + label: "Review usage", + detail: "No recent activity signal is visible. Check whether this skill still belongs here.", + tone: "outline", + }, + healthy: { + label: "Healthy", + detail: "Installed, current, and recently used across the team.", + tone: "default", + }, +}; + +function displayName(member: WorkspaceTeamOverview["members"][number] | undefined): string { + return member?.name?.trim() || member?.email || "Unknown member"; +} + +function usageLabel(value: TeamSkill["usage_status"]): string { + if (value === "recent") return "Used in the last 30 days"; + if (value === "stale") return "No use in the last 30 days"; + return "No usage signal"; +} + +function TeamSkeleton() { + return ( + + + + + + ); +} + +export function teamFailureContent(cloudLinked: boolean | undefined, error: unknown) { + if (cloudLinked === false) { + return { + title: "Connect Cloud to see your team", + description: + "Team is the shared operational view for access, installations, usage, and updates. Your skill contents and raw sessions stay on each device.", + detail: null, + action: "connect" as const, + }; + } + 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, + action: "retry" as const, + }; +} + +export function Team() { + const settings = useSettings(); + const overview = useQuery({ + queryKey: ["workspace-team-overview"], + queryFn: fetchWorkspaceTeamOverview, + retry: false, + refetchInterval: 60_000, + }); + + if (overview.isLoading) return ; + if (overview.error || !overview.data) { + const failure = teamFailureContent(settings.data?.cloud_account.linked, overview.error); + return ( + + + + + {failure.action === "connect" ? : } + {failure.action === "retry" && ( + + )} + + {failure.action === "retry" && ( + + Cloud connected + + )} + + {failure.title} + + {failure.description} + + + + {failure.action === "connect" ? ( + + ) : ( +
+ + +
+ )} + {failure.detail && ( +
+ + View technical details + +

+ {failure.detail} +

+
+ )} +
+
+
+ ); + } + + const data = overview.data; + const deviceCount = data.members.reduce((total, member) => total + member.devices.length, 0); + const attentionCount = data.skills.filter( + ({ recommendation }) => recommendation !== "healthy", + ).length; + + return ( + + }> + Manage members + + } + /> + +
+ {[ + { + icon: UsersIcon, + value: data.members.length, + label: "People with access", + }, + { icon: LaptopIcon, value: deviceCount, label: "Linked devices" }, + { + icon: CircleAlertIcon, + value: attentionCount, + label: "Skills needing attention", + }, + ].map(({ icon: Icon, value, label }) => ( +
+ + + +
+

{value}

+

{label}

+
+
+ ))} +
+ +
+
+
+

People and devices

+

+ Workspace access is shared. Installation stays visible per person and device. +

+
+ Metadata only +
+
+ {data.members.map((member) => ( +
+
+

{displayName(member)}

+

{member.role}

+
+
+ {member.devices.length === 0 ? ( + No linked device yet + ) : ( + member.devices.map((device) => ( + + + {device.name} + + {device.installed_skills} skills · {device.platform} + + + )) + )} +
+
+ ))} +
+
+ +
+
+

Skill coverage

+

+ Installation, bounded usage status, and update health in one place. +

+
+ {data.skills.length === 0 ? ( +
+ +

No team manifest yet

+

+ Run a Cloud sync from a linked desktop to publish privacy-safe inventory. +

+
+ ) : ( +
+ {data.skills.map((skill) => { + const recommendation = recommendationCopy[skill.recommendation]; + const installers = skill.installed_by_user_ids.map((userId) => + displayName(data.members.find((member) => member.user_id === userId)), + ); + return ( +
+
+
+

{skill.identity}

+ {recommendation.label} +
+

{recommendation.detail}

+
+
+

+ Installed by +

+

{skill.installed_by_user_ids.length} teammate(s)

+

+ {installers.length > 0 ? installers.join(", ") : "Not installed"} +

+
+
+

+ Usage status +

+

+ {skill.usage_status === "recent" ? ( + + ) : ( + + )} + {usageLabel(skill.usage_status)} +

+

+ {skill.update_available_count} outdated installation + {skill.update_available_count === 1 ? "" : "s"} +

+
+
+ ); + })} +
+ )} +
+ +

+ + Only inventory and aggregate last-used timestamps are reported. Prompts, session content, + evaluations, and skill files stay local. +

+
+ ); +} diff --git a/apps/local-dashboard/src/shared-app-routes.test.ts b/apps/local-dashboard/src/shared-app-routes.test.ts index 8c43be9c..343dcec6 100644 --- a/apps/local-dashboard/src/shared-app-routes.test.ts +++ b/apps/local-dashboard/src/shared-app-routes.test.ts @@ -35,16 +35,16 @@ describe("Local app-core composition", () => { const routes = composeLocalPrimaryRoutes([ { id: "settings", path: "/old-settings", label: "Settings", tooltip: "Settings" }, { id: "projects", path: "/old-projects", label: "Old projects", tooltip: "Old" }, + { id: "plugins", path: "/plugins", label: "Plugins", tooltip: "Plugins" }, { id: "skills", path: "/old-skills", label: "Old skills", tooltip: "Old" }, + { id: "collaboration", path: "/old-team", label: "Old team", tooltip: "Old" }, { id: "registry", path: "/registry", label: "Registry", tooltip: "Registry" }, { id: "analytics", path: "/insights", label: "Analytics", tooltip: "Analytics" }, ]); - expect(routes.map(({ id }) => id)).toEqual([ - ...APP_CORE_SHELL_NAVIGATION.map(({ id }) => id), - "settings", - "analytics", - ]); + const expected: string[] = APP_CORE_SHELL_NAVIGATION.map(({ id }) => id); + expected.splice(expected.indexOf("projects") + 1, 0, "plugins"); + expect(routes.map(({ id }) => id)).toEqual([...expected, "settings", "analytics"]); for (const navigation of APP_CORE_SHELL_NAVIGATION) { expect(routes.find(({ id }) => id === navigation.id)).toMatchObject(navigation); } diff --git a/apps/local-dashboard/src/shared-app-routes.ts b/apps/local-dashboard/src/shared-app-routes.ts index 00da3cfd..45649817 100644 --- a/apps/local-dashboard/src/shared-app-routes.ts +++ b/apps/local-dashboard/src/shared-app-routes.ts @@ -22,7 +22,7 @@ export const LOCAL_APP_CORE_NAVIGATION = APP_CORE_SHELL_NAVIGATION; export const LOCAL_APP_CORE_ROUTE_REGISTRY = APP_CORE_ROUTE_REGISTRY; -const LOCAL_HOST_PRIMARY_ROUTE_IDS = new Set(["analytics", "settings"]); +const LOCAL_HOST_PRIMARY_ROUTE_IDS = new Set(["plugins", "analytics", "settings"]); interface LocalPrimaryRouteMetadata { readonly id: string; @@ -53,7 +53,16 @@ export function composeLocalPrimaryRoutes LOCAL_HOST_PRIMARY_ROUTE_IDS.has(id)); - return [...sharedRoutes, ...hostRoutes]; + const pluginRoute = hostRoutes.find(({ id }) => id === "plugins"); + const trailingHostRoutes = hostRoutes.filter(({ id }) => id !== "plugins"); + const projectIndex = sharedRoutes.findIndex(({ id }) => id === "projects"); + if (!pluginRoute || projectIndex < 0) return [...sharedRoutes, ...trailingHostRoutes]; + return [ + ...sharedRoutes.slice(0, projectIndex + 1), + pluginRoute, + ...sharedRoutes.slice(projectIndex + 1), + ...trailingHostRoutes, + ]; } export function getLocalAppCoreNavigation(routeId: string) { diff --git a/apps/local-dashboard/src/types.ts b/apps/local-dashboard/src/types.ts index f3ade48c..8e42726c 100644 --- a/apps/local-dashboard/src/types.ts +++ b/apps/local-dashboard/src/types.ts @@ -147,6 +147,7 @@ export type { WorkspaceMember, WorkspaceMemberRole, WorkspaceMembersResponse, + WorkspaceTeamOverview, ApplySkillSetRequest, PlanSkillSetRequest, PortfolioQuarantineBatchResult, diff --git a/apps/local/CHANGELOG.md b/apps/local/CHANGELOG.md deleted file mode 100644 index 1181c2a2..00000000 --- a/apps/local/CHANGELOG.md +++ /dev/null @@ -1,12 +0,0 @@ -# @selftune/local - -## 1.0.1 - -### Patch Changes - -- Updated dependencies [[`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262)]: - - @selftune/dashboard-core@1.1.0 - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 - - @selftune/orchestration@1.0.1 - - @selftune/harness-registry@1.0.1 diff --git a/apps/local/package.json b/apps/local/package.json index cb30910f..44ccb0fc 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/local", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "SelfTune local HTTP, daemon, and service host", "license": "MIT", @@ -10,7 +10,7 @@ }, "scripts": { "start": "bun run src/dashboard-server.ts", - "test": "bun test src/routes/library-transfer.test.ts 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/library-transfer-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/dashboard ../../tests/runtime", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/local/src/cloud-billing.ts b/apps/local/src/cloud-billing.ts index 458a1faf..4b27cbb3 100644 --- a/apps/local/src/cloud-billing.ts +++ b/apps/local/src/cloud-billing.ts @@ -31,7 +31,12 @@ const BillingPlan = Schema.Struct({ features: Schema.Array(Schema.String), highlighted: Schema.Boolean, seats: Schema.optional( - Schema.NullOr(Schema.Struct({ minimum: PositiveInt, label: Schema.NullOr(Schema.String) })), + Schema.NullOr( + Schema.Struct({ + minimum: PositiveInt, + label: Schema.NullOr(Schema.String), + }), + ), ), }); const BillingStatus = Schema.Struct({ @@ -51,6 +56,12 @@ const BillingCheckoutFinalizeResult = Schema.Struct({ sessionStatus: Schema.NullOr(Schema.String), paymentStatus: Schema.NullOr(Schema.String), }); +const HostedState = Schema.Struct({ + workspaceId: Schema.String, + plan: Schema.Literals(["free", "pro", "team"]), + status: Schema.Literals(["none", "active", "canceled", "past_due", "trialing", "unpaid"]), + currentPeriodEnd: Schema.NullOr(Schema.Number), +}); const CloudBillingErrorResponse = Schema.Struct({ error: Schema.Union([ Schema.String, @@ -66,7 +77,7 @@ const CloudBillingErrorResponse = Schema.Struct({ function isCloudRemote(url: string): boolean { try { const hostname = new URL(url).hostname.toLowerCase(); - return hostname === "api.selftune.dev" || hostname.endsWith("-api.selftune.dev"); + return hostname === "cloud.selftune.dev"; } catch { return false; } @@ -162,7 +173,47 @@ async function billingRequest(input: { function decodeBillingStatus(body: unknown): DesktopBillingStatus { try { - return Schema.decodeUnknownSync(BillingStatus)(body); + const state = Schema.decodeUnknownSync(HostedState)(body); + return { + plan: state.plan, + subscriptionStatus: state.status, + currentPeriodEnd: + state.currentPeriodEnd === null ? null : new Date(state.currentPeriodEnd).toISOString(), + trialEnd: null, + seatCount: 1, + hasStripeCustomer: state.status !== "none", + canManageBilling: true, + availablePlans: [ + { + id: "free", + name: "Community", + price: "$0", + period: null, + description: "The complete local-first skill manager.", + features: ["Local library", "Skill Sets", "Project-scoped installs"], + highlighted: false, + }, + { + id: "pro", + name: "Pro", + price: "$19", + period: "/month", + description: "Subscription access and secure hosted sharing.", + features: ["Secure sharing", "Update summaries", "Device management"], + highlighted: true, + }, + { + id: "team", + name: "Team", + price: "$49", + period: "/month", + description: "Shared access for teams managing the same skill estate.", + features: ["Everything in Pro", "Team workspace"], + highlighted: false, + seats: { minimum: 1, label: "seats" }, + }, + ], + }; } catch { throw new CLIError( "SelfTune Cloud returned an invalid billing response.", @@ -202,7 +253,7 @@ function decodeBillingCheckoutFinalizeResult(body: unknown): DesktopBillingCheck } } -/** Keeps the stored Remote Library credential in the sidecar process. */ +/** Keeps the linked device credential in the sidecar process. */ export function makeCloudBillingOperations( configRoot: string, options: CloudBillingTransportOptions = {}, @@ -224,31 +275,34 @@ export function makeCloudBillingOperations( return { status: async (): Promise => { return request({ - path: "/api/v1/cloud/billing/status", + path: "/api/v1/desktop/state", method: "GET", }).then(decodeBillingStatus); }, checkout: async (input: DesktopBillingCheckoutRequest): Promise => { - return request({ - path: "/api/v1/cloud/billing/checkout", - method: "POST", - body: input, - }).then(decodeBillingSession); + return decodeBillingSession({ + url: `https://cloud.selftune.dev/?billing=${input.plan}`, + }); }, portal: async (): Promise => { - return request({ - path: "/api/v1/cloud/billing/portal", - method: "POST", - }).then(decodeBillingSession); + return decodeBillingSession({ + url: "https://cloud.selftune.dev/?billing=portal", + }); }, finalize: async ( input: DesktopBillingCheckoutFinalizeRequest, ): Promise => { - return request({ - path: "/api/v1/cloud/billing/checkout/finalize", - method: "POST", - body: { sessionId: input.sessionId }, - }).then(decodeBillingCheckoutFinalizeResult); + const billing = await request({ + path: "/api/v1/desktop/state", + method: "GET", + }).then(decodeBillingStatus); + return decodeBillingCheckoutFinalizeResult({ + 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 new file mode 100644 index 00000000..0410d6a4 --- /dev/null +++ b/apps/local/src/cloud-team-collaboration.ts @@ -0,0 +1,340 @@ +import * as Schema from "effect/Schema"; + +import type { + TeamCollaborationSnapshotModel, + TeamContributionStatusModel, + TeamRolloutPolicyModel, +} from "@selftune/dashboard-core/models"; +import { loadRemoteLibraryConfig } from "@selftune/runtime/remote-library-config"; + +const Sha256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const UtcTimestamp = Schema.String.check( + Schema.makeFilter((value) => { + const epochMillis = Date.parse(value); + return Number.isFinite(epochMillis) && new Date(epochMillis).toISOString() === value + ? undefined + : "Expected a canonical ISO-8601 UTC timestamp"; + }), +); +const RolloutPolicy = Schema.Literals(["manual", "notify", "automatic"]); +const ContributionStatus = Schema.Literals([ + "pending", + "rejected", + "adopted", + "stale", + "rolled_back", +]); +const ContributionFile = Schema.Struct({ + path: Schema.NonEmptyString, + hash: Sha256, + size: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), +}); +const ContributionChange = Schema.Struct({ + path: Schema.NonEmptyString, + kind: Schema.Literals(["added", "modified", "removed"]), + baseHash: Schema.NullOr(Sha256), + candidateHash: Schema.NullOr(Sha256), +}); +const RevisionContribution = Schema.Struct({ + id: Schema.String, + entryId: Schema.String, + entryName: Schema.NonEmptyString, + baseVersionId: Schema.String, + baseVersion: Schema.NonEmptyString, + candidateVersion: Schema.NonEmptyString, + candidateContentHash: Sha256, + files: Schema.Array(ContributionFile), + changes: Schema.Array(ContributionChange), + summary: Schema.NonEmptyString, + submittedBy: Schema.String, + submittedByName: Schema.NonEmptyString, + status: ContributionStatus, + reviewedBy: Schema.NullOr(Schema.String), + adoptedVersionId: Schema.NullOr(Schema.String), + createdAt: UtcTimestamp, + reviewedAt: Schema.NullOr(UtcTimestamp), +}); +const ManagedInstallation = Schema.Struct({ + id: Schema.String, + entryId: Schema.String, + entryName: Schema.NonEmptyString, + deviceId: Schema.String, + installedVersion: Schema.NonEmptyString, + installedContentHash: Schema.NullOr(Sha256), + latestVersion: Schema.NonEmptyString, + latestContentHash: Sha256, + rolloutPolicy: RolloutPolicy, + updateStatus: Schema.Literals([ + "current", + "update_available", + "updated", + "conflict", + "failed", + "rolled_back", + ]), + lastSyncedAt: UtcTimestamp, + lastConflictAt: Schema.NullOr(UtcTimestamp), + lastReceiptId: Schema.NullOr(Schema.String), +}); +const RegistryEntry = Schema.Struct({ + id: Schema.String, + name: Schema.NonEmptyString, + rolloutPolicy: RolloutPolicy, + currentVersion: Schema.NullOr(Schema.NonEmptyString), + pendingContributions: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + installations: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + conflicts: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), +}); +const CollaborationSnapshot = Schema.Struct({ + entries: Schema.Array(RegistryEntry), + contributions: Schema.Array(RevisionContribution), + installations: Schema.Array(ManagedInstallation), +}); +const RolloutPolicyResult = Schema.Struct({ + entryId: Schema.String, + policy: RolloutPolicy, +}); +const ContributionDecisionResult = Schema.Struct({ + id: Schema.String, + status: ContributionStatus, + adoptedVersionId: Schema.optionalKey(Schema.String), + restoredVersionId: Schema.optionalKey(Schema.String), + rolloutPolicy: Schema.optionalKey(RolloutPolicy), +}); +const CloudErrorResponse = Schema.Struct({ + error: Schema.Union([ + Schema.String, + Schema.Struct({ + code: Schema.optionalKey(Schema.String), + message: Schema.optionalKey(Schema.String), + suggestion: Schema.optionalKey(Schema.String), + retryable: Schema.optionalKey(Schema.Boolean), + }), + ]), +}); +const TeamRole = Schema.Literals(["viewer", "member", "admin", "owner"]); +const TeamStatus = Schema.Struct({ + currentUserId: Schema.String, + currentRole: TeamRole, + readOnly: Schema.Boolean, + seatUsage: Schema.Int, + seatLimit: Schema.NullOr(Schema.Int), + billingPath: Schema.String, + members: Schema.Array( + Schema.Struct({ + userId: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + role: TeamRole, + joinedAt: Schema.String, + }), + ), + invitations: Schema.Array( + Schema.Struct({ + id: Schema.String, + email: Schema.String, + role: TeamRole, + invitedBy: Schema.String, + invitedAt: Schema.String, + }), + ), +}); + +export interface TeamCollaborationAccessModel { + readonly currentRole: "viewer" | "member" | "admin" | "owner"; + readonly readOnly: boolean; +} + +export interface TeamRolloutPolicyResultModel { + readonly entryId: string; + readonly policy: TeamRolloutPolicyModel; +} + +export interface TeamContributionDecisionResultModel { + readonly id: string; + readonly status: TeamContributionStatusModel; + readonly adoptedVersionId?: string; + readonly restoredVersionId?: string; + readonly rolloutPolicy?: TeamRolloutPolicyModel; +} + +export class CloudTeamCollaborationError extends Schema.TaggedErrorClass()( + "CloudTeamCollaborationError", + { + code: Schema.String, + message: Schema.String, + status: Schema.Number, + suggestion: Schema.optionalKey(Schema.String), + retryable: Schema.Boolean, + }, +) {} + +export interface CloudTeamCollaborationTransportOptions { + readonly fetch?: typeof fetch; + readonly loadRemoteLibraryConfig?: typeof loadRemoteLibraryConfig; +} + +function invalidResponse(): CloudTeamCollaborationError { + return CloudTeamCollaborationError.make({ + code: "API_ERROR", + message: "SelfTune Cloud returned an invalid team collaboration response.", + status: 502, + suggestion: "Retry in a moment.", + retryable: true, + }); +} + +async function collaborationRequest(input: { + readonly configRoot: string; + readonly fetch: typeof fetch; + readonly loadConfig: typeof loadRemoteLibraryConfig; + readonly path: string; + readonly method: "GET" | "PATCH" | "POST"; + readonly body?: unknown; +}): Promise { + const remote = input.loadConfig(input.configRoot); + 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) }), + }); + } catch (cause) { + throw CloudTeamCollaborationError.make({ + code: "API_ERROR", + message: cause instanceof Error ? cause.message : "Unable to reach SelfTune Cloud.", + status: 503, + suggestion: "Check the Cloud connection and retry.", + retryable: true, + }); + } + + const responseText = await response.text(); + if (!response.ok) { + if (response.status === 404 && responseText.trim() === "404 Not Found") { + throw CloudTeamCollaborationError.make({ + code: "API_ERROR", + message: "The connected SelfTune deployment does not expose team collaboration yet.", + status: 502, + suggestion: "Deploy the current Cloud API, then retry.", + retryable: false, + }); + } + try { + const decoded = Schema.decodeUnknownSync(CloudErrorResponse)(JSON.parse(responseText)); + const error = typeof decoded.error === "string" ? { message: decoded.error } : decoded.error; + throw CloudTeamCollaborationError.make({ + 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, + }); + } catch (cause) { + if (cause instanceof CloudTeamCollaborationError) throw cause; + } + throw CloudTeamCollaborationError.make({ + 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, + }); + } + + try { + return JSON.parse(responseText); + } catch { + throw invalidResponse(); + } +} + +function decodeSnapshot(body: unknown): TeamCollaborationSnapshotModel { + try { + const decoded = Schema.decodeUnknownSync(CollaborationSnapshot)(body); + return { + entries: decoded.entries.map((entry) => ({ ...entry })), + contributions: decoded.contributions.map((contribution) => ({ + ...contribution, + files: contribution.files.map((file) => ({ ...file })), + changes: contribution.changes.map((change) => ({ ...change })), + })), + installations: decoded.installations.map((installation) => ({ ...installation })), + }; + } catch { + throw invalidResponse(); + } +} + +function decodeAccess(body: unknown): TeamCollaborationAccessModel { + try { + const status = Schema.decodeUnknownSync(TeamStatus)(body); + return { currentRole: status.currentRole, readOnly: status.readOnly }; + } catch { + throw invalidResponse(); + } +} + +function decodeRolloutPolicyResult(body: unknown): TeamRolloutPolicyResultModel { + try { + return Schema.decodeUnknownSync(RolloutPolicyResult)(body); + } catch { + throw invalidResponse(); + } +} + +function decodeDecisionResult(body: unknown): TeamContributionDecisionResultModel { + try { + return Schema.decodeUnknownSync(ContributionDecisionResult)(body); + } catch { + throw invalidResponse(); + } +} + +/** Retains the device credential in the sidecar and forwards only validated collaboration data. */ +export function makeCloudTeamCollaborationOperations( + configRoot: string, + options: CloudTeamCollaborationTransportOptions = {}, +) { + const fetchImplementation = options.fetch ?? fetch; + const loadConfig = options.loadRemoteLibraryConfig ?? loadRemoteLibraryConfig; + const request = ( + input: Omit[0], "configRoot" | "fetch" | "loadConfig">, + ) => + collaborationRequest({ + ...input, + configRoot, + fetch: fetchImplementation, + loadConfig, + }); + const decide = ( + contributionId: string, + action: "adopt" | "reject" | "rollback", + ): Promise => + request({ + path: `/api/v1/collaboration/contributions/${encodeURIComponent(contributionId)}/${action}`, + method: "POST", + }).then(decodeDecisionResult); + + return { + access: (): Promise => + request({ path: "/api/v1/cloud/team", method: "GET" }).then(decodeAccess), + snapshot: (): Promise => + request({ path: "/api/v1/collaboration", method: "GET" }).then(decodeSnapshot), + updateRolloutPolicy: ( + entryId: string, + policy: TeamRolloutPolicyModel, + ): Promise => + request({ + path: `/api/v1/collaboration/registry/${encodeURIComponent(entryId)}/rollout-policy`, + method: "PATCH", + body: { policy }, + }).then(decodeRolloutPolicyResult), + decide, + } as const; +} diff --git a/apps/local/src/daemon.ts b/apps/local/src/daemon.ts index 3027ec9b..08b08ed3 100644 --- a/apps/local/src/daemon.ts +++ b/apps/local/src/daemon.ts @@ -274,7 +274,6 @@ const acquireDaemon = Effect.fn("SelfTuneDaemon.acquire")(function* ( dependencies: DaemonStartDependencies = LIVE_START_DEPENDENCIES, ) { const instanceId = dependencies.createInstanceId(); - const shutdown = Promise.withResolvers(); const runtimeIdentity: DaemonRuntimeIdentity = { configDir: options.configDir, instanceId, @@ -328,7 +327,7 @@ const acquireDaemon = Effect.fn("SelfTuneDaemon.acquire")(function* ( : {}), supervision: runtimeIdentity.supervision, }, - runtimeShutdown: () => shutdown.resolve(), + runtimeShutdown: () => process.kill(process.pid, "SIGTERM"), spaProxyUrl: process.env.SPA_PROXY_URL, manageProcessSignals: false, }), @@ -404,7 +403,7 @@ const acquireDaemon = Effect.fn("SelfTuneDaemon.acquire")(function* ( } transferred = true; - return { ...handle, shutdown: shutdown.promise, stop }; + return { ...handle, stop }; }), (runtimeLock) => (transferred ? Effect.void : Effect.promise(() => runtimeLock.stop())), ); @@ -424,14 +423,9 @@ export const startDaemon = Effect.fn("SelfTuneDaemon.start")(function* ( export interface DaemonRunProgramDependencies { readonly resolveOptions: (input: DaemonRunInput) => DaemonRunOptions; - readonly start: (options: DaemonRunOptions) => Effect.Effect< - { - readonly shutdown: Promise; - readonly stop: () => void | Promise; - }, - DaemonFailure, - Scope.Scope - >; + readonly start: ( + options: DaemonRunOptions, + ) => Effect.Effect<{ readonly stop: () => void | Promise }, DaemonFailure, Scope.Scope>; } const LIVE_RUN_PROGRAM_DEPENDENCIES: DaemonRunProgramDependencies = { @@ -449,8 +443,8 @@ export const runDaemonProgram = Effect.fn("SelfTuneDaemon.program")(function* ( }); return yield* Effect.scoped( Effect.gen(function* () { - const handle = yield* dependencies.start(options); - return yield* Effect.promise(() => handle.shutdown); + yield* dependencies.start(options); + return yield* Effect.never; }), ); }); diff --git a/apps/local/src/dashboard-http.ts b/apps/local/src/dashboard-http.ts index dd0e55b2..97f76262 100644 --- a/apps/local/src/dashboard-http.ts +++ b/apps/local/src/dashboard-http.ts @@ -3,7 +3,7 @@ import type { DashboardOperationError } from "./dashboard-operations.js"; export function dashboardCorsHeaders(): Record { return { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, PATCH, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type", }; } diff --git a/apps/local/src/dashboard-operation-errors.ts b/apps/local/src/dashboard-operation-errors.ts index 5c8bb7f8..74908917 100644 --- a/apps/local/src/dashboard-operation-errors.ts +++ b/apps/local/src/dashboard-operation-errors.ts @@ -12,6 +12,8 @@ import { import { SkillIntelligenceFeedbackError } from "@selftune/runtime/skill-intelligence/feedback"; import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { CloudTeamCollaborationError } from "./cloud-team-collaboration.js"; + export class DashboardOperationError extends Schema.TaggedErrorClass()( "DashboardOperationError", { @@ -28,6 +30,16 @@ export class DashboardOperationError extends Schema.TaggedErrorClass SkillSetSuggestionReview | Promise; skillSetsLoader?: () => SkillSetsResponse | Promise; + pluginInventoryLoader?: () => PluginInventoryModel | Promise; + pluginManager?: ( + input: PluginManagementInputModel, + ) => PluginManagementReceiptModel | Promise; + skillSetPluginPreviewer?: ( + setId: string, + ) => SkillSetPluginInstallPreview | Promise; + skillSetPluginInstaller?: (input: { + readonly setId: string; + readonly expectedRevisionHash: string; + readonly hosts: ReadonlyArray; + }) => SkillSetPluginInstallReceipt | Promise; sourceUpdatePreviewer?: ( skillName: string, ) => SkillSourceUpdatePreview | Promise; @@ -222,6 +266,20 @@ export interface DashboardOperationOverrides { | DesktopBillingSession | DesktopBillingCheckoutFinalizeResult | Promise; + teamCollaborationAccessLoader?: () => + | TeamCollaborationAccessModel + | Promise; + teamCollaborationSnapshotLoader?: () => + | TeamCollaborationSnapshotModel + | Promise; + teamCollaborationRolloutPolicyUpdater?: ( + entryId: string, + policy: TeamRolloutPolicyModel, + ) => TeamRolloutPolicyResultModel | Promise; + teamCollaborationContributionDecider?: ( + contributionId: string, + action: "adopt" | "reject" | "rollback", + ) => TeamContributionDecisionResultModel | Promise; remoteLibraryShareAction?: ( action: RemoteLibraryShareAction, input?: CreateRemoteLibraryShareRequest | CreateSkillShareGrantRequest | { share_id: string }, @@ -388,18 +446,67 @@ export class DashboardOperations extends Context.Service< candidateId: string, ) => Effect.Effect; readonly skillSets: Effect.Effect; + readonly plugins: Effect.Effect; + readonly managePlugin: ( + input: PluginManagementInputModel, + ) => Effect.Effect; readonly createSkillSet: ( input: CreateSkillSetRequest, ) => Effect.Effect; readonly updateSkillSet: ( input: UpdateSkillSetRequest, ) => Effect.Effect; + readonly deleteSkillSet: ( + setId: string, + ) => Effect.Effect<{ readonly deleted: true }, DashboardOperationError>; readonly deriveSkillSet: ( input: DeriveSkillSetRequest, ) => Effect.Effect; readonly exportSkillSet: ( input: ExportSkillSetRequest, ) => Effect.Effect<{ output_path: string }, DashboardOperationError>; + readonly exportSkillSetPlugin: (input: { + readonly set_id: string; + readonly target: "claude" | "openai" | "agent-plugins-v1" | "dual" | "all"; + }) => Effect.Effect< + { readonly filename: string; readonly content_base64: string }, + DashboardOperationError + >; + readonly previewSkillSetPluginInstall: ( + setId: string, + ) => Effect.Effect; + readonly installSkillSetPlugin: (input: { + readonly setId: string; + readonly expectedRevisionHash: string; + readonly hosts: ReadonlyArray; + }) => Effect.Effect; + readonly previewSkillSetPack: ( + packUrl: string, + ) => Effect.Effect< + { readonly packUrl: string; readonly preview: SkillSetPackPreview }, + DashboardOperationError + >; + readonly importSkillSetPack: (input: { + readonly packUrl: string; + readonly expectedObjectSha256: string; + }) => Effect.Effect< + { + readonly manifest: import("@selftune/library").SkillSetManifest; + readonly sourceRevisionSha256: string; + readonly objectSha256: string; + }, + DashboardOperationError + >; + readonly listSkillSetPacks: () => Effect.Effect< + import("@selftune/control-plane").SkillSetPackManagementList, + DashboardOperationError + >; + readonly revokeSkillSetPack: ( + packId: string, + ) => Effect.Effect< + { readonly packId: string; readonly status: "revoked" }, + DashboardOperationError + >; readonly planSkillSet: ( input: PlanSkillSetRequest, ) => Effect.Effect; @@ -435,6 +542,22 @@ export class DashboardOperations extends Context.Service< DesktopBillingStatus | DesktopBillingSession | DesktopBillingCheckoutFinalizeResult, DashboardOperationError >; + readonly teamCollaborationAccess: Effect.Effect< + TeamCollaborationAccessModel, + DashboardOperationError + >; + readonly teamCollaborationSnapshot: Effect.Effect< + TeamCollaborationSnapshotModel, + DashboardOperationError + >; + readonly updateTeamCollaborationRolloutPolicy: ( + entryId: string, + policy: TeamRolloutPolicyModel, + ) => Effect.Effect; + readonly decideTeamCollaborationContribution: ( + contributionId: string, + action: "adopt" | "reject" | "rollback", + ) => Effect.Effect; readonly updateSchedule: ( input: UpdateDesktopScheduleRequest, ) => Effect.Effect; @@ -457,6 +580,15 @@ export class DashboardOperations extends Context.Service< skillId: string, targetAgent: "codex" | "claude_code" | "opencode" | "openclaw" | "pi", ) => Effect.Effect; + readonly previewLicenseDraft: ( + skillId: string, + terms: LicenseDraftTerms, + ) => Effect.Effect; + readonly applyLicenseDraft: ( + skillId: string, + previewId: string, + terms: LicenseDraftTerms, + ) => Effect.Effect; readonly remoteLibraryShare: ( action: RemoteLibraryShareAction, input?: CreateRemoteLibraryShareRequest | CreateSkillShareGrantRequest | { share_id: string }, @@ -640,18 +772,43 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride const configuredCloudBilling = makeCloudBillingOperations( options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, ); - const runRemoteLibrary = options.remoteLibraryAction ?? configuredRemoteLibrary.run; + const configuredHostedState = makeHostedStateOperations( + options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, + getLibrary, + ); + const configuredTeamCollaboration = makeCloudTeamCollaborationOperations( + options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, + ); + const runRemoteLibrary = + options.remoteLibraryAction ?? + (async (action) => { + if (!(await configuredHostedState.isCloudConnection())) + return configuredRemoteLibrary.run(action); + if (action === "sync") return configuredHostedState.sync(); + if (action === "status") + return { url: "https://cloud.selftune.dev", mode: "privacy_safe_manifest" }; + throw new Error( + "SelfTune Cloud does not store a library backup. Export and restore remain local.", + ); + }); const cloudAccountLink = makeCloudAccountLinkManager({ configRoot: resolve(options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR), loadSettings: getMigratedSettings, - sync: () => runRemoteLibrary("sync"), + sync: configuredHostedState.sync, startOverride: options.cloudAccountLinkStarter, completeOverride: options.cloudAccountLinkCompleter, }); const runRemoteLibraryShare = - options.remoteLibraryShareAction ?? configuredRemoteLibrary.share; + options.remoteLibraryShareAction ?? + (async (action, input) => + action === "create" && + input && + "mode" in input && + (await configuredHostedState.isCloudConnection()) + ? configuredHostedState.share(input) + : configuredRemoteLibrary.share(action, input)); return DashboardOperations.of({ skillSetsWritable: !options.skillSetsLoader, @@ -922,6 +1079,21 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride receipts: listSkillSetReceipts(skillSetOptions), })), ), + plugins: attempt("plugins.load", () => + options.pluginInventoryLoader + ? options.pluginInventoryLoader() + : discoverPluginInventory({ + configRoot: options.skillSetConfigRoot, + }), + ), + managePlugin: (input) => + attempt("plugins.manage", () => + options.pluginManager + ? options.pluginManager(input) + : managePluginInstallation(input, { + configRoot: options.skillSetConfigRoot, + }), + ), createSkillSet: (input) => createSkillSetWithCatalogResolution(input, { resolver: @@ -949,12 +1121,49 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride skillSetOptions, ), ), + deleteSkillSet: (setId) => + invalidatingAttempt("skill_sets.delete", () => deleteSkillSet(setId, skillSetOptions)), deriveSkillSet: (input) => attempt("skill_sets.derive", () => captureSkillSetFromProject(input, skillSetOptions)), exportSkillSet: (input) => attempt("skill_sets.export", () => ({ output_path: exportPortableSkillSet(input.set_id, input.project_root, skillSetOptions), })), + exportSkillSetPlugin: (input) => + attempt("skill_sets.plugin_export", () => + exportSkillSetPluginArchive(input.set_id, input.target, skillSetOptions), + ), + previewSkillSetPluginInstall: (setId) => + attempt("skill_sets.plugin_install_preview", () => + options.skillSetPluginPreviewer + ? options.skillSetPluginPreviewer(setId) + : previewSkillSetPluginInstall(setId, skillSetOptions), + ), + installSkillSetPlugin: (input) => + attempt("skill_sets.plugin_install", () => + options.skillSetPluginInstaller + ? options.skillSetPluginInstaller(input) + : installSkillSetPlugin(input, skillSetOptions), + ), + previewSkillSetPack: (packUrl) => + attempt("skill_sets.pack_preview", () => + previewSkillSetPack( + packUrl, + resolve(options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR), + ), + ), + importSkillSetPack: (input) => + invalidatingAttempt("skill_sets.pack_import", () => + importSkillSetPack({ + packUrl: input.packUrl, + expectedObjectSha256: input.expectedObjectSha256, + configRoot: resolve(options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR), + }), + ), + listSkillSetPacks: () => + attempt("skill_sets.packs_list", () => configuredRemoteLibrary.listPacks()), + revokeSkillSetPack: (packId) => + attempt("skill_sets.pack_revoke", () => configuredRemoteLibrary.revokePack(packId)), planSkillSet: (input) => attempt("skill_sets.plan", () => planSkillSet(input, skillSetOptions)), applySkillSet: (input) => @@ -1020,6 +1229,28 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride } return configuredCloudBilling.finalize(input); }), + teamCollaborationAccess: attempt("team_collaboration.access", () => + options.teamCollaborationAccessLoader + ? options.teamCollaborationAccessLoader() + : configuredTeamCollaboration.access(), + ), + teamCollaborationSnapshot: attempt("team_collaboration.snapshot", () => + options.teamCollaborationSnapshotLoader + ? options.teamCollaborationSnapshotLoader() + : configuredTeamCollaboration.snapshot(), + ), + updateTeamCollaborationRolloutPolicy: (entryId, policy) => + attempt("team_collaboration.rollout_policy.update", () => + options.teamCollaborationRolloutPolicyUpdater + ? options.teamCollaborationRolloutPolicyUpdater(entryId, policy) + : configuredTeamCollaboration.updateRolloutPolicy(entryId, policy), + ), + decideTeamCollaborationContribution: (contributionId, action) => + attempt(`team_collaboration.contribution.${action}`, () => + options.teamCollaborationContributionDecider + ? options.teamCollaborationContributionDecider(contributionId, action) + : configuredTeamCollaboration.decide(contributionId, action), + ), updateSchedule: (input) => attempt("settings.schedule", () => options.settingsUpdater @@ -1057,7 +1288,10 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride attempt("remote_library.skill.backup", () => options.remoteLibrarySkillBackup ? options.remoteLibrarySkillBackup(skillId) - : configuredRemoteLibrary.backupSkill(skillId), + : (async () => + (await configuredHostedState.isCloudConnection()) + ? configuredHostedState.sync() + : configuredRemoteLibrary.backupSkill(skillId))(), ), installLibrarySkill: (skillId, targetAgent) => attempt("remote_library.skill.install", () => @@ -1070,6 +1304,30 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride }, ), ), + previewLicenseDraft: (skillId, terms) => + Effect.flatMap(libraryReport.read, (snapshot) => + attempt("library.license.preview", () => { + const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId); + const location = + skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0]; + if (!location) throw new Error("Refresh the Library and select this skill again."); + return previewLocalLicenseDraft(location.skillPath, terms); + }), + ), + applyLicenseDraft: (skillId, previewId, terms) => + Effect.flatMap(libraryReport.read, (snapshot) => + invalidatingAttempt("library.license.apply", () => { + const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId); + const location = + skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0]; + if (!location) throw new Error("Refresh the Library and select this skill again."); + return applyLocalLicenseDraft({ + skillPath: location.skillPath, + previewId, + terms, + }); + }), + ), remoteLibraryShare: (action, input) => attempt(`remote_library.share.${action}`, () => runRemoteLibraryShare(action, input)), workspace: (action, input) => diff --git a/apps/local/src/dashboard-server.ts b/apps/local/src/dashboard-server.ts index 4071090d..ac893bbb 100644 --- a/apps/local/src/dashboard-server.ts +++ b/apps/local/src/dashboard-server.ts @@ -16,6 +16,7 @@ * POST /api/v2/settings/schedule — Reconcile local automation jobs * POST /api/v2/settings/cloud-account/link/{start,complete} — Link Desktop to Cloud * GET/POST /api/v2/settings/billing/* — Proxy Cloud billing through the local sidecar + * GET/PATCH/POST /api/v2/team-collaboration/* — Proxy role-gated Cloud collaboration * POST /api/v2/correction-studies/explicit-corrections — Capture and evaluate a correction * GET /api/v2/correction-studies/:episodeId — Read durable correction evidence * GET /api/v2/library — Canonical local Skill Library snapshot @@ -32,11 +33,18 @@ * POST /api/v2/insights/evaluate — Run immutable draft release gates * POST /api/v2/insights/release — Release a passing revision to the Library * GET /api/v2/correction-studies/signals — Read-only, review-required correction hypotheses + * POST /api/v2/trace-candidates/prepare — Prepare a bounded candidate from local trace evidence + * POST /api/v2/trace-candidates/evaluate — Run registered managed replay and persist review evidence * GET /api/v2/skill-sets — List project Skill Sets and apply receipts + * GET /api/v2/plugins — Discover installed Claude and Codex plugins + * POST /api/v2/plugins/manage — Run a supported host-native plugin action * POST /api/v2/skill-sets — Create a content-addressed Skill Set * POST /api/v2/skill-sets/update — Update a Skill Set with optimistic concurrency + * DELETE /api/v2/skill-sets/:id — Remove a Skill Set from the local library * POST /api/v2/skill-sets/derive — Capture a project's active skills * POST /api/v2/skill-sets/export — Write a portable project manifest + * POST /api/v2/skill-sets/plugin-install/preview — Inspect native host availability + * POST /api/v2/skill-sets/plugin-install — Install through Claude or Codex * POST /api/v2/skill-sets/plan — Preview project materialization * POST /api/v2/skill-sets/apply — Apply a conflict-free Skill Set * POST /api/v2/skill-sets/rollback — Roll back receipt-owned paths @@ -61,8 +69,12 @@ import * as Effect from "effect/Effect"; 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 * as BunServices from "@effect/platform-bun/BunServices"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; 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"; import { DASHBOARD_ACTION_STREAM_LOG, LOG_DIR } from "@selftune/runtime/constants"; @@ -78,7 +90,6 @@ import { } from "@selftune/local-store"; import { EvidenceCohort, EvidenceCohortEntry } from "@selftune/observability/evidence-cohort"; import { LocalTraceImporter } from "@selftune/observability/local-trace-importer"; -import type { DuckDbAnalyticalStoreFailure } from "@selftune/observability/duckdb-store"; import { makeLocalTraceImporterLive } from "@selftune/orchestration/sync/local-trace-importer"; import { maintainUploadArtifacts } from "@selftune/runtime/alpha-upload/prune"; import { @@ -93,6 +104,12 @@ import { CloudEvaluationTargetClient, makeCloudEvaluationTargetClientLayer, } from "@selftune/runtime/evolution/cloud-evaluation-target-client"; +import { + runAutomaticRegistrySuggestions, + type AutomaticRegistrySuggestionOptions, +} from "@selftune/runtime/registry/automatic-suggestions"; +import { makeRegistryClientLayer } from "@selftune/runtime/registry/client"; +import { makeRegistryPlatformLayer } from "@selftune/runtime/registry/platform"; import { createDashboardAuth } from "./dashboard-auth.js"; import { createDashboardEventHub } from "./dashboard-events.js"; @@ -128,6 +145,11 @@ import { makeTraceCandidatePreparationLayer, decodePreparedTraceCandidateDraft, } from "./trace-candidate-service.js"; +import { + HistoricalSkillImprovement, + 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, @@ -170,6 +192,13 @@ export interface DashboardServerOptions sqlite: Database, configPath: string, ) => CompatibilityExportWorker; + /** Test seam for timing the daemon-owned managed-skill suggestion worker. */ + automaticRegistrySuggestionOptions?: AutomaticRegistrySuggestionOptions; + /** + * Managed replay capability supplied by a concrete harness adapter. The + * HTTP surface remains fail-closed when no harness owns execution. + */ + historicalReplayExecutor?: BlindBenchmarkExecutor; } interface DashboardSocketData { @@ -335,6 +364,34 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr ), ); }; + 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() }), + }), + preparationLayer, + ), + ), + ), + ), + ); + }; const correctionStudyRouteError = (error: unknown): CorrectionStudyServiceError => error instanceof CorrectionStudyServiceFailure ? new CorrectionStudyServiceError(error.code, error.message, error.status) @@ -389,6 +446,9 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr 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) => { @@ -404,6 +464,8 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr } 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; @@ -419,7 +481,7 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr !target.verification_only && target.min_repetitions <= target.max_repetitions && target.max_repetitions >= 3 && - target.skill_revision === payload.candidate.target_revision, + target.skill_revision === candidate.target_revision, ); return { draft_id: draftId, @@ -439,6 +501,11 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr }; } 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( @@ -524,41 +591,73 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr : undefined; let otlpComposition: | { - runtime: ManagedRuntime.ManagedRuntime; otlp: typeof import("@selftune/observability/otlp"); + run: ( + effect: Effect.Effect, + signal: AbortSignal, + ) => Promise
; } | undefined; - let disposeOtlpRuntime: (() => Promise) | undefined; try { if (otlpEnabled(hostname, authToken, dashboardHost)) { const [{ makeDuckDbNodeApiAnalyticalStoreLive }, otlp] = await Promise.all([ import("@selftune/observability/duckdb-node-api"), import("@selftune/observability/otlp"), ]); - const runtime = ManagedRuntime.make( - Layer.provide( - makeLocalTraceImporterLive(localDatabase), - makeDuckDbNodeApiAnalyticalStoreLive(storagePaths.localAnalyticsPath), - ), + const localTraceLayer = Layer.provide( + makeLocalTraceImporterLive(localDatabase), + makeDuckDbNodeApiAnalyticalStoreLive(storagePaths.localAnalyticsPath), ); - disposeOtlpRuntime = runtime.dispose; - await runtime.runPromise(Effect.map(LocalTraceImporter, () => undefined)); - otlpComposition = { runtime, otlp }; + const semaphore = Semaphore.makeUnsafe(1); + const provideLocalTrace = (effect: Effect.Effect) => + Effect.scoped(effect.pipe(Effect.provide(localTraceLayer))); + // Initialize and migrate once, but release DuckDB immediately. The + // dashboard must not pin the cross-process writer lock while idle: sync + // and ingest commands run as separate SelfTune processes. + await Effect.runPromise(provideLocalTrace(Effect.map(LocalTraceImporter, () => undefined))); + otlpComposition = { + otlp, + run: (effect, signal) => + Effect.runPromise(semaphore.withPermit(provideLocalTrace(effect)), { signal }), + }; } } catch (error) { await evaluationSubmissionRuntime.dispose(); await evaluationTargetRuntime.dispose(); await compatibilityExportWorker?.stop(); - await disposeOtlpRuntime?.(); await operationsRuntime.dispose(); throw error; } compatibilityExportWorker?.start(); - const otlpRuntime = otlpComposition?.runtime; + const automaticRegistrySuggestionRuntime = + runtimeMode === "standalone" && dashboardHost === "local" + ? ManagedRuntime.make( + Layer.merge( + makeRegistryClientLayer(storagePaths.configPath), + makeRegistryPlatformLayer({ configDirectory: storagePaths.configDir }), + ).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(BunServices.layer)), + ) + : undefined; + const automaticRegistrySuggestionController = automaticRegistrySuggestionRuntime + ? new AbortController() + : undefined; + const automaticRegistrySuggestionWorker = automaticRegistrySuggestionRuntime + ? automaticRegistrySuggestionRuntime + .runPromise(runAutomaticRegistrySuggestions(options?.automaticRegistrySuggestionOptions), { + signal: automaticRegistrySuggestionController?.signal, + }) + .catch((error: unknown) => { + if (!automaticRegistrySuggestionController?.signal.aborted) { + process.stderr.write( + `SelfTune automatic team suggestion worker stopped: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } + }) + : undefined; const otlpRoutes = otlpComposition ? createOtlpRoutes(async (signal, encoding, body, abortSignal) => { try { - await otlpComposition.runtime.runPromise( + await otlpComposition.run( Effect.gen(function* () { const normalized = yield* otlpComposition.otlp.normalizeOtlpExport({ signal, @@ -573,7 +672,7 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr batch: normalized.batch, }); }), - { signal: abortSignal }, + abortSignal, ); } catch (error) { if ( @@ -674,6 +773,7 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr const hookRoutes = createHookRoutes({ runners: options?.hookRunners }); const traceCandidateRoutes = createTraceCandidateRoutes({ prepare: prepareTraceCandidate, + evaluate: evaluateHistoricalSkill, }); const evaluationDraftSubmissionRoutes = createEvaluationDraftSubmissionRoutes({ discover: discoverDraftTargets, @@ -695,6 +795,9 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr if (backgroundRemoteSyncInterval) clearInterval(backgroundRemoteSyncInterval); if (backgroundUploadPruneStartup) clearTimeout(backgroundUploadPruneStartup); if (backgroundUploadPruneInterval) clearInterval(backgroundUploadPruneInterval); + automaticRegistrySuggestionController?.abort(); + await automaticRegistrySuggestionWorker; + await automaticRegistrySuggestionRuntime?.dispose(); await compatibilityExportWorker?.stop(); eventHub.stop(); for (const upstreamSocket of proxiedSpaSockets.values()) { @@ -708,7 +811,6 @@ export async function startDashboardServer(options?: DashboardServerOptions): Pr await hookRoutes.waitForIdle(); await evaluationSubmissionRuntime.dispose(); await evaluationTargetRuntime.dispose(); - await otlpRuntime?.dispose(); await operationsRuntime.dispose(); })(); return disposePromise; diff --git a/apps/local/src/historical-evidence-safety.ts b/apps/local/src/historical-evidence-safety.ts new file mode 100644 index 00000000..6779c290 --- /dev/null +++ b/apps/local/src/historical-evidence-safety.ts @@ -0,0 +1,95 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { classifyPromptKind } from "@selftune/runtime/normalization"; + +export const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength; + +function truncateToBytes(value: string, limit: number): string { + let result = ""; + for (const character of value) { + if (byteLength(`${result}${character}`) > limit) break; + result += character; + } + return result; +} + +export const redactedPortableText = (value: string): string => + value + .replace( + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, + "[redacted-private-key]", + ) + .replace( + /\b(?:api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*[^\s,;]+/gi, + "[redacted]", + ) + .replace(/(?:^|([\s"'`]))(?:\/(?:[^\s"'`]+)|[a-zA-Z]:\\[^\s"'`]+)/g, "$1[local-path]"); + +function removeExplicitSkillInvocation(value: string, skillName?: string): string { + const normalized = skillName?.trim(); + if (!normalized) return value; + const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return value.replace(new RegExp(`(?:^|\\s)/${escaped}(?=\\s|$)`, "gi"), " ").trim(); +} + +export function boundedHistoricalTask( + value: string | null | undefined, + invokedSkillName?: string, +): string | null { + const task = value ? removeExplicitSkillInvocation(value.trim(), invokedSkillName) : undefined; + if (!task || classifyPromptKind(task) !== "user") return null; + if ( + task.startsWith(" line.startsWith("# ") && !line.startsWith("## ")); + return (title === -1 ? content : lines.slice(title + 1).join("\n")).trim(); +} + +export function changedLineCount(before: string, after: string): number { + const left = before.split("\n"); + const right = after.split("\n"); + let prefix = 0; + while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix++; + let suffix = 0; + while ( + suffix < left.length - prefix && + suffix < right.length - prefix && + left[left.length - suffix - 1] === right[right.length - suffix - 1] + ) { + suffix++; + } + return Math.max(left.length - prefix - suffix, right.length - prefix - suffix); +} diff --git a/apps/local/src/historical-skill-improve-cli.ts b/apps/local/src/historical-skill-improve-cli.ts new file mode 100644 index 00000000..6c2b12c0 --- /dev/null +++ b/apps/local/src/historical-skill-improve-cli.ts @@ -0,0 +1,221 @@ +import { createHash } from "node:crypto"; +import { dirname } from "node:path"; + +import { + SELFTUNE_LOCAL_ANALYTICS_PATH, + SELFTUNE_LOCAL_DATABASE_PATH, +} from "@selftune/config/paths"; +import { LocalDatabaseService, makeLocalDatabaseLive } from "@selftune/local-store"; +import { makeDuckDbNodeApiAnalyticalStoreLive } from "@selftune/observability/duckdb-node-api"; +import { isLlmBackedAgent, type LlmBackedAgent } from "@selftune/runtime/utils/llm-call"; +import { Effect, Layer } from "effect"; + +import { + HistoricalSkillImprovement, + makeHistoricalSkillImprovementLayer, +} from "./historical-skill-improvement-service.js"; +import { + historicalRoutingVerifierQualification, + historicalTaskQualityVerifierQualification, + makeHostHistoricalTaskCalibrator, + makeHostHistoricalSkillReplayExecutorFactory, +} from "./historical-skill-replay-executor.js"; +import { + executionPatternIdForSkill, + makeLiveCohortBodyTeacher, + makeTraceCandidatePreparationLayer, +} from "./trace-candidate-service.js"; + +export interface HistoricalSkillImproveCliInput { + readonly skill: string; + readonly skillPath: string; + readonly agent?: string; +} + +export interface HistoricalSkillImproveCliAttempt { + readonly handled: boolean; + readonly result?: { + readonly mode: "historical_blind_replay"; + readonly source: "local_duckdb"; + readonly review_only: true; + readonly skill: string; + readonly agent: LlmBackedAgent; + readonly student_model: string; + readonly reasoning_effort: "max"; + readonly pattern_id: string; + readonly status: "review_ready" | "not_ready" | "blocked"; + readonly evidence_level: "E0" | "E0.5" | "E2"; + readonly reason: string; + readonly evaluation_id: string | null; + readonly cohort_fingerprint: string | null; + readonly cases: { + readonly calibration: number; + readonly selection: number; + readonly audit_holdout: number; + readonly active_regressions: number; + }; + readonly search: { + readonly requested_candidates: number; + readonly generated_candidates: number; + readonly calibrated_candidates: number; + readonly required_calibration_repetitions: number; + readonly current_calibration_passed_repetitions: number; + readonly frontier_candidate_ids: readonly string[]; + readonly selected_candidate_id: string | null; + readonly selection_method: "pareto_calibration_frontier"; + readonly candidate_summaries: readonly { + readonly proposal_id: string; + readonly calibration_passed: boolean; + readonly scored_repetitions: number; + readonly passed_repetitions: number; + readonly calibration_score: number; + readonly changed_lines: number; + readonly input_tokens: number | null; + readonly output_tokens: number | null; + readonly wall_time_ms: number | null; + readonly frontier_member: boolean; + readonly selected: boolean; + }[]; + } | null; + readonly applies_change: false; + readonly before_after?: { + readonly case_id: string; + readonly task: string; + readonly current: { readonly passed: boolean; readonly output: string }; + readonly candidate: { readonly passed: boolean; readonly output: string }; + } | null; + }; +} + +const fingerprint = (value: string): string => + `sha256:${createHash("sha256").update(value).digest("hex")}`; + +const CODEX_STUDENT_MODEL = "gpt-5.6-luna"; +const CODEX_STUDENT_REASONING_EFFORT = "max" as const; +const HISTORICAL_SEARCH_CANDIDATES = 5; + +function requestedAgent(value: string | undefined): LlmBackedAgent { + const agent = value ?? "codex"; + if (!isLlmBackedAgent(agent)) { + throw new Error( + `Historical improvement supports these replay agents: claude, codex, opencode, pi. Received: ${agent}`, + ); + } + if (agent === "pi") { + throw new Error("Historical host replay does not support the pi harness yet."); + } + return agent; +} + +/** Open the real local stores, run the review transaction, and close both stores. */ +export async function runHistoricalSkillImproveCli( + input: HistoricalSkillImproveCliInput, +): Promise { + const agent = requestedAgent(input.agent); + const patternId = executionPatternIdForSkill(input.skill); + const searchDirs = [dirname(dirname(input.skillPath))]; + const taskQualityReplay = input.skill.trim().toLowerCase() === "to-issues"; + + const program = Effect.gen(function* () { + const { sqlite } = yield* LocalDatabaseService; + const preparationLayer = Layer.provide( + makeTraceCandidatePreparationLayer({ + sqlite, + teacher: makeLiveCohortBodyTeacher({ agent }), + searchDirs, + studentAgent: agent, + studentModel: agent === "codex" ? CODEX_STUDENT_MODEL : undefined, + historicalTaskCalibrator: taskQualityReplay + ? makeHostHistoricalTaskCalibrator({ + agent, + model: agent === "codex" ? CODEX_STUDENT_MODEL : "configured-default", + }) + : undefined, + }), + makeDuckDbNodeApiAnalyticalStoreLive(SELFTUNE_LOCAL_ANALYTICS_PATH), + ); + const improvementLayer = Layer.provide( + makeHistoricalSkillImprovementLayer({ + sqlite, + executorFactory: makeHostHistoricalSkillReplayExecutorFactory(), + searchDirs, + }), + preparationLayer, + ); + return yield* Effect.scoped( + Effect.gen(function* () { + const improvement = yield* HistoricalSkillImprovement; + return yield* improvement.evaluate({ + pattern_id: patternId, + candidate_count: HISTORICAL_SEARCH_CANDIDATES, + qualified_verifier: taskQualityReplay + ? historicalTaskQualityVerifierQualification() + : historicalRoutingVerifierQualification(), + runtime: { + harness: agent === "claude" ? "claude_code" : agent, + model: agent === "codex" ? CODEX_STUDENT_MODEL : "configured-default", + config_digest: fingerprint( + JSON.stringify({ + schema_version: 1, + agent, + model: agent === "codex" ? CODEX_STUDENT_MODEL : "configured-default", + reasoning_effort: + agent === "codex" ? CODEX_STUDENT_REASONING_EFFORT : "configured-default", + candidate_count: HISTORICAL_SEARCH_CANDIDATES, + replay: taskQualityReplay + ? "isolated-host-execution-quality" + : "isolated-host-routing", + verifier: taskQualityReplay + ? "selftune.to-issues-draft-quality@v2" + : "selftune.skill-routing-selection@v1", + }), + ), + }, + 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: 20, + estimated_cost_usd: 5, + }, + recorded_at: new Date().toISOString(), + }); + }).pipe(Effect.provide(improvementLayer)), + ); + }).pipe(Effect.provide(makeLocalDatabaseLive(SELFTUNE_LOCAL_DATABASE_PATH))); + + const response = await Effect.runPromise(Effect.scoped(program)); + const unsupportedContrast = response.reason.startsWith( + "The exact pattern is no longer supported", + ); + if (response.status === "not_ready" && unsupportedContrast) { + return { handled: false }; + } + return { + handled: true, + result: { + mode: "historical_blind_replay", + source: "local_duckdb", + review_only: true, + skill: input.skill, + agent, + student_model: agent === "codex" ? CODEX_STUDENT_MODEL : "configured-default", + reasoning_effort: CODEX_STUDENT_REASONING_EFFORT, + pattern_id: response.pattern_id, + status: response.status, + evidence_level: response.evidence_level, + reason: response.reason, + evaluation_id: response.evaluation_id, + cohort_fingerprint: response.cohort_fingerprint, + cases: response.cases, + search: response.search ?? null, + applies_change: false, + before_after: response.before_after ?? null, + }, + }; +} diff --git a/apps/local/src/historical-skill-improvement-service.ts b/apps/local/src/historical-skill-improvement-service.ts new file mode 100644 index 00000000..049db3d6 --- /dev/null +++ b/apps/local/src/historical-skill-improvement-service.ts @@ -0,0 +1,686 @@ +/** + * One local transaction for trace-backed skill improvement: + * prepare from DuckDB, freeze a blind protocol, run a harness, and persist a + * review-only E2 receipt. It never edits or deploys a skill. + */ +import type { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; + +import { + getCorrectionSignalCandidate, + getEvaluationSubmissionDraft, + listActivePromotedStudyCases, + upsertCorrectionSignalCandidate, +} from "@selftune/local-store"; +import { replaceBody } from "@selftune/runtime/evolution/deploy-proposal"; +import { + computeSkillVersionHash, + computeSkillVersionHashWithContent, + findInstalledSkillPackages, + getDefaultSkillSearchDirs, +} from "@selftune/runtime/utils/skill-discovery"; +import { + BlindBenchmarkCase, + type BlindBenchmarkExecutor, + type BlindBenchmarkProtocol, +} from "@selftune/skill-intelligence/blind-benchmark"; +import { VerifierQualificationResult } from "@selftune/skill-intelligence/verifier-instruments"; +import { Context, Effect, Layer, Schema } from "effect"; + +import { + ProactiveExecutionControls, + makeLocalStoreProactiveCandidateEvaluationPersistence, + runProactiveCorrectionE2, +} from "./proactive-correction-e2-service.js"; +import { + TraceCandidatePreparation, + decodePreparedTraceCandidateDraft, +} from "./trace-candidate-service.js"; +import type { TraceCandidateReview } from "./trace-candidate-contract.js"; +import type { + HistoricalSkillReplayExecutorFactory, + HistoricalSkillReplayObservation, +} from "./historical-skill-replay-executor.js"; +import { bodyBelowTitle, changedLineCount } from "./historical-evidence-safety.js"; + +const Identifier = Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/), +); +const Sha256 = Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)); +const Timestamp = Schema.String.check( + Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/), +); + +export const HistoricalSkillImprovementRequest = Schema.Struct({ + pattern_id: Identifier, + candidate_count: Schema.optionalKey( + Schema.Number.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(2), + Schema.isLessThanOrEqualTo(8), + ), + ), + qualified_verifier: VerifierQualificationResult, + runtime: Schema.Struct({ + harness: Identifier, + model: Identifier, + config_digest: Sha256, + }), + required_scored_repetitions: Schema.Number, + max_attempts_per_arm: Schema.Number, + controls: ProactiveExecutionControls, + recorded_at: Timestamp, +}); +export type HistoricalSkillImprovementRequest = typeof HistoricalSkillImprovementRequest.Type; + +export interface HistoricalSkillImprovementResponse { + readonly pattern_id: string; + readonly draft_id: string | null; + readonly candidate_id: string | null; + readonly evaluation_id: string | null; + readonly status: "review_ready" | "not_ready" | "blocked"; + readonly evidence_level: "E0" | "E0.5" | "E2"; + readonly reason: string; + readonly cohort_fingerprint: string | null; + readonly cases: { + readonly calibration: number; + readonly selection: number; + readonly audit_holdout: number; + readonly active_regressions: number; + }; + readonly applies_change: false; + readonly search?: { + readonly requested_candidates: number; + readonly generated_candidates: number; + readonly calibrated_candidates: number; + readonly required_calibration_repetitions: number; + readonly current_calibration_passed_repetitions: number; + readonly frontier_candidate_ids: readonly string[]; + readonly selected_candidate_id: string | null; + readonly selection_method: "pareto_calibration_frontier"; + readonly candidate_summaries: NonNullable< + TraceCandidateReview["search"] + >["candidate_summaries"]; + } | null; + readonly before_after?: { + readonly case_id: string; + readonly task: string; + readonly current: { readonly passed: boolean; readonly output: string }; + readonly candidate: { readonly passed: boolean; readonly output: string }; + } | null; +} + +export class HistoricalSkillImprovementFailure extends Schema.TaggedErrorClass()( + "HistoricalSkillImprovementFailure", + { + code: Schema.Literals([ + "INVALID_REQUEST", + "PREPARATION_FAILED", + "DRAFT_UNAVAILABLE", + "STALE_SKILL_REVISION", + "INVALID_EVIDENCE", + "INCOMPATIBLE_REGRESSION_VERIFIER", + "PERSISTENCE_FAILED", + ]), + message: Schema.String, + }, +) {} + +export interface HistoricalSkillImprovementService { + readonly evaluate: ( + input: unknown, + ) => Effect.Effect; +} + +export class HistoricalSkillImprovement extends Context.Service< + HistoricalSkillImprovement, + HistoricalSkillImprovementService +>()("@selftune/local/HistoricalSkillImprovement") {} + +interface RegressionRow { + readonly case_id: string; + readonly manifest_json: string; + readonly verifier_payload_json: string; +} + +interface RegressionProjection { + readonly benchmarkCase: typeof BlindBenchmarkCase.Type; + readonly activeCase: { + readonly case_id: string; + readonly skill_id: string; + readonly status: "active"; + readonly task_fingerprint: string; + }; +} + +const digest = (value: string): string => + `sha256:${createHash("sha256").update(value).digest("hex")}`; + +const stableId = (prefix: string, value: string): string => + `${prefix}-${createHash("sha256").update(value).digest("hex").slice(0, 32)}`; + +const skillId = (name: string): string => + `skill-${createHash("sha256").update(name).digest("hex").slice(0, 32)}`; + +function sourceKey(value: { + readonly source_id: string; + readonly source_revision: string; + readonly trace_id: string; + readonly span_id: string; + readonly skill_invocation_id: string; +}): string { + return [ + value.source_id, + value.source_revision, + value.trace_id, + value.span_id, + value.skill_invocation_id, + ].join("\u0000"); +} + +function caseId(role: string, source: { readonly skill_invocation_id: string }): string { + return stableId("historical-case", `${role}\u0000${source.skill_invocation_id}`); +} + +function parseJson(value: string, label: string): Record { + 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; + } catch (error) { + throw new HistoricalSkillImprovementFailure({ + code: "INVALID_EVIDENCE", + message: `${label} could not be decoded: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} + +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); + if ( + instrument?.verifier_id !== expectedVerifier.instrument.verifier_id || + instrument.version !== expectedVerifier.instrument.version + ) { + throw new HistoricalSkillImprovementFailure({ + code: "INCOMPATIBLE_REGRESSION_VERIFIER", + 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; + 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 benchmarkCase = BlindBenchmarkCase.make({ + case_id: row.case_id, + task_payload: taskPayload, + task_fingerprint: taskFingerprint, + partition: "selection", + regression_case: true, + }); + return { + benchmarkCase, + activeCase: { + case_id: row.case_id, + skill_id: owningSkillId, + status: "active", + task_fingerprint: benchmarkCase.task_fingerprint, + }, + }; +} + +function emptyCounts() { + return { calibration: 0, selection: 0, audit_holdout: 0, active_regressions: 0 }; +} + +function historicalFailure( + error: unknown, + code: HistoricalSkillImprovementFailure["code"] = "INVALID_EVIDENCE", +): HistoricalSkillImprovementFailure { + return error instanceof HistoricalSkillImprovementFailure + ? error + : new HistoricalSkillImprovementFailure({ + code, + message: error instanceof Error ? error.message : String(error), + }); +} + +function boundedReplayOutput(value: string, limitBytes: number = 4_000): string { + const redacted = value + .replace( + /\b(?:api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*[^\s,;]+/gi, + "[redacted]", + ) + .replace(/(?:~|\/Users\/[^\s/]+|\/home\/[^\s/]+)(?:\/[^\s]*)?/g, "[local-path]"); + const encoder = new TextEncoder(); + let output = ""; + for (const character of redacted) { + if (encoder.encode(`${output}${character}`).byteLength > limitBytes) break; + output += character; + } + return output; +} + +export function makeHistoricalSkillImprovementLayer(options: { + readonly sqlite: Database; + readonly executor?: BlindBenchmarkExecutor; + readonly executorFactory?: HistoricalSkillReplayExecutorFactory; + readonly searchDirs?: readonly string[]; +}): Layer.Layer { + return Layer.effect( + HistoricalSkillImprovement, + Effect.gen(function* () { + const preparation = yield* TraceCandidatePreparation; + const evaluate = Effect.fn("HistoricalSkillImprovement.evaluate")(function* ( + unknownInput: unknown, + ) { + const input = yield* Schema.decodeUnknownEffect(HistoricalSkillImprovementRequest)( + unknownInput, + ).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "INVALID_REQUEST", + message: error.message, + }), + ), + ); + const review = yield* preparation + .prepare({ + pattern_id: input.pattern_id, + candidate_count: input.candidate_count ?? 3, + calibration_repetitions: input.required_scored_repetitions, + }) + .pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "PREPARATION_FAILED", + message: error.message, + }), + ), + ); + if (review.readiness !== "review_ready" || !review.draft_id) { + return { + pattern_id: input.pattern_id, + draft_id: review.draft_id, + candidate_id: null, + evaluation_id: null, + status: "not_ready", + evidence_level: "E0", + reason: review.failure_reason ?? "Historical evidence is not ready for evaluation.", + cohort_fingerprint: review.cohort_fingerprint, + cases: emptyCounts(), + applies_change: false, + search: review.search ?? null, + } satisfies HistoricalSkillImprovementResponse; + } + + const storedDraft = yield* getEvaluationSubmissionDraft( + options.sqlite, + review.draft_id, + ).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "PERSISTENCE_FAILED", + message: error.message, + }), + ), + ); + if (!storedDraft || storedDraft.lifecycle !== "prepared") { + return yield* new HistoricalSkillImprovementFailure({ + code: "DRAFT_UNAVAILABLE", + message: "The prepared historical candidate is no longer available.", + }); + } + const rawDraft = yield* Effect.try({ + try: () => JSON.parse(storedDraft.payload_json), + catch: (error) => historicalFailure(error), + }); + const draft = yield* decodePreparedTraceCandidateDraft(rawDraft).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "INVALID_EVIDENCE", + message: error.message, + }), + ), + ); + if (draft.candidate === null) { + return yield* new HistoricalSkillImprovementFailure({ + code: "DRAFT_UNAVAILABLE", + message: "The historical search receipt does not contain a selected candidate.", + }); + } + const installed = findInstalledSkillPackages([ + ...(options.searchDirs ?? getDefaultSkillSearchDirs()), + ]).find((skill) => skill.name === storedDraft.skill_name); + if (!installed) { + return yield* new HistoricalSkillImprovementFailure({ + code: "DRAFT_UNAVAILABLE", + message: "The target skill is no longer installed.", + }); + } + const installedRevision = computeSkillVersionHash(installed.skill_path); + if ( + !installedRevision || + installedRevision !== storedDraft.skill_revision || + installedRevision !== draft.candidate.target_revision + ) { + return yield* new HistoricalSkillImprovementFailure({ + code: "STALE_SKILL_REVISION", + message: "The installed skill changed after historical evidence was frozen.", + }); + } + const installedContent = yield* Effect.tryPromise({ + try: () => Bun.file(installed.skill_path).text(), + catch: (error) => + new HistoricalSkillImprovementFailure({ + code: "DRAFT_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }), + }); + const installedBody = bodyBelowTitle(installedContent); + const candidateContent = replaceBody(installedContent, draft.candidate.proposed_body); + const candidateRevision = computeSkillVersionHashWithContent( + installed.skill_path, + candidateContent, + ); + if (!candidateRevision || candidateRevision === installedRevision) { + return yield* new HistoricalSkillImprovementFailure({ + code: "INVALID_EVIDENCE", + message: + "The proposed body does not produce a distinct, reproducible package revision.", + }); + } + + const historicalCases = yield* Effect.try({ + try: () => { + if (draft.schema_version === 2) { + return draft.cohort.entries.map((entry) => + BlindBenchmarkCase.make({ + case_id: caseId(entry.role, entry.source), + task_payload: entry.redacted_task, + task_fingerprint: digest(entry.redacted_task), + partition: entry.role, + regression_case: false, + }), + ); + } + const resolved = new Map( + draft.resolved_evidence.map((entry) => [sourceKey(entry), entry]), + ); + return draft.cohort.entries.flatMap((entry) => { + const evidence = resolved.get(sourceKey(entry.source)); + if (!evidence) return []; + if (!evidence.query.trim() || evidence.query.length > 8_000) { + throw new HistoricalSkillImprovementFailure({ + code: "INVALID_EVIDENCE", + message: `Historical case ${entry.source.skill_invocation_id} has no bounded task.`, + }); + } + const partition = + entry.role === "heldout_failure" + ? "selection" + : entry.role === "heldout_success" + ? "audit_holdout" + : "calibration"; + return [ + BlindBenchmarkCase.make({ + case_id: caseId(entry.role, entry.source), + task_payload: evidence.query, + task_fingerprint: digest(evidence.query), + partition, + regression_case: false, + }), + ]; + }); + }, + catch: (error) => historicalFailure(error), + }); + const owningSkillId = skillId(draft.cohort.target_skill.skill_name); + const regressionRows = yield* Effect.try({ + try: () => + listActivePromotedStudyCases(options.sqlite, owningSkillId, 50) as RegressionRow[], + catch: (error) => + new HistoricalSkillImprovementFailure({ + code: "PERSISTENCE_FAILED", + message: error instanceof Error ? error.message : String(error), + }), + }); + const regressions = yield* Effect.try({ + try: () => + regressionRows.map((row) => + regressionProjection(row, input.qualified_verifier, owningSkillId), + ), + catch: (error) => historicalFailure(error), + }); + const cases = [...historicalCases, ...regressions.map((entry) => entry.benchmarkCase)]; + const protocol: BlindBenchmarkProtocol = { + cases, + candidate_generation_case_ids: historicalCases + .filter((entry) => entry.partition === "calibration") + .map((entry) => entry.case_id), + qualified_verifier: input.qualified_verifier, + current_revision: installedRevision, + installed_current_revision: installedRevision, + candidate_revision: candidateRevision, + runtime: input.runtime, + required_scored_repetitions: input.required_scored_repetitions, + max_attempts_per_arm: input.max_attempts_per_arm, + }; + const replayObservations: HistoricalSkillReplayObservation[] = []; + const executor = options.executorFactory + ? yield* options.executorFactory + .create({ + skillName: draft.cohort.target_skill.skill_name, + skillPath: installed.skill_path, + currentBody: installedBody, + candidateBody: draft.candidate.proposed_body, + currentRevision: installedRevision, + candidateRevision, + runtime: input.runtime, + qualifiedVerifier: input.qualified_verifier, + recordObservation: (observation) => replayObservations.push(observation), + }) + .pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "INVALID_EVIDENCE", + message: error.message, + }), + ), + ) + : options.executor; + if (!executor) { + return yield* new HistoricalSkillImprovementFailure({ + code: "INVALID_EVIDENCE", + message: "No managed replay harness is registered for historical evaluation.", + }); + } + const candidateId = review.draft_id; + const taskQuality = draft.schema_version === 2; + const sourceSessionId = + (taskQuality + ? draft.cohort.entries.find((entry) => entry.role === "selection")?.source.trace_id + : draft.cohort.entries.find((entry) => entry.role === "heldout_failure")?.source + .trace_id) ?? + draft.cohort.entries[0]?.source.trace_id ?? + input.pattern_id; + const hypothesisReason = taskQuality + ? "Neutral historical tasks produced a bounded skill-body hypothesis for randomized execution-quality replay." + : "Repeated trace-correlated errors produced a bounded skill-body hypothesis."; + const signalPayload = JSON.stringify({ + kind: taskQuality ? "historical_task_quality" : "repeated_correlated_errors", + pattern_id: input.pattern_id, + cohort_fingerprint: draft.cohort.fingerprint, + candidate_id: candidateId, + evidence_level: "E0", + reason: hypothesisReason, + skill: { + name: draft.cohort.target_skill.skill_name, + pre_revision: installedRevision, + post_revision: null, + }, + source: { + session_id: sourceSessionId, + }, + correction_intent: draft.candidate.rationale, + }); + const existingCandidate = yield* getCorrectionSignalCandidate( + options.sqlite, + candidateId, + ).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "PERSISTENCE_FAILED", + message: error.message, + }), + ), + ); + yield* upsertCorrectionSignalCandidate(options.sqlite, { + candidate_id: candidateId, + idempotency_key: `historical-improvement:${candidateId}`, + skill_id: owningSkillId, + skill_name: draft.cohort.target_skill.skill_name, + source_session_id: sourceSessionId, + evidence_level: "E0", + lifecycle: "review_ready", + reason: hypothesisReason, + manifest_digest: digest( + JSON.stringify({ + draft_id: review.draft_id, + cohort_fingerprint: draft.cohort.fingerprint, + candidate_revision: candidateRevision, + }), + ), + signal_payload_digest: digest(signalPayload), + signal_payload_json: signalPayload, + created_at: existingCandidate?.created_at ?? input.recorded_at, + updated_at: input.recorded_at, + }).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: "PERSISTENCE_FAILED", + message: error.message, + }), + ), + ); + const evaluation = yield* runProactiveCorrectionE2( + { + candidate: { + candidate_id: candidateId, + skill_id: owningSkillId, + skill_name: draft.cohort.target_skill.skill_name, + candidate_kind: "existing_skill_body_mutation", + installed_body: installedBody, + proposed_body: draft.candidate.proposed_body, + installed_revision: installedRevision, + candidate_revision: candidateRevision, + changed_lines: changedLineCount(installedBody, draft.candidate.proposed_body), + cross_file_edits: false, + protected_metadata_changed: false, + }, + observed_installed_revision: installedRevision, + protocol, + active_regression_cases: regressions.map((entry) => entry.activeCase), + controls: input.controls, + recorded_at: input.recorded_at, + }, + executor, + makeLocalStoreProactiveCandidateEvaluationPersistence(options.sqlite), + ).pipe( + Effect.mapError( + (error) => + new HistoricalSkillImprovementFailure({ + code: + error.code === "PERSISTENCE_FAILED" ? "PERSISTENCE_FAILED" : "INVALID_EVIDENCE", + message: error.message, + }), + ), + ); + const selectionCase = historicalCases.find( + (entry) => entry.partition === "selection" && !entry.regression_case, + ); + const currentObservation = selectionCase + ? replayObservations.find( + (entry) => entry.caseId === selectionCase.case_id && entry.arm === "current_skill", + ) + : undefined; + const candidateObservation = selectionCase + ? replayObservations.find( + (entry) => entry.caseId === selectionCase.case_id && entry.arm === "candidate_skill", + ) + : undefined; + const beforeAfter = + selectionCase && currentObservation && candidateObservation + ? { + case_id: selectionCase.case_id, + task: selectionCase.task_payload, + current: { + passed: currentObservation.passed, + output: boundedReplayOutput(currentObservation.output), + }, + candidate: { + passed: candidateObservation.passed, + output: boundedReplayOutput(candidateObservation.output), + }, + } + : null; + return { + pattern_id: input.pattern_id, + draft_id: review.draft_id, + candidate_id: candidateId, + evaluation_id: evaluation.evaluation_id, + status: evaluation.status, + evidence_level: evaluation.evidence_level, + reason: evaluation.reason, + cohort_fingerprint: draft.cohort.fingerprint, + cases: { + calibration: cases.filter((entry) => entry.partition === "calibration").length, + selection: cases.filter((entry) => entry.partition === "selection").length, + audit_holdout: cases.filter((entry) => entry.partition === "audit_holdout").length, + active_regressions: regressions.length, + }, + applies_change: false, + search: draft.schema_version === 2 ? (draft.search ?? null) : null, + before_after: beforeAfter, + } satisfies HistoricalSkillImprovementResponse; + }); + return HistoricalSkillImprovement.of({ evaluate }); + }), + ); +} diff --git a/apps/local/src/historical-skill-replay-executor.ts b/apps/local/src/historical-skill-replay-executor.ts new file mode 100644 index 00000000..ad3cbdb2 --- /dev/null +++ b/apps/local/src/historical-skill-replay-executor.ts @@ -0,0 +1,426 @@ +import type { + BlindBenchmarkExecutor, + BlindBenchmarkProtocol, +} from "@selftune/skill-intelligence/blind-benchmark"; +import { BlindBenchmarkExecutionFailure } from "@selftune/skill-intelligence/blind-benchmark"; +import { + qualifyVerifierInstrument, + type VerifierQualificationResult, +} from "@selftune/skill-intelligence/verifier-instruments"; +import { + buildRoutingReplayFixture, + resolveRuntimeReplayPlatform, + runCodexHostTaskReplay, + runHostRuntimeReplayFixture, +} from "@selftune/runtime/evolution/validate-host-replay"; +import type { RoutingReplayEntryResult } from "@selftune/runtime/types"; +import { Effect, Schema } from "effect"; + +import type { HistoricalTaskCalibrator } from "./historical-task-candidate.js"; + +export const HISTORICAL_ROUTING_VERIFIER_ID = "selftune.skill-routing-selection"; +export const HISTORICAL_ROUTING_VERIFIER_VERSION = "v1"; +export const HISTORICAL_TASK_QUALITY_VERIFIER_ID = "selftune.to-issues-draft-quality"; +export const HISTORICAL_TASK_QUALITY_VERIFIER_VERSION = "v2"; + +export interface HistoricalSkillReplayObservation { + readonly caseId: string; + readonly task: string; + readonly arm: "no_skill" | "current_skill" | "candidate_skill"; + readonly repetition: number; + readonly passed: boolean; + readonly output: string; +} + +export interface HistoricalSkillReplayContext { + readonly skillName: string; + readonly skillPath: string; + readonly currentBody: string; + readonly candidateBody: string; + readonly currentRevision: string; + readonly candidateRevision: string; + readonly runtime: BlindBenchmarkProtocol["runtime"]; + readonly qualifiedVerifier: VerifierQualificationResult; + readonly recordObservation?: (observation: HistoricalSkillReplayObservation) => void; +} + +export interface HistoricalSkillReplayExecutorFactory { + readonly create: ( + context: HistoricalSkillReplayContext, + ) => Effect.Effect; +} + +export class HistoricalSkillReplayHarnessFailure extends Schema.TaggedErrorClass()( + "HistoricalSkillReplayHarnessFailure", + { + code: Schema.Literals(["UNSUPPORTED_HARNESS", "UNSUPPORTED_VERIFIER", "HARNESS_UNAVAILABLE"]), + message: Schema.String, + }, +) {} + +type HostReplayRunner = typeof runHostRuntimeReplayFixture; +type HostTaskReplayRunner = typeof runCodexHostTaskReplay; + +function runtimeAgent(harness: string): string | null { + switch (harness) { + case "claude": + case "claude_code": + return "claude"; + case "codex": + return "codex"; + case "opencode": + return "opencode"; + default: + return null; + } +} + +function boundedCount(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; +} + +function processMetrics(result: RoutingReplayEntryResult) { + const metrics = result.runtime_metrics; + return { + turns: boundedCount(metrics?.num_turns), + input_tokens: boundedCount(metrics?.input_tokens), + output_tokens: boundedCount(metrics?.output_tokens), + tool_calls: result.triggered ? 1 : 0, + failed_tool_calls: 0, + repeated_actions: 0, + user_corrections: 0, + progress_events: 1, + wall_time_ms: boundedCount(metrics?.duration_ms), + }; +} + +function retryableReplayFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /(?:timed? ?out|temporar|rate limit|connection|socket|exited with code)/i.test(message); +} + +function supportsVerifier(qualification: VerifierQualificationResult): boolean { + return ( + qualification.status === "qualified" && + qualification.instrument.kind === "deterministic" && + qualification.instrument.verifier_id === HISTORICAL_ROUTING_VERIFIER_ID && + qualification.instrument.version === HISTORICAL_ROUTING_VERIFIER_VERSION + ); +} + +function supportsTaskQualityVerifier(qualification: VerifierQualificationResult): boolean { + return ( + qualification.status === "qualified" && + qualification.instrument.kind === "deterministic" && + qualification.instrument.verifier_id === HISTORICAL_TASK_QUALITY_VERIFIER_ID && + qualification.instrument.version === HISTORICAL_TASK_QUALITY_VERIFIER_VERSION + ); +} + +export interface ToIssuesDraftQualityAssessment { + readonly passed: boolean; + readonly failures: readonly string[]; +} + +export function assessToIssuesDraftQuality( + task: string, + output: string, +): ToIssuesDraftQualityAssessment { + const failures: string[] = []; + if (!/(?:create|break|draft|publish).{0,80}issues?/is.test(task)) { + failures.push("task_does_not_request_issues"); + } + const hasTitle = /(?:^|\n)(?:#{2,4}\s+|\d+[.)]\s+|\*\*Title\*\*\s*:)/m.test(output); + const issueCount = output.match(/(?:^|\n)\s*\d+[.)]\s+/g)?.length ?? 0; + const completionBulletCount = output.match(/(?:^|\n)[ \t]*-[ \t]+\S/g)?.length ?? 0; + const hasAcceptanceCriteria = + /\bacceptance criteria\b/i.test(output) || + (issueCount > 0 && completionBulletCount >= issueCount * 2); + const hasDependency = /\bblocked by\b/i.test(output); + const asksRedundantApproval = + /(?:granularity (?:feel|and dependency order look right)|too coarse\s*\/\s*too fine|dependency relationships correct|slices be merged or split)/i.test( + output, + ); + const claimsExternalPublication = + /(?:https?:\/\/[^\s)]+\/issues\/\d+|\b(?:published|created|opened) (?:the )?(?:github )?issues?\b)/i.test( + output, + ); + if (!hasTitle) failures.push("missing_issue_titles"); + if (!hasAcceptanceCriteria) failures.push("missing_acceptance_criteria"); + if (!hasDependency) failures.push("missing_blocked_by_dependencies"); + if (asksRedundantApproval) failures.push("redundant_approval_question"); + if (claimsExternalPublication) failures.push("false_publication_claim"); + return { passed: failures.length === 0, failures }; +} + +export function verifyToIssuesDraftQuality(task: string, output: string): boolean { + return assessToIssuesDraftQuality(task, output).passed; +} + +/** Qualification for the exact deterministic decision made by host routing replay. */ +export function historicalRoutingVerifierQualification(): VerifierQualificationResult { + return qualifyVerifierInstrument({ + instrument: { + verifier_id: HISTORICAL_ROUTING_VERIFIER_ID, + version: HISTORICAL_ROUTING_VERIFIER_VERSION, + kind: "deterministic", + success_contract: + "The isolated harness selects the target skill exactly once and selects no competing skill.", + 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: `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", + partition: "verifier_calibration" as const, + candidate_strategy_reference: null, + })), + }); +} + +export function historicalTaskQualityVerifierQualification(): VerifierQualificationResult { + const task = "Break this release plan into tracked issues."; + const controls = [ + { + id: "known-failure", + label: "known_failure" as const, + expected: "reject" as const, + output: "I published the issues. https://github.com/acme/repo/issues/123", + }, + { + id: "known-good", + label: "known_good" as const, + expected: "accept" as const, + output: + "## 1. Release readiness slice\n\n## Acceptance criteria\n- [ ] Smoke test passes\n\n## Blocked by\nNone - can start immediately", + }, + { + id: "boundary", + label: "boundary" as const, + expected: "reject" as const, + output: "1. Release readiness slice\n\nBlocked by: None", + }, + { + id: "adversarial", + label: "adversarial" as const, + expected: "reject" as const, + output: + "## 1. Release readiness\nAcceptance criteria: done\nBlocked by: none\nDoes the granularity feel right (too coarse / too fine)?", + }, + ]; + return qualifyVerifierInstrument({ + instrument: { + verifier_id: HISTORICAL_TASK_QUALITY_VERIFIER_ID, + version: HISTORICAL_TASK_QUALITY_VERIFIER_VERSION, + kind: "deterministic", + success_contract: + "An explicit issue-creation task yields complete draft issues with titles, acceptance criteria, and dependencies, without redundant approval questions or false publication claims.", + check_description: + "Checks the bounded user-facing draft with deterministic structure and external-action safety rules.", + }, + evidence: controls.map((control) => ({ + evidence_id: `historical-task-quality-${control.id}`, + label: control.label, + expected_decision: control.expected, + observed_decision: verifyToIssuesDraftQuality(task, control.output) ? "accept" : "reject", + partition: "verifier_calibration" as const, + candidate_strategy_reference: null, + })), + }); +} + +export function makeHostHistoricalTaskCalibrator(options: { + readonly agent: string; + readonly model: string; +}): HistoricalTaskCalibrator { + return async (input) => { + const platform = resolveRuntimeReplayPlatform(options.agent); + if (options.agent !== "codex" || !platform) { + throw new Error("Historical task calibration currently requires the Codex harness."); + } + const fixture = buildRoutingReplayFixture({ + skillName: input.skillName, + skillPath: input.skillPath, + platform, + stagingMode: "package", + fixtureId: `historical-calibration-${platform}-${input.skillName}`, + }); + const result = await runCodexHostTaskReplay({ + task: input.task, + body: input.body, + fixture, + contentTarget: "body", + includeTargetSkill: true, + model: options.model, + reasoningEffort: "max", + }); + const assessment = assessToIssuesDraftQuality(input.task, result.output); + return { + passed: assessment.passed, + score: Math.max(0, 1 - assessment.failures.length / 5), + output: result.output, + feedback: [ + "Required contract: complete issue titles, concrete acceptance criteria, explicit Blocked by dependencies, no redundant approval or granularity questions after an explicit create/publish request, and no false publication claim.", + `Observed failures: ${assessment.failures.join(", ") || "none"}.`, + ].join(" "), + process: { + input_tokens: result.input_tokens, + output_tokens: result.output_tokens, + wall_time_ms: result.duration_ms, + }, + }; + }; +} + +export function makeHostHistoricalSkillReplayExecutorFactory(options?: { + readonly runReplay?: HostReplayRunner; + readonly runTaskReplay?: HostTaskReplayRunner; + readonly findExecutable?: (name: string) => string | null; +}): HistoricalSkillReplayExecutorFactory { + const runReplay = options?.runReplay ?? runHostRuntimeReplayFixture; + const runTaskReplay = options?.runTaskReplay ?? runCodexHostTaskReplay; + const findExecutable = options?.findExecutable ?? ((name: string) => Bun.which(name)); + + const create = Effect.fn("HistoricalSkillReplayExecutorFactory.create")(function* ( + context: HistoricalSkillReplayContext, + ) { + const routingVerifier = supportsVerifier(context.qualifiedVerifier); + const taskQualityVerifier = supportsTaskQualityVerifier(context.qualifiedVerifier); + if (!routingVerifier && !taskQualityVerifier) { + return yield* new HistoricalSkillReplayHarnessFailure({ + code: "UNSUPPORTED_VERIFIER", + message: `Historical host replay requires verifier ${HISTORICAL_ROUTING_VERIFIER_ID}@${HISTORICAL_ROUTING_VERIFIER_VERSION} or ${HISTORICAL_TASK_QUALITY_VERIFIER_ID}@${HISTORICAL_TASK_QUALITY_VERIFIER_VERSION}.`, + }); + } + const agent = runtimeAgent(context.runtime.harness); + const platform = resolveRuntimeReplayPlatform(agent); + if (!agent || !platform) { + return yield* new HistoricalSkillReplayHarnessFailure({ + code: "UNSUPPORTED_HARNESS", + message: `Historical host replay does not support harness ${context.runtime.harness}.`, + }); + } + if (!findExecutable(agent)) { + return yield* new HistoricalSkillReplayHarnessFailure({ + code: "HARNESS_UNAVAILABLE", + message: `Historical host replay could not find the ${agent} executable.`, + }); + } + if (taskQualityVerifier && agent !== "codex") { + return yield* new HistoricalSkillReplayHarnessFailure({ + code: "UNSUPPORTED_HARNESS", + message: "Historical execution-quality replay currently requires the Codex harness.", + }); + } + const fixture = buildRoutingReplayFixture({ + skillName: context.skillName, + skillPath: context.skillPath, + platform, + stagingMode: "package", + fixtureId: `historical-${platform}-${context.skillName}`, + }); + + const execute = Effect.fn("HistoricalSkillReplayExecutor.execute")(function* ( + input: Parameters[0], + ) { + const expectedRevision = + input.arm === "no_skill" + ? null + : input.arm === "current_skill" + ? context.currentRevision + : context.candidateRevision; + const body = input.arm === "candidate_skill" ? context.candidateBody : context.currentBody; + if (taskQualityVerifier) { + const result = yield* Effect.tryPromise({ + try: () => + runTaskReplay({ + task: input.case.task_payload, + body, + fixture, + contentTarget: "body", + includeTargetSkill: input.arm !== "no_skill", + model: context.runtime.model, + reasoningEffort: "max", + }), + catch: (error) => + new BlindBenchmarkExecutionFailure({ + kind: "infrastructure", + retryable: retryableReplayFailure(error), + }), + }); + const passed = verifyToIssuesDraftQuality(input.case.task_payload, result.output); + context.recordObservation?.({ + caseId: input.case.case_id, + task: input.case.task_payload, + arm: input.arm, + repetition: input.repetition, + passed, + output: result.output, + }); + return { + kind: "scored" as const, + passed, + executed_revision: expectedRevision, + process: { + turns: 1, + input_tokens: boundedCount(result.input_tokens), + output_tokens: boundedCount(result.output_tokens), + tool_calls: 0, + failed_tool_calls: 0, + repeated_actions: 0, + user_corrections: 0, + progress_events: 1, + wall_time_ms: boundedCount(result.duration_ms), + }, + }; + } + const results = yield* Effect.tryPromise({ + try: () => + runReplay({ + routing: body, + evalSet: [ + { + query: input.case.task_payload, + should_trigger: true, + source: "log", + }, + ], + fixture, + contentTarget: "body", + includeTargetSkill: input.arm !== "no_skill", + model: context.runtime.model, + reasoningEffort: context.runtime.harness === "codex" ? "max" : undefined, + }), + catch: (error) => + new BlindBenchmarkExecutionFailure({ + kind: "infrastructure", + retryable: retryableReplayFailure(error), + }), + }); + const result = results[0]; + if (!result) { + return yield* new BlindBenchmarkExecutionFailure({ + kind: "infrastructure", + retryable: false, + }); + } + return { + kind: "scored" as const, + passed: result.passed, + executed_revision: expectedRevision, + process: processMetrics(result), + }; + }); + + return { execute } satisfies BlindBenchmarkExecutor; + }); + + return { create }; +} diff --git a/apps/local/src/historical-task-candidate.ts b/apps/local/src/historical-task-candidate.ts new file mode 100644 index 00000000..8692005b --- /dev/null +++ b/apps/local/src/historical-task-candidate.ts @@ -0,0 +1,666 @@ +import type { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; + +import { createOrGetPreparedEvaluationSubmissionDraft } from "@selftune/local-store"; +import type { DuckDbAnalyticalStoreService } from "@selftune/observability"; +import { + CohortBodyTeacherOutput, + type CohortBodyTeacher, +} from "@selftune/runtime/evolution/evidence-cohort-body-adapter"; +import { computeNonDominatedFrontier } from "@selftune/runtime/evolution/pareto"; +import { + computeSkillVersionHash, + type InstalledSkillPackage, +} from "@selftune/runtime/utils/skill-discovery"; +import { Effect, Schema } from "effect"; + +import { + bodyBelowTitle, + boundedHistoricalTask, + byteLength, + changedLineCount, + latestPackageMtimeMs, + pathCanUseInstalledSnapshot, + redactedPortableText, +} from "./historical-evidence-safety.js"; +import { preparedHistoricalTaskDraftSchema } from "./prepared-trace-candidate-draft.js"; +import { + TraceCandidatePreparationError, + type TraceCandidateReview, +} from "./trace-candidate-contract.js"; + +function stableTaskRank(value: { + readonly trace_id: string; + readonly span_id: string; + readonly skill_invocation_id: string; +}): string { + return createHash("sha256") + .update("selftune.historical-task-quality.v1") + .update("\u0000") + .update(value.trace_id) + .update("\u0000") + .update(value.span_id) + .update("\u0000") + .update(value.skill_invocation_id) + .digest("hex"); +} + +type HistoricalInvocationRow = { + skill_invocation_id: string; + session_id: string; + query: string | null; + matched_prompt: string | null; + matched_prompt_index: number | 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; +}; + +export interface HistoricalTaskCalibrationResult { + readonly passed: boolean; + readonly score?: number; + readonly output: string; + readonly feedback: string; + readonly process?: { + readonly input_tokens: number | null; + readonly output_tokens: number | null; + readonly wall_time_ms: number | null; + }; +} + +export type HistoricalTaskCalibrator = (input: { + readonly task: string; + readonly body: string; + readonly arm: "current" | "candidate"; + readonly skillName: string; + readonly skillPath: string; +}) => Promise; + +export interface PrepareHistoricalTaskCandidateOptions { + readonly analytical: DuckDbAnalyticalStoreService; + readonly sqlite: Database; + readonly teacher: CohortBodyTeacher; + readonly patternId: string; + readonly installed: InstalledSkillPackage; + readonly skillId: string; + readonly revision: string; + readonly computeRevision?: (skillPath: string) => string | undefined; + readonly candidateCount: number; + readonly calibrationRepetitions: number; + readonly calibrator?: HistoricalTaskCalibrator; +} + +const CANDIDATE_STRATEGIES = [ + "minimal corrective rule", + "explicit execution contract", + "counterexample and failure guardrail", + "structure-first checklist", + "concise token-efficient instruction", + "dependency-ordering emphasis", + "acceptance-criteria emphasis", + "direct user-intent emphasis", +] as const; + +interface CalibratedHistoricalCandidate { + readonly proposalId: string; + readonly teacher: typeof CohortBodyTeacherOutput.Type; + readonly changedLines: number; + readonly calibration: HistoricalTaskCalibrationResult; + readonly calibrationScore: number; + readonly scoredRepetitions: number; + readonly passedRepetitions: number; +} + +function lowerIsBetter(value: number | null | undefined): number { + return value ?? Number.MAX_SAFE_INTEGER; +} + +function candidateDominates( + left: CalibratedHistoricalCandidate, + right: CalibratedHistoricalCandidate, +): boolean { + const atLeastAsGood = + left.calibrationScore >= right.calibrationScore && + left.changedLines <= right.changedLines && + lowerIsBetter(left.calibration.process?.output_tokens) <= + lowerIsBetter(right.calibration.process?.output_tokens) && + lowerIsBetter(left.calibration.process?.wall_time_ms) <= + lowerIsBetter(right.calibration.process?.wall_time_ms); + const strictlyBetter = + left.calibrationScore > right.calibrationScore || + left.changedLines < right.changedLines || + lowerIsBetter(left.calibration.process?.output_tokens) < + lowerIsBetter(right.calibration.process?.output_tokens) || + lowerIsBetter(left.calibration.process?.wall_time_ms) < + lowerIsBetter(right.calibration.process?.wall_time_ms); + return atLeastAsGood && strictlyBetter; +} + +function selectHistoricalCandidate( + frontier: readonly CalibratedHistoricalCandidate[], +): CalibratedHistoricalCandidate { + const selected = frontier.toSorted((left, right) => { + const comparisons = [ + right.calibrationScore - left.calibrationScore, + left.changedLines - right.changedLines, + lowerIsBetter(left.calibration.process?.output_tokens) - + lowerIsBetter(right.calibration.process?.output_tokens), + lowerIsBetter(left.calibration.process?.input_tokens) - + lowerIsBetter(right.calibration.process?.input_tokens), + lowerIsBetter(left.calibration.process?.wall_time_ms) - + lowerIsBetter(right.calibration.process?.wall_time_ms), + left.proposalId.localeCompare(right.proposalId), + ]; + return comparisons.find((comparison) => comparison !== 0) ?? 0; + })[0]; + if (!selected) throw new Error("Cannot select from an empty historical candidate frontier."); + return selected; +} + +function historicalTaskWithContext( + sqlite: Database, + row: HistoricalInvocationRow, + skillName: string, +): string | null { + const current = + boundedHistoricalTask(row.matched_prompt, skillName) ?? + boundedHistoricalTask(row.query, skillName); + if (!current) return null; + if (row.matched_prompt_index === null) return current; + const previous = sqlite + .query( + `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 }>; + const seen = new Set([current]); + const context = previous + .flatMap((entry) => { + const task = boundedHistoricalTask(entry.prompt_text, skillName); + if (!task || seen.has(task)) return []; + seen.add(task); + return [task]; + }) + .slice(0, 2) + .toReversed(); + if (context.length === 0) return current; + return boundedHistoricalTask( + `Current request:\n${current}\n\nEarlier user context:\n${context.join("\n")}`, + skillName, + ); +} + +export const prepareHistoricalTaskCandidate = Effect.fn( + "TraceCandidatePreparation.prepareHistoricalTaskQuality", +)(function* (options: PrepareHistoricalTaskCandidateOptions) { + const references = yield* options.analytical + .queryHistoricalSkillTaskReferences({ skill_id: options.skillId, limit: 512 }) + .pipe( + Effect.mapError((error) => new TraceCandidatePreparationError({ message: error.message })), + ); + const ids = references.map((entry) => entry.skill_invocation_id); + const placeholders = ids.map(() => "?").join(","); + const rows = + ids.length === 0 + ? [] + : (options.sqlite + .query( + `SELECT + invocation.skill_invocation_id, + invocation.session_id, + invocation.query, + prompt.prompt_text AS matched_prompt, + prompt.prompt_index AS matched_prompt_index, + invocation.triggered, + invocation.invocation_mode, + invocation.source, + invocation.capture_mode, + invocation.skill_version_hash, + invocation.occurred_at, + invocation.skill_path + FROM skill_invocations invocation + LEFT JOIN prompts prompt ON prompt.prompt_id = invocation.matched_prompt_id + WHERE invocation.skill_invocation_id IN (${placeholders})`, + ) + .all(...ids) as HistoricalInvocationRow[]); + const packageMtimeMs = yield* Effect.try({ + try: () => latestPackageMtimeMs(options.installed.package_path), + catch: (error) => + new TraceCandidatePreparationError({ + message: `Could not inspect the installed skill snapshot: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + const rowsById = new Map(rows.map((row) => [row.skill_invocation_id, row])); + const seenTasks = new Set(); + const tasks = references + .toSorted((left, right) => stableTaskRank(left).localeCompare(stableTaskRank(right))) + .flatMap((reference) => { + const row = rowsById.get(reference.skill_invocation_id); + if (!row || row.invocation_mode !== "explicit" || !row.triggered) return []; + if (row.source !== "claude_code_replay" && row.capture_mode !== "hook") return []; + const occurredAt = row.occurred_at ? Date.parse(row.occurred_at) : Number.NaN; + const exactRevision = + row.skill_version_hash === options.revision || + (!row.skill_version_hash && + Number.isFinite(occurredAt) && + packageMtimeMs <= occurredAt && + pathCanUseInstalledSnapshot(row.skill_path, options.installed.skill_path)); + if (!exactRevision) return []; + const task = historicalTaskWithContext(options.sqlite, row, options.installed.name); + if (!task || seenTasks.has(task)) return []; + seenTasks.add(task); + return [{ reference, task }]; + }) + .slice(0, 4); + if (tasks.length < 3) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: null, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: + "Historical skill links exist, but fewer than 3 unique explicit tasks resolve to the exact installed revision for separate calibration, selection, and audit partitions.", + evidence: { cohort_entries: tasks.length, resolved_entries: tasks.length }, + candidate: null, + } satisfies TraceCandidateReview; + } + const selectionIndex = tasks.length - 2; + const entries = tasks.map(({ reference, task }, index) => ({ + role: + index < selectionIndex + ? ("calibration" as const) + : index === selectionIndex + ? ("selection" as const) + : ("audit_holdout" as const), + source: { + source_id: reference.source_id, + source_revision: reference.source_revision, + trace_id: reference.trace_id, + span_id: reference.span_id, + skill_invocation_id: reference.skill_invocation_id, + }, + redacted_task: task, + })); + if (byteLength(JSON.stringify(entries)) > 8_192) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: null, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: "The bounded historical task request exceeds 8 KiB.", + evidence: { cohort_entries: 0, resolved_entries: 0 }, + candidate: null, + } satisfies TraceCandidateReview; + } + const cohortWithoutFingerprint = { + schema_version: "1.0.0" as const, + selector_version: "local-historical-task-quality/v1", + pattern: { + pattern_id: options.patternId, + kind: "historical_task_quality" as const, + skill_id: options.skillId, + skill_name: options.installed.name, + }, + target_skill: { + skill_id: options.skillId, + skill_name: options.installed.name, + revision: options.revision, + }, + request_limit_bytes: 8_192, + entries, + }; + const cohortFingerprint = `sha256:${createHash("sha256") + .update("selftune.historical-task-quality.cohort.v1") + .update("\u0000") + .update(JSON.stringify(cohortWithoutFingerprint)) + .digest("hex")}`; + const installedContent = yield* Effect.tryPromise({ + try: () => Bun.file(options.installed.skill_path).text(), + catch: (error) => + new TraceCandidatePreparationError({ + message: error instanceof Error ? error.message : String(error), + }), + }); + const currentBody = bodyBelowTitle(installedContent); + const computeRevision = options.computeRevision ?? computeSkillVersionHash; + if (computeRevision(options.installed.skill_path) !== options.revision) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: "The installed skill no longer matches the cohort target revision.", + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: null, + } satisfies TraceCandidateReview; + } + if (!options.calibrator) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: + "Historical tasks are neutral until the current skill produces a measured calibration failure.", + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: null, + } satisfies TraceCandidateReview; + } + const calibrationTask = entries.find((entry) => entry.role === "calibration")?.redacted_task; + if (!calibrationTask) { + return yield* new TraceCandidatePreparationError({ + message: "The historical task cohort has no calibration partition.", + }); + } + const currentCalibrationAttempts = yield* Effect.forEach( + Array.from({ length: options.calibrationRepetitions }, (_, index) => index + 1), + () => + Effect.tryPromise({ + try: () => + options.calibrator?.({ + task: calibrationTask, + body: currentBody, + arm: "current", + skillName: options.installed.name, + skillPath: options.installed.skill_path, + }) ?? Promise.reject(new Error("Historical task calibrator is unavailable.")), + catch: (error) => + new TraceCandidatePreparationError({ + message: + error instanceof Error ? error.message : "Historical calibration replay failed.", + }), + }), + { concurrency: 1 }, + ); + const currentPassedRepetitions = currentCalibrationAttempts.filter( + (attempt) => attempt.passed, + ).length; + if (currentPassedRepetitions === options.calibrationRepetitions) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: `The current skill passed ${currentPassedRepetitions}/${options.calibrationRepetitions} calibration repetitions, so there is no measured failure to improve.`, + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: null, + } satisfies TraceCandidateReview; + } + const currentCalibration = currentCalibrationAttempts.toSorted((left, right) => { + const leftScore = left.score ?? (left.passed ? 1 : 0); + const rightScore = right.score ?? (right.passed ? 1 : 0); + return leftScore - rightScore; + })[0]!; + const calibrationExcerpt = redactedPortableText( + `Current skill passed ${currentPassedRepetitions}/${options.calibrationRepetitions} calibration repetitions.\nDeterministic verifier feedback: ${currentCalibration.feedback}\nCurrent-skill output:\n${currentCalibration.output}`, + ).slice(0, 2_000); + const calibrationEvidence = entries + .filter((entry) => entry.role === "calibration") + .map((entry) => ({ + reference: `trace://${entry.source.source_id}/${entry.source.source_revision}/${entry.source.trace_id}/${entry.source.span_id}/${entry.source.skill_invocation_id}`, + query: entry.redacted_task, + should_trigger: true, + outcome: "failed" as const, + excerpt: calibrationExcerpt, + })); + const generated = yield* Effect.forEach( + Array.from({ length: options.candidateCount }, (_, index) => index), + (index) => + Effect.tryPromise({ + try: async () => { + const teacher = Schema.decodeUnknownSync(CohortBodyTeacherOutput)( + await options.teacher({ + schema_version: 1, + cohort_id: cohortFingerprint, + cohort_fingerprint: cohortFingerprint, + skill_name: options.installed.name, + target_revision: options.revision, + current_body: currentBody, + calibration: calibrationEvidence, + search: { + candidate_index: index + 1, + candidate_count: options.candidateCount, + strategy: CANDIDATE_STRATEGIES[index] ?? `bounded strategy ${index + 1}`, + }, + }), + ); + const proposedBody = teacher.proposed_body.trim(); + return { + teacher: { ...teacher, proposed_body: proposedBody }, + changedLines: changedLineCount(currentBody, proposedBody), + }; + }, + catch: (error) => + new TraceCandidatePreparationError({ + message: error instanceof Error ? error.message : "Historical task teacher failed.", + }), + }), + { concurrency: 1 }, + ); + const bounded = generated.filter( + (entry) => + entry.changedLines > 0 && + entry.changedLines <= 40 && + byteLength(entry.teacher.proposed_body) <= 16_000, + ); + const unique = [ + ...new Map(bounded.map((entry) => [entry.teacher.proposed_body, entry] as const)).values(), + ]; + if (computeRevision(options.installed.skill_path) !== options.revision) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: "The installed skill changed while candidates were being generated.", + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: null, + } satisfies TraceCandidateReview; + } + if (unique.length === 0) { + return { + draft_id: null, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: + "Historical candidate search produced no distinct body within the bounded 40-line, 16 KiB mutation contract.", + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: null, + } satisfies TraceCandidateReview; + } + const calibrated = yield* Effect.forEach( + unique, + (entry) => + Effect.gen(function* () { + const proposalId = `evo-body-historical-${createHash("sha256") + .update(cohortFingerprint) + .update("\u0000") + .update(entry.teacher.proposed_body) + .digest("hex") + .slice(0, 20)}`; + const attempts: HistoricalTaskCalibrationResult[] = []; + for (let repetition = 1; repetition <= options.calibrationRepetitions; repetition += 1) { + const result = yield* Effect.tryPromise({ + try: () => + options.calibrator!({ + task: calibrationTask, + body: entry.teacher.proposed_body, + arm: "candidate", + skillName: options.installed.name, + skillPath: options.installed.skill_path, + }), + catch: (error) => + new TraceCandidatePreparationError({ + message: + error instanceof Error + ? error.message + : "Historical candidate calibration failed.", + }), + }); + attempts.push(result); + if (!result.passed) break; + } + const passedRepetitions = attempts.filter((attempt) => attempt.passed).length; + const lastAttempt = attempts.at(-1)!; + const sumMetric = ( + select: (attempt: HistoricalTaskCalibrationResult) => number | null | undefined, + ): number | null => { + const values = attempts.flatMap((attempt) => { + const value = select(attempt); + return value == null ? [] : [value]; + }); + return values.length === 0 ? null : values.reduce((total, value) => total + value, 0); + }; + const scores = attempts.map((attempt) => attempt.score ?? (attempt.passed ? 1 : 0)); + const candidateCalibration: HistoricalTaskCalibrationResult = { + passed: passedRepetitions === options.calibrationRepetitions, + score: scores.reduce((total, score) => total + score, 0) / scores.length, + output: lastAttempt.output, + feedback: lastAttempt.feedback, + process: { + input_tokens: sumMetric((attempt) => attempt.process?.input_tokens), + output_tokens: sumMetric((attempt) => attempt.process?.output_tokens), + wall_time_ms: sumMetric((attempt) => attempt.process?.wall_time_ms), + }, + }; + return { + proposalId, + teacher: entry.teacher, + changedLines: entry.changedLines, + calibration: candidateCalibration, + calibrationScore: Math.max(0, Math.min(1, candidateCalibration.score ?? 0)), + scoredRepetitions: attempts.length, + passedRepetitions, + } satisfies CalibratedHistoricalCandidate; + }), + { concurrency: 1 }, + ); + const eligible = calibrated.filter((entry) => entry.calibration.passed); + const buildSearchReceipt = ( + frontierIds: readonly string[], + selectedCandidateId: string | null, + ) => ({ + requested_candidates: options.candidateCount, + generated_candidates: unique.length, + calibrated_candidates: calibrated.length, + required_calibration_repetitions: options.calibrationRepetitions, + current_calibration_passed_repetitions: currentPassedRepetitions, + frontier_candidate_ids: frontierIds, + selected_candidate_id: selectedCandidateId, + selection_method: "pareto_calibration_frontier" as const, + candidate_summaries: calibrated.map((entry) => ({ + proposal_id: entry.proposalId, + calibration_passed: entry.calibration.passed, + scored_repetitions: entry.scoredRepetitions, + passed_repetitions: entry.passedRepetitions, + calibration_score: entry.calibrationScore, + changed_lines: entry.changedLines, + input_tokens: entry.calibration.process?.input_tokens ?? null, + output_tokens: entry.calibration.process?.output_tokens ?? null, + wall_time_ms: entry.calibration.process?.wall_time_ms ?? null, + frontier_member: frontierIds.includes(entry.proposalId), + selected: entry.proposalId === selectedCandidateId, + })), + }); + if (eligible.length === 0) { + const search = buildSearchReceipt([], null); + const draftPayload = { + schema_version: 2 as const, + cohort: { ...cohortWithoutFingerprint, fingerprint: cohortFingerprint }, + candidate: null, + search, + }; + yield* Schema.decodeUnknownEffect(preparedHistoricalTaskDraftSchema)(draftPayload).pipe( + Effect.mapError((error) => new TraceCandidatePreparationError({ message: error.message })), + ); + const persistedDraft = yield* createOrGetPreparedEvaluationSubmissionDraft(options.sqlite, { + draft_id: `eval-draft-${createHash("sha256") + .update(JSON.stringify(draftPayload)) + .digest("hex") + .slice(0, 32)}`, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + skill_name: options.installed.name, + skill_revision: options.revision, + payload_json: JSON.stringify(draftPayload), + }).pipe( + Effect.mapError((error) => new TraceCandidatePreparationError({ message: error.message })), + ); + return { + draft_id: persistedDraft.draft_id, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "not_ready", + failure_reason: `${calibrated.length} bounded candidates were calibrated; none fixed the measured calibration failure.`, + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: null, + search, + } satisfies TraceCandidateReview; + } + const frontier = computeNonDominatedFrontier(eligible, candidateDominates); + const selected = selectHistoricalCandidate(frontier); + const frontierIds = frontier.map((entry) => entry.proposalId); + const search = buildSearchReceipt(frontierIds, selected.proposalId); + const draftPayload = { + schema_version: 2 as const, + cohort: { ...cohortWithoutFingerprint, fingerprint: cohortFingerprint }, + candidate: { + proposal_id: selected.proposalId, + target_revision: options.revision, + proposed_body: selected.teacher.proposed_body, + rationale: selected.teacher.rationale, + }, + search, + }; + yield* Schema.decodeUnknownEffect(preparedHistoricalTaskDraftSchema)(draftPayload).pipe( + Effect.mapError((error) => new TraceCandidatePreparationError({ message: error.message })), + ); + const persistedDraft = yield* createOrGetPreparedEvaluationSubmissionDraft(options.sqlite, { + draft_id: `eval-draft-${createHash("sha256") + .update(JSON.stringify(draftPayload)) + .digest("hex") + .slice(0, 32)}`, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + skill_name: options.installed.name, + skill_revision: options.revision, + payload_json: JSON.stringify(draftPayload), + }).pipe( + Effect.mapError((error) => new TraceCandidatePreparationError({ message: error.message })), + ); + return { + draft_id: persistedDraft.draft_id, + pattern_id: options.patternId, + cohort_fingerprint: cohortFingerprint, + target_revision: options.revision, + readiness: "review_ready", + failure_reason: null, + evidence: { cohort_entries: entries.length, resolved_entries: entries.length }, + candidate: { + body: selected.teacher.proposed_body, + rationale: selected.teacher.rationale, + diff: { + changed_lines: selected.changedLines, + target_section: selected.teacher.target_section, + }, + uncertainty: selected.teacher.uncertainty, + }, + search, + } satisfies TraceCandidateReview; +}); diff --git a/apps/local/src/hosted-state.ts b/apps/local/src/hosted-state.ts new file mode 100644 index 00000000..328bb591 --- /dev/null +++ b/apps/local/src/hosted-state.ts @@ -0,0 +1,188 @@ +import { createHash } from "node:crypto"; +import { hostname, platform } from "node:os"; +import * as Schema from "effect/Schema"; + +import type { LibrarySnapshot } from "@selftune/runtime/dashboard-contract"; +import { exportPortableSkillSetPackBytes } from "@selftune/library"; +import type { + CreateSkillShareGrantRequest, + SkillShareGrantReceipt, +} from "@selftune/library/remote/types"; +import { HostedManifestReceipt } from "@selftune/control-plane"; +import { loadRemoteLibraryConfig } from "@selftune/runtime/remote-library-config"; +import { collectLocalObjects } from "@selftune/runtime/remote-library/collect"; +import { CLIError } from "@selftune/runtime/utils/cli-error"; + +type ManifestReceipt = typeof HostedManifestReceipt.Type; + +const UploadRequest = Schema.Struct({ upload_url: Schema.String }); +const UploadReceipt = Schema.Struct({ storageId: Schema.String }); +const ShareReceipt = Schema.Struct({ + share_id: Schema.String, + share_url: Schema.String, + expires_at: Schema.Number, +}); + +function cloudRequestError(operation: string, response: Response) { + return new CLIError( + `${operation} failed (${response.status}).`, + "API_ERROR", + "Your local library is unchanged. Reconnect Cloud and retry when online.", + 1, + response.status >= 500, + ); +} + +function manifestSkills(library: LibrarySnapshot) { + const staleBefore = Date.now() - 30 * 24 * 60 * 60 * 1_000; + return library.skills.map((skill) => ({ + identity: skill.skillId, + revision_hash: skill.revisions[0]?.contentHash ?? "", + scope: [...new Set(skill.locations.map((location) => location.scope))].join(","), + connections: [ + ...new Set( + skill.locations.flatMap((location) => + location.harness === null ? [] : [location.harness], + ), + ), + ], + update_status: skill.updateStatus === "untracked" ? ("unknown" as const) : skill.updateStatus, + usage_status: + skill.lastUsedAt === null + ? ("none" as const) + : Date.parse(skill.lastUsedAt) < staleBefore + ? ("stale" as const) + : ("recent" as const), + })); +} + +function manifestRevision(skills: ReturnType) { + return createHash("sha256").update(JSON.stringify(skills)).digest("hex"); +} + +export interface HostedStateOptions { + readonly fetch?: typeof fetch; + readonly deviceName?: () => string; + readonly platform?: () => string; + readonly loadConfig?: typeof loadRemoteLibraryConfig; + readonly packageForShare?: ( + input: CreateSkillShareGrantRequest, + ) => Promise<{ readonly bytes: Uint8Array; readonly label: string }>; +} + +export function isSelfTuneCloudUrl(input: string): boolean { + try { + const hostname = new URL(input).hostname.toLowerCase(); + return hostname === "cloud.selftune.dev" || hostname === "api.selftune.dev"; + } catch { + return false; + } +} + +/** The only Desktop boundary allowed to report local state to SelfTune Cloud. */ +export function makeHostedStateOperations( + configRoot: string, + loadLibrary: () => LibrarySnapshot | Promise, + options: HostedStateOptions = {}, +) { + const requestFetch = options.fetch ?? fetch; + const connection = () => + Promise.resolve((options.loadConfig ?? loadRemoteLibraryConfig)(configRoot)); + const isCloudConnection = async () => isSelfTuneCloudUrl((await connection()).url); + const sync = async (): Promise => { + const [config, library] = await Promise.all([connection(), Promise.resolve(loadLibrary())]); + const skills = manifestSkills(library); + const response = await requestFetch(new URL("/api/v1/desktop/manifest", config.url), { + method: "POST", + headers: { + authorization: `Bearer ${config.apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + revision: manifestRevision(skills), + device_name: (options.deviceName ?? hostname)(), + platform: (options.platform ?? platform)(), + skills, + }), + }); + if (!response.ok) throw cloudRequestError("Hosted-state sync", response); + return Schema.decodeUnknownSync(HostedManifestReceipt)(await response.json()); + }; + + const share = async (input: CreateSkillShareGrantRequest): Promise => { + if (input.delivery === "email") + throw new CLIError( + "Email delivery is not available for local-first sharing.", + "UNSUPPORTED_TYPE", + "Create a secure link and send it using your preferred channel.", + ); + const config = await connection(); + const packageForShare = + options.packageForShare ?? + (async () => { + if ("skillSetId" in input) + return { + bytes: exportPortableSkillSetPackBytes(input.skillSetId, { + configRoot, + }), + label: input.skillSetId, + }; + const objects = await collectLocalObjects({ + configRoot, + preferences: { + releasedSkills: false, + drafts: false, + skillSets: false, + metadata: false, + decisionHistory: false, + }, + selectedSkillIds: [input.skillId], + }); + const selected = objects[0]; + if (!selected) + throw new CLIError( + "Skill package not found.", + "NOT_FOUND", + "Refresh the Library and retry.", + ); + return { bytes: selected.bytes, label: input.skillId }; + }); + const { bytes, label } = await packageForShare(input); + const authorization = { authorization: `Bearer ${config.apiKey}` }; + const uploadRequest = await requestFetch(new URL("/api/v1/desktop/share/upload", config.url), { + method: "POST", + headers: authorization, + }); + if (!uploadRequest.ok) throw cloudRequestError("Share upload request", uploadRequest); + const { upload_url: uploadUrl } = Schema.decodeUnknownSync(UploadRequest)( + await uploadRequest.json(), + ); + const upload = await requestFetch(uploadUrl, { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: new Blob([Uint8Array.from(bytes)]), + }); + if (!upload.ok) throw cloudRequestError("Package upload", upload); + const { storageId } = Schema.decodeUnknownSync(UploadReceipt)(await upload.json()); + const issue = await requestFetch(new URL("/api/v1/desktop/share/issue", config.url), { + method: "POST", + headers: { ...authorization, "content-type": "application/json" }, + body: JSON.stringify({ + storage_id: storageId, + content_hash: createHash("sha256").update(bytes).digest("hex"), + label, + expires_in_hours: input.mode === "private_single_claim" ? 24 : 168, + }), + }); + if (!issue.ok) throw cloudRequestError("Share link creation", issue); + const receipt = Schema.decodeUnknownSync(ShareReceipt)(await issue.json()); + return { + shareId: receipt.share_id, + mode: input.mode, + delivery: "copy_link", + shareUrl: receipt.share_url, + expiresAt: new Date(receipt.expires_at).toISOString(), + }; + }; + return { isCloudConnection, sync, share } as const; +} diff --git a/apps/local/src/plugin-inventory.ts b/apps/local/src/plugin-inventory.ts new file mode 100644 index 00000000..ea46ee48 --- /dev/null +++ b/apps/local/src/plugin-inventory.ts @@ -0,0 +1,312 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import type { + PluginHostInstallationModel, + PluginHostModel, + PluginHostStatusModel, + PluginInventoryItemModel, + PluginInventoryModel, + PluginManagementActionModel, + PluginManagementInputModel, + PluginManagementReceiptModel, +} from "@selftune/dashboard-core/models"; +import { SELFTUNE_CONFIG_DIR } from "@selftune/runtime/constants"; + +export interface PluginInventoryCommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface PluginInventoryRuntime { + readonly which: (command: string) => string | null; + readonly run: (command: string, args: ReadonlyArray) => PluginInventoryCommandResult; + readonly now: () => Date; +} + +const defaultRuntime: PluginInventoryRuntime = { + which: (command) => Bun.which(command), + run: (command, args) => { + const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe" }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + }; + }, + 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; +} + +function pluginIdentity(pluginId: string): { name: string; marketplaceName: string } { + const separator = pluginId.lastIndexOf("@"); + if (separator <= 0 || separator === pluginId.length - 1) { + return { name: pluginId, marketplaceName: "unknown" }; + } + return { + name: pluginId.slice(0, separator), + marketplaceName: pluginId.slice(separator + 1), + }; +} + +function receiptPluginIds(configRoot: string): ReadonlySet { + const receiptsRoot = resolve(configRoot, "plugin-installs"); + const pluginIds = new Set(); + if (!existsSync(receiptsRoot)) return pluginIds; + + 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"); + if (pluginId) pluginIds.add(pluginId); + } + } + return pluginIds; +} + +function claudeActions(enabled: boolean, scope: string | null): PluginManagementActionModel[] { + const actions: PluginManagementActionModel[] = ["update"]; + if (scope !== "managed") { + actions.push(enabled ? "disable" : "enable", "remove"); + } + return actions; +} + +function claudeInstallations( + value: unknown, + managedPluginIds: ReadonlySet, +): PluginHostInstallationModel[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const pluginId = stringField(entry, "id"); + if (!pluginId) return []; + const identity = pluginIdentity(pluginId); + const scope = stringField(entry, "scope"); + const enabled = booleanField(entry, "enabled", true); + return [ + { + host: "claude", + hostLabel: "Claude", + pluginId, + version: stringField(entry, "version"), + enabled, + scope, + sourceType: scope === "managed" ? "managed" : "marketplace", + sourceLabel: identity.marketplaceName, + managedBySelfTune: managedPluginIds.has(pluginId), + availableActions: claudeActions(enabled, scope), + }, + ]; + }); +} + +function codexSourceType(value: unknown): PluginHostInstallationModel["sourceType"] { + if (!isRecord(value)) return "unknown"; + return stringField(value, "source") === "local" ? "local" : "marketplace"; +} + +function codexInstallations( + value: unknown, + 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 identity = pluginIdentity(pluginId); + return [ + { + host: "codex", + hostLabel: "Codex", + pluginId, + version: stringField(entry, "version"), + enabled: booleanField(entry, "enabled", true), + scope: null, + sourceType: codexSourceType(isRecord(entry) ? entry.source : null), + sourceLabel: stringField(entry, "marketplaceName") ?? identity.marketplaceName, + managedBySelfTune: managedPluginIds.has(pluginId), + availableActions: ["remove"], + }, + ]; + }); +} + +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) { + return { + status: { + host, + label, + status: "unavailable", + installedCount: 0, + message: `${label} is not installed on this machine.`, + }, + installations: [], + }; + } + + const result = runtime.run(executable, ["plugin", "list", "--json"]); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + return { + status: { + host, + label, + status: "error", + installedCount: 0, + message: detail.slice(0, 500) || `${label} did not return its plugin inventory.`, + }, + installations: [], + }; + } + + const decoded = parseJson(result.stdout); + if (decoded === null) { + return { + status: { + host, + label, + status: "error", + installedCount: 0, + message: `${label} returned an invalid plugin inventory.`, + }, + installations: [], + }; + } + const installations = + host === "claude" + ? claudeInstallations(decoded, managedPluginIds) + : codexInstallations(decoded, managedPluginIds); + return { + status: { + host, + label, + status: "available", + installedCount: installations.length, + message: null, + }, + installations, + }; +} + +function groupedPlugins( + installations: ReadonlyArray, +): PluginInventoryItemModel[] { + const groups = new Map(); + for (const installation of installations) { + const current = groups.get(installation.pluginId) ?? []; + current.push(installation); + groups.set(installation.pluginId, current); + } + return [...groups.entries()] + .map(([pluginId, hostInstallations]) => { + const identity = pluginIdentity(pluginId); + const versions = new Set( + hostInstallations.flatMap((installation) => + installation.version ? [installation.version] : [], + ), + ); + return { + pluginId, + name: identity.name, + marketplaceName: identity.marketplaceName, + installations: hostInstallations.sort((left, right) => left.host.localeCompare(right.host)), + managedBySelfTune: hostInstallations.some((installation) => installation.managedBySelfTune), + versionDrift: versions.size > 1, + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function discoverPluginInventory( + options: { readonly configRoot?: string } = {}, + runtime: PluginInventoryRuntime = defaultRuntime, +): PluginInventoryModel { + const managedPluginIds = receiptPluginIds(options.configRoot ?? SELFTUNE_CONFIG_DIR); + const claude = inspectHost("claude", runtime, managedPluginIds); + const codex = inspectHost("codex", runtime, managedPluginIds); + const plugins = groupedPlugins([...claude.installations, ...codex.installations]); + return { + hosts: [claude.status, codex.status], + plugins, + totalPlugins: plugins.length, + managedPlugins: plugins.filter((plugin) => plugin.managedBySelfTune).length, + refreshedAt: runtime.now().toISOString(), + }; +} + +function supportedScope(scope: string | null): scope is "user" | "project" | "local" | "managed" { + return scope === "user" || scope === "project" || scope === "local" || scope === "managed"; +} + +function managementCommand( + installation: PluginHostInstallationModel, + action: PluginManagementActionModel, +): ReadonlyArray { + if (installation.host === "codex") { + return ["plugin", "remove", installation.pluginId, "--json"]; + } + + const scope = supportedScope(installation.scope) ? ["--scope", installation.scope] : []; + if (action === "remove") { + return ["plugin", "uninstall", installation.pluginId, "--keep-data", ...scope]; + } + return ["plugin", action, installation.pluginId, ...scope]; +} + +export function managePluginInstallation( + input: PluginManagementInputModel, + options: { readonly configRoot?: string } = {}, + runtime: PluginInventoryRuntime = defaultRuntime, +): PluginManagementReceiptModel { + const inventory = discoverPluginInventory(options, runtime); + const installation = inventory.plugins + .find((plugin) => plugin.pluginId === input.pluginId) + ?.installations.find((candidate) => candidate.host === input.host); + if (!installation) { + throw new Error(`${input.pluginId} is not installed in ${input.host}.`); + } + if (!installation.availableActions.includes(input.action)) { + throw new Error(`${input.action} is not supported for ${input.pluginId} in ${input.host}.`); + } + const executable = runtime.which(input.host); + if (!executable) throw new Error(`${input.host} is not installed on this machine.`); + const result = runtime.run(executable, managementCommand(installation, input.action)); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + throw new Error(detail.slice(0, 1_000) || `${input.action} failed for ${input.pluginId}.`); + } + return { + ...input, + completedAt: runtime.now().toISOString(), + inventory: discoverPluginInventory(options, runtime), + }; +} diff --git a/apps/local/src/prepared-trace-candidate-draft.ts b/apps/local/src/prepared-trace-candidate-draft.ts new file mode 100644 index 00000000..fa478642 --- /dev/null +++ b/apps/local/src/prepared-trace-candidate-draft.ts @@ -0,0 +1,116 @@ +import { EvidenceCohortEntry } from "@selftune/observability"; +import { ResolvedEvidenceReference } from "@selftune/runtime/evolution/evidence-cohort-body-adapter"; +import { Schema } from "effect"; + +const correlatedErrorDraftSchema = Schema.Struct({ + schema_version: Schema.Literal(1), + cohort: Schema.Struct({ + schema_version: Schema.Literal("1.0.0"), + selector_version: Schema.String.check(Schema.isMaxLength(256)), + pattern: Schema.Struct({ + pattern_id: Schema.String.check(Schema.isMaxLength(256)), + kind: Schema.Literal("repeated_correlated_errors"), + skill_id: Schema.String.check(Schema.isMaxLength(256)), + skill_name: Schema.String.check(Schema.isMaxLength(256)), + }), + target_skill: Schema.Struct({ + skill_id: Schema.String.check(Schema.isMaxLength(256)), + skill_name: Schema.String.check(Schema.isMaxLength(256)), + revision: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + }), + excerpt_limit_bytes: Schema.Number, + request_limit_bytes: Schema.Number, + entries: Schema.Array(EvidenceCohortEntry).check(Schema.isMaxLength(14)), + fingerprint: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)), + }), + candidate: Schema.Struct({ + proposal_id: Schema.String.check(Schema.isMaxLength(128)), + target_revision: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + proposed_body: Schema.String.check(Schema.isMaxLength(16_000)), + rationale: Schema.String.check(Schema.isMaxLength(2_000)), + }), + resolved_evidence: Schema.Array(ResolvedEvidenceReference).check(Schema.isMaxLength(14)), +}); + +const historicalTaskSourceSchema = Schema.Struct({ + source_id: Schema.String.check(Schema.isMaxLength(256)), + source_revision: Schema.String.check(Schema.isMaxLength(512)), + trace_id: Schema.String.check(Schema.isPattern(/^[a-f0-9]{32}$/)), + span_id: Schema.String.check(Schema.isPattern(/^[a-f0-9]{16}$/)), + skill_invocation_id: Schema.String.check(Schema.isMaxLength(256)), +}); + +const historicalCandidateSearchSchema = Schema.Struct({ + requested_candidates: Schema.Number, + generated_candidates: Schema.Number, + calibrated_candidates: Schema.Number, + required_calibration_repetitions: Schema.Number, + current_calibration_passed_repetitions: Schema.Number, + frontier_candidate_ids: Schema.Array(Schema.String.check(Schema.isMaxLength(128))).check( + Schema.isMaxLength(8), + ), + selected_candidate_id: Schema.NullOr(Schema.String.check(Schema.isMaxLength(128))), + selection_method: Schema.Literal("pareto_calibration_frontier"), + candidate_summaries: Schema.Array( + Schema.Struct({ + proposal_id: Schema.String.check(Schema.isMaxLength(128)), + calibration_passed: Schema.Boolean, + scored_repetitions: Schema.Number, + passed_repetitions: Schema.Number, + calibration_score: Schema.Number, + changed_lines: Schema.Number, + input_tokens: Schema.NullOr(Schema.Number), + output_tokens: Schema.NullOr(Schema.Number), + wall_time_ms: Schema.NullOr(Schema.Number), + frontier_member: Schema.Boolean, + selected: Schema.Boolean, + }), + ).check(Schema.isMaxLength(8)), +}); + +export const preparedHistoricalTaskDraftSchema = Schema.Struct({ + schema_version: Schema.Literal(2), + cohort: Schema.Struct({ + schema_version: Schema.Literal("1.0.0"), + selector_version: Schema.String.check(Schema.isMaxLength(256)), + pattern: Schema.Struct({ + pattern_id: Schema.String.check(Schema.isMaxLength(256)), + kind: Schema.Literal("historical_task_quality"), + skill_id: Schema.String.check(Schema.isMaxLength(256)), + skill_name: Schema.String.check(Schema.isMaxLength(256)), + }), + target_skill: Schema.Struct({ + skill_id: Schema.String.check(Schema.isMaxLength(256)), + skill_name: Schema.String.check(Schema.isMaxLength(256)), + revision: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + }), + request_limit_bytes: Schema.Number, + entries: Schema.Array( + Schema.Struct({ + role: Schema.Literals(["calibration", "selection", "audit_holdout"]), + source: historicalTaskSourceSchema, + redacted_task: Schema.String.check(Schema.isMaxLength(512)), + }), + ).check(Schema.isLengthBetween(3, 12)), + fingerprint: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)), + }), + candidate: Schema.NullOr( + Schema.Struct({ + proposal_id: Schema.String.check(Schema.isMaxLength(128)), + target_revision: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + proposed_body: Schema.String.check(Schema.isMaxLength(16_000)), + rationale: Schema.String.check(Schema.isMaxLength(2_000)), + }), + ), + search: Schema.optionalKey(historicalCandidateSearchSchema), +}); + +const preparedCandidateDraftSchema = Schema.Union([ + correlatedErrorDraftSchema, + preparedHistoricalTaskDraftSchema, +]); + +export type PreparedTraceCandidateDraft = typeof preparedCandidateDraftSchema.Type; + +export const decodePreparedTraceCandidateDraft = (value: unknown) => + Schema.decodeUnknownEffect(preparedCandidateDraftSchema)(value); diff --git a/apps/local/src/proactive-correction-e2-service.ts b/apps/local/src/proactive-correction-e2-service.ts index 4883f658..fb0d3201 100644 --- a/apps/local/src/proactive-correction-e2-service.ts +++ b/apps/local/src/proactive-correction-e2-service.ts @@ -11,6 +11,8 @@ import { createOrGetCorrectionCandidateEvaluation } from "@selftune/local-store" import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { changedLineCount } from "./historical-evidence-safety.js"; + const Identifier = Schema.String.check( Schema.isNonEmpty(), Schema.isMaxLength(128), @@ -178,17 +180,6 @@ function stableId(prefix: string, canonical: string): string { return `${prefix}-${createHash("sha256").update(canonical).digest("hex").slice(0, 32)}`; } -function changedLines(before: string, after: string): number { - const left = before.split("\n"); - const right = after.split("\n"); - const length = Math.max(left.length, right.length); - let changed = 0; - for (let index = 0; index < length; index += 1) { - if (left[index] !== right[index]) changed += 1; - } - return changed; -} - function candidateReason(candidate: ProactiveExistingSkillBodyCandidate): string | null { if (candidate.candidate_revision === candidate.installed_revision) return "candidate_revision_not_distinct"; @@ -200,7 +191,7 @@ function candidateReason(candidate: ProactiveExistingSkillBodyCandidate): string !Number.isInteger(candidate.changed_lines) || candidate.changed_lines < 1 || candidate.changed_lines > 40 || - changedLines(candidate.installed_body, candidate.proposed_body) !== candidate.changed_lines + changedLineCount(candidate.installed_body, candidate.proposed_body) !== candidate.changed_lines ) return "invalid_changed_line_bound"; return null; diff --git a/apps/local/src/remote-library-operations.ts b/apps/local/src/remote-library-operations.ts index 9c8ae5f5..a5a18f26 100644 --- a/apps/local/src/remote-library-operations.ts +++ b/apps/local/src/remote-library-operations.ts @@ -1,13 +1,20 @@ import { dirname, join, resolve } from "node:path"; +import { createHash } from "node:crypto"; import type { CreateRemoteLibraryShareRequest } from "@selftune/runtime/dashboard-contract"; import { createRemoteLibraryHandle } from "@selftune/library/remote/transport"; import { + createSelfHostedSkillSetPack, actOnRemoteLibraryShare, createRemoteLibraryShare, createSkillShareGrant, + listSkillSetPacks, listRemoteLibraryShares, + revokeSkillSetPack, } from "@selftune/library/remote/sharing"; +import { exportPortableSkillSetPackBytes } from "@selftune/library"; +import { RemoteArtifact } from "@selftune/control-plane"; +import { syncRemoteObjects } from "@selftune/library/remote/sync"; import { listWorkspaceSkillSetPolicies, resetWorkspaceSkillSetPolicy, @@ -19,6 +26,7 @@ import type { } from "@selftune/library/remote/types"; import { inviteWorkspaceMember, + getWorkspaceTeamOverview, listWorkspaceMembers, removeWorkspaceMember, updateWorkspaceMemberRole, @@ -33,10 +41,12 @@ import { syncRemoteLibrary, } from "@selftune/runtime/remote-library-sync"; import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { DEFAULT_CLOUD_API_URL } from "@selftune/runtime/auth/device-code"; export type RemoteLibraryAction = "status" | "sync" | "export" | "restore"; export type RemoteLibraryShareAction = "list" | "create" | "accept" | "import" | "revoke"; export type RemoteWorkspaceAction = + | "overview" | "members" | "invite" | "role" @@ -100,7 +110,7 @@ export function makeRemoteLibraryOperations(configRootInput: string) { apiKey: config.apiKey, }); try { - const result = await syncRemoteLibrary({ + return await syncRemoteLibrary({ handle, configRoot, preferences: { @@ -112,27 +122,6 @@ export function makeRemoteLibraryOperations(configRootInput: string) { }, selectedSkillIds: [skillId], }); - const [artifact] = result.syncedArtifacts; - if ( - result.syncedArtifacts.length !== 1 || - !artifact || - artifact.artifactType !== "skill_revision" || - !artifact.artifactId.startsWith("backup-skill/") - ) { - throw new CLIError( - `Sync & Backup did not return one immutable revision for skill "${skillId}".`, - "GUARD_BLOCKED", - "Refresh the Skill Manager catalog and retry the share.", - ); - } - return { - ...result, - subject: { - skillId, - snapshotId: result.snapshot.snapshotId, - artifactId: artifact.artifactId, - }, - }; } finally { await handle.dispose(); } @@ -145,7 +134,53 @@ export function makeRemoteLibraryOperations(configRootInput: string) { const config = loadRemoteLibraryConfig(configRoot); if (action === "list") return listRemoteLibraryShares(config); if (action === "create") { - if (input && "mode" in input) return createSkillShareGrant(config, input); + if (input && "mode" in input) { + if (!("skillSetId" in input)) return createSkillShareGrant(config, input); + const cloud = new URL(config.url).origin === new URL(DEFAULT_CLOUD_API_URL).origin; + if (cloud) return createSkillShareGrant(config, input); + const bytes = exportPortableSkillSetPackBytes(input.skillSetId, { configRoot }); + const objectHash = createHash("sha256").update(bytes).digest("hex"); + const handle = createRemoteLibraryHandle({ baseUrl: config.url, apiKey: config.apiKey }); + try { + const artifactId = `skill-set/${input.skillSetId}/${objectHash}`; + const synced = await syncRemoteObjects({ + handle, + objects: [ + { + bytes, + artifact: RemoteArtifact.make({ + artifactId, + artifactType: "skill_set", + objectHash, + revisionHash: objectHash, + updatedAt: new Date().toISOString(), + }), + }, + ], + }); + if (input.delivery === "email") { + const share = await createRemoteLibraryShare(config, { + snapshot_id: synced.snapshot.snapshotId, + artifact_id: artifactId, + recipient_email: input.recipientEmail, + }); + return { + shareId: share.id, + mode: "private_single_claim" as const, + delivery: "email" as const, + shareUrl: null, + expiresAt: share.expires_at ?? "", + }; + } + return createSelfHostedSkillSetPack(config, { + snapshot_id: synced.snapshot.snapshotId, + artifact_id: artifactId, + mode: input.mode, + }); + } finally { + await handle.dispose(); + } + } if (!input || !("recipient_email" in input)) { throw new CLIError("Private share details are required.", "MISSING_FLAG"); } @@ -164,6 +199,17 @@ export function makeRemoteLibraryOperations(configRootInput: string) { return result; }; + const listPacks = async () => { + const config = loadRemoteLibraryConfig(configRoot); + return listSkillSetPacks(config); + }; + + const revokePack = async (packId: string) => { + const config = loadRemoteLibraryConfig(configRoot); + await revokeSkillSetPack(config, packId); + return { packId, status: "revoked" as const }; + }; + const setPolicy = async ( skillSetId: string, input: { action: WorkspaceSkillSetPolicyAction; reason?: string | null }, @@ -179,6 +225,7 @@ export function makeRemoteLibraryOperations(configRootInput: string) { const workspace = async (action: RemoteWorkspaceAction, input?: RemoteWorkspaceInput) => { const config = loadRemoteLibraryConfig(configRoot); + if (action === "overview") return getWorkspaceTeamOverview(config); if (action === "members") return listWorkspaceMembers(config); if (action === "policies") return policies(); if (action === "policy_update" || action === "policy_reset") { @@ -209,6 +256,8 @@ export function makeRemoteLibraryOperations(configRootInput: string) { run, backupSkill, share, + listPacks, + revokePack, policies, setPolicy, resetPolicy, diff --git a/apps/local/src/routes/application.ts b/apps/local/src/routes/application.ts index 58e655ba..811a926b 100644 --- a/apps/local/src/routes/application.ts +++ b/apps/local/src/routes/application.ts @@ -114,6 +114,26 @@ const ApplySkillSetBody = Schema.Struct({ project_root: Schema.String, policy_approval: Schema.optional(Schema.Boolean), }); +const PluginExportBody = Schema.Struct({ + set_id: Schema.String, + target: Schema.Literals(["claude", "openai", "agent-plugins-v1", "dual", "all"]), +}); +const PluginInstallPreviewBody = Schema.Struct({ set_id: Schema.String }); +const PluginInstallBody = Schema.Struct({ + set_id: Schema.String, + expected_revision_hash: Schema.String, + hosts: Schema.Array(Schema.Literals(["claude", "codex"])), +}); +const PluginManagementBody = Schema.Struct({ + host: Schema.Literals(["claude", "codex"]), + plugin_id: Schema.String, + action: Schema.Literals(["update", "enable", "disable", "remove"]), +}); +const SkillSetPackPreviewBody = Schema.Struct({ pack_url: Schema.String }); +const SkillSetPackImportBody = Schema.Struct({ + pack_url: Schema.String, + expected_object_sha256: Schema.String, +}); const ProjectProvisionBody = Schema.Struct({ project_root: Schema.String, set_ids: Schema.Array(Schema.String), @@ -177,6 +197,9 @@ const BillingCheckoutBody = Schema.Struct({ seats: Schema.optional(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)))), }); const BillingFinalizeBody = Schema.Struct({ session_id: Schema.NonEmptyString }); +const TeamRolloutPolicyBody = Schema.Struct({ + policy: Schema.Literals(["manual", "notify", "automatic"]), +}); const OnboardingBody = Schema.Struct({ import_sources: Schema.Array(HarnessId), hook_harnesses: Schema.Array(HookHarnessId), @@ -257,6 +280,19 @@ function requireNonBlank( ): Effect.Effect { return value.trim() ? Effect.succeed(value) : Effect.fail(requestError(operation, code, message)); } +function decodeRouteSegment( + operation: string, + value: string, +): Effect.Effect { + return Effect.try({ + try: () => decodeURIComponent(value), + catch: () => requestError(operation, "INVALID_FLAG", "The collaboration ID is malformed."), + }).pipe( + Effect.flatMap((decoded) => + requireNonBlank(operation, decoded, "INVALID_FLAG", "The collaboration ID is required."), + ), + ); +} function json(value: unknown, status = 200): Response { return Response.json(value, { status, headers: dashboardCorsHeaders() }); } @@ -668,10 +704,89 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function return json(yield* operations.skillSets); } + if (url.pathname === "/api/v2/plugins" && request.method === "GET") { + return json(yield* operations.plugins); + } + + if (url.pathname === "/api/v2/plugins/manage" && request.method === "POST") { + const unauthorized = mutationFailure(request, context); + if (unauthorized) return unauthorized; + const body = yield* decodeBody( + "plugins.manage", + request, + PluginManagementBody, + "INVALID_FLAG", + "host, plugin_id, and a supported plugin action are required.", + ); + yield* requireNonBlank( + "plugins.manage", + body.plugin_id, + "MISSING_FLAG", + "plugin_id is required.", + ); + return json( + yield* operations.managePlugin({ + host: body.host, + pluginId: body.plugin_id, + action: body.action, + }), + ); + } + + if (url.pathname === "/api/v2/skill-sets/packs" && request.method === "GET") { + return json(yield* operations.listSkillSetPacks()); + } + + const skillSetMatch = /^\/api\/v2\/skill-sets\/([^/]+)$/.exec(url.pathname); + if (skillSetMatch && request.method === "DELETE") { + if (!operations.skillSetsWritable) return readOnlySkillSetsResponse(); + const unauthorized = mutationFailure(request, context); + if (unauthorized) return unauthorized; + const result = yield* operations.deleteSkillSet(decodeURIComponent(skillSetMatch[1] ?? "")); + yield* resourcesChanged(projectSkillSetResources.remove); + context.onSkillSetChanged?.(); + return 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( + yield* operations.revokeSkillSetPack(decodeURIComponent(skillSetPackMatch[1] ?? "")), + ); + } + if (url.pathname.startsWith("/api/v2/skill-sets") && request.method === "POST") { if (!operations.skillSetsWritable) return readOnlySkillSetsResponse(); const unauthorized = mutationFailure(request, context); if (unauthorized) return unauthorized; + if (url.pathname === "/api/v2/skill-sets/packs/preview") { + const body = yield* decodeBody( + "skill_sets.pack_preview", + request, + SkillSetPackPreviewBody, + "MISSING_FLAG", + "pack_url is required.", + ); + return json(yield* operations.previewSkillSetPack(body.pack_url)); + } + if (url.pathname === "/api/v2/skill-sets/packs/import") { + const body = yield* decodeBody( + "skill_sets.pack_import", + request, + SkillSetPackImportBody, + "MISSING_FLAG", + "pack_url and expected_object_sha256 are required.", + ); + const result = yield* operations.importSkillSetPack({ + packUrl: body.pack_url, + expectedObjectSha256: body.expected_object_sha256, + }); + yield* resourcesChanged(projectSkillSetResources.create); + context.onSkillSetChanged?.(); + return json(result); + } if (url.pathname === "/api/v2/skill-sets") { const body = yield* decodeBody( "skill_sets.create", @@ -744,6 +859,42 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function yield* resourcesChanged(projectSkillSetResources.export); return json(receipt); } + if (url.pathname === "/api/v2/skill-sets/plugin-export") { + const body = yield* decodeBody( + "skill_sets.plugin_export", + request, + PluginExportBody, + "MISSING_FLAG", + "set_id and target are required.", + ); + return json(yield* operations.exportSkillSetPlugin(body)); + } + if (url.pathname === "/api/v2/skill-sets/plugin-install/preview") { + const body = yield* decodeBody( + "skill_sets.plugin_install_preview", + request, + PluginInstallPreviewBody, + "MISSING_FLAG", + "set_id is required.", + ); + return json(yield* operations.previewSkillSetPluginInstall(body.set_id)); + } + if (url.pathname === "/api/v2/skill-sets/plugin-install") { + const body = yield* decodeBody( + "skill_sets.plugin_install", + request, + PluginInstallBody, + "MISSING_FLAG", + "set_id, expected_revision_hash, and hosts are required.", + ); + return json( + yield* operations.installSkillSetPlugin({ + setId: body.set_id, + expectedRevisionHash: body.expected_revision_hash, + hosts: body.hosts, + }), + ); + } if (url.pathname === "/api/v2/skill-sets/plan") { const body = yield* decodeBody( "skill_sets.plan", @@ -883,6 +1034,47 @@ const routeApplicationRequest = Effect.fn("DashboardApplication.route")(function return json(yield* operations.cloudBilling("finalize", { sessionId: body.session_id })); } + if (url.pathname === "/api/v2/team-collaboration/access" && request.method === "GET") { + return json(yield* operations.teamCollaborationAccess); + } + if (url.pathname === "/api/v2/team-collaboration" && request.method === "GET") { + return json(yield* operations.teamCollaborationSnapshot); + } + const collaborationRolloutMatch = url.pathname.match( + /^\/api\/v2\/team-collaboration\/registry\/([^/]+)\/rollout-policy$/, + ); + if (collaborationRolloutMatch && request.method === "PATCH") { + const unauthorized = mutationFailure(request, context); + if (unauthorized) return unauthorized; + const entryId = yield* decodeRouteSegment( + "team_collaboration.rollout_policy.update", + collaborationRolloutMatch[1] ?? "", + ); + const body = yield* decodeBody( + "team_collaboration.rollout_policy.update", + request, + TeamRolloutPolicyBody, + "INVALID_FLAG", + "Choose manual, notify, or automatic rollout.", + ); + return json(yield* operations.updateTeamCollaborationRolloutPolicy(entryId, body.policy)); + } + const collaborationDecisionMatch = url.pathname.match( + /^\/api\/v2\/team-collaboration\/contributions\/([^/]+)\/(adopt|reject|rollback)$/, + ); + if (collaborationDecisionMatch && request.method === "POST") { + const unauthorized = mutationFailure(request, context); + if (unauthorized) return unauthorized; + const contributionId = yield* decodeRouteSegment( + "team_collaboration.contribution.decision", + collaborationDecisionMatch[1] ?? "", + ); + const action = collaborationDecisionMatch[2]; + if (action === "adopt" || action === "reject" || action === "rollback") { + return json(yield* operations.decideTeamCollaborationContribution(contributionId, action)); + } + } + if (url.pathname === "/api/v2/settings/remote-library/preview" && request.method === "POST") { const unauthorized = mutationFailure(request, context, "A same-origin request is required."); if (unauthorized) return unauthorized; diff --git a/apps/local/src/routes/core.ts b/apps/local/src/routes/core.ts index 1fba0131..4209b2f1 100644 --- a/apps/local/src/routes/core.ts +++ b/apps/local/src/routes/core.ts @@ -31,8 +31,10 @@ import { handleOverview, handleReport, handleSkillReport, + handleTrayStatus, runAction, summarizeOverview, + summarizeTrayStatus, } from "./index.js"; export interface DashboardCoreRouteOverrides { @@ -224,6 +226,15 @@ export function createDashboardCoreRoutes(options: DashboardCoreRouteOptions): D : unavailableResponse(); } + if (url.pathname === "/api/v2/tray-status" && request.method === "GET") { + if (options.overviewLoader) { + return Response.json(summarizeTrayStatus(options.overviewLoader()), { + headers: dashboardCorsHeaders(), + }); + } + return database ? withDashboardCors(handleTrayStatus(database)) : unavailableResponse(); + } + if (url.pathname === "/api/v2/orchestrate-runs" && request.method === "GET") { if (!database) return unavailableResponse(); const rawLimit = url.searchParams.get("limit"); diff --git a/apps/local/src/routes/correction-studies.ts b/apps/local/src/routes/correction-studies.ts index 506d9715..06cc6afb 100644 --- a/apps/local/src/routes/correction-studies.ts +++ b/apps/local/src/routes/correction-studies.ts @@ -147,8 +147,10 @@ export function createCorrectionStudyRoutes( } if (url.pathname === "/api/v2/correction-studies/reviews" && request.method === "GET") { - const unauthorized = sameOriginFailure(request, allowedOrigins); - if (unauthorized) return unauthorized; + if (request.headers.has("origin")) { + const unauthorized = sameOriginFailure(request, allowedOrigins); + if (unauthorized) return unauthorized; + } const input = signalQuery(url); if (!input || !options.listReviews) return errorResponse( diff --git a/apps/local/src/routes/index.ts b/apps/local/src/routes/index.ts index d996640e..81a27192 100644 --- a/apps/local/src/routes/index.ts +++ b/apps/local/src/routes/index.ts @@ -16,6 +16,12 @@ export { type CorrectionStudyRouteOptions, } from "./correction-studies.js"; export { handleOrchestrateRuns } from "./orchestrate-runs.js"; -export { handleDashboardShell, handleOverview, summarizeOverview } from "./overview.js"; +export { + handleDashboardShell, + handleOverview, + handleTrayStatus, + summarizeOverview, + summarizeTrayStatus, +} from "./overview.js"; export { handleReport } from "./report.js"; export { handleSkillReport } from "./skill-report.js"; diff --git a/apps/local/src/routes/library-transfer.test.ts b/apps/local/src/routes/library-transfer.test.ts index aa68e4bc..dc331c3d 100644 --- a/apps/local/src/routes/library-transfer.test.ts +++ b/apps/local/src/routes/library-transfer.test.ts @@ -1,179 +1,35 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it } from "vitest"; import { backedArtifact } from "./library-transfer.js"; describe("library share backup resolution", () => { - it("uses the exact backed artifact without equating catalog ID and display name", () => { + it("selects the requested skill revision from the returned immutable snapshot", () => { expect( backedArtifact( { - subject: { - skillId: "code reviewer", - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/hash-2", - }, - syncedArtifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], snapshot: { snapshotId: "snapshot-1", artifacts: [ - { - artifactId: "backup-skill/Other Skill/hash-1", - artifactType: "skill_revision", - }, - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, + { artifactId: "skill/other/hash-1", artifactType: "skill_revision" }, + { artifactId: "skill/reviewer/hash-2", artifactType: "skill_revision" }, ], }, }, - "code reviewer", + "reviewer", ), - ).toEqual({ - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/hash-2", - }); - }); - - it.each([ - { - name: "a subject mismatch", - result: { - subject: { - skillId: "another skill", - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/hash-2", - }, - syncedArtifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], - snapshot: { - snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], - }, - }, - }, - { - name: "a partial reference", - result: { - subject: { - skillId: "code reviewer", - snapshotId: "snapshot-1", - }, - syncedArtifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], - snapshot: { - snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], - }, - }, - }, - { - name: "an artifact not present in the referenced snapshot", - result: { - subject: { - skillId: "code reviewer", - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/hash-2", - }, - syncedArtifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], - snapshot: { - snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "backup-skill/Another Skill/hash-1", - artifactType: "skill_revision", - }, - ], - }, - }, - }, - { - name: "a historical artifact that was not part of this backup", - result: { - subject: { - skillId: "code reviewer", - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/hash-2", - }, - syncedArtifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-3", - artifactType: "skill_revision", - }, - ], - snapshot: { - snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "backup-skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - { - artifactId: "backup-skill/Code Reviewer/hash-3", - artifactType: "skill_revision", - }, - ], - }, - }, - }, - ])("rejects $name", ({ result }) => { - expect(backedArtifact(result, "code reviewer")).toBeNull(); + ).toEqual({ snapshotId: "snapshot-1", artifactId: "skill/reviewer/hash-2" }); }); - it("rejects an artifact from the released-skill namespace", () => { + it("does not substitute another skill when the backup response is incomplete", () => { expect( backedArtifact( { - subject: { - skillId: "code reviewer", - snapshotId: "snapshot-1", - artifactId: "skill/Code Reviewer/hash-2", - }, - syncedArtifacts: [ - { - artifactId: "skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], snapshot: { snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "skill/Code Reviewer/hash-2", - artifactType: "skill_revision", - }, - ], + artifacts: [{ artifactId: "skill/other/hash-1", artifactType: "skill_revision" }], }, }, - "code reviewer", + "reviewer", ), ).toBeNull(); }); diff --git a/apps/local/src/routes/library-transfer.ts b/apps/local/src/routes/library-transfer.ts index 2dac8b56..0131bf8f 100644 --- a/apps/local/src/routes/library-transfer.ts +++ b/apps/local/src/routes/library-transfer.ts @@ -9,62 +9,75 @@ const InstallBody = Schema.Struct({ skill_id: Schema.String, target_agent: Schema.Literals(["codex", "claude_code", "opencode", "openclaw", "pi"]), }); -const ShareBody = Schema.Struct({ +const ShareBody = Schema.Union([ + Schema.Struct({ + skill_id: Schema.String, + mode: Schema.Literals(["reusable_unlisted", "private_single_claim"]), + delivery: Schema.Literal("copy_link"), + }), + Schema.Struct({ + skill_id: Schema.String, + mode: Schema.Literal("private_single_claim"), + delivery: Schema.Literal("email"), + recipient_email: Schema.String, + }), +]); +const ShareSkillSetBody = Schema.Union([ + Schema.Struct({ + set_id: Schema.String, + mode: Schema.Literals(["reusable_unlisted", "private_single_claim"]), + delivery: Schema.Literal("copy_link"), + }), + Schema.Struct({ + set_id: Schema.String, + mode: Schema.Literal("private_single_claim"), + delivery: Schema.Literal("email"), + recipient_email: Schema.String, + }), +]); +const LicenseDraftTermsBody = Schema.Struct({ + copyright_holder: Schema.String, + licensed_organization: Schema.String, + year: Schema.Number, +}); +const PreviewLicenseBody = Schema.Struct({ skill_id: Schema.String, - mode: Schema.Literal("reusable_unlisted"), - delivery: Schema.Literal("copy_link"), + terms: LicenseDraftTermsBody, }); -const ShareSkillSetBody = Schema.Struct({ - set_id: Schema.String, - mode: Schema.Literal("reusable_unlisted"), - delivery: Schema.Literal("copy_link"), +const ApplyLicenseBody = Schema.Struct({ + skill_id: Schema.String, + preview_id: Schema.String, + terms: LicenseDraftTermsBody, }); -function property(target: unknown, key: string): unknown { - return typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined; -} - -function matchingSkillRevisionCount(value: unknown, artifactId: string): number { - if (!Array.isArray(value)) return 0; - return value.filter( - (candidate) => - typeof candidate === "object" && - candidate !== null && - Reflect.get(candidate, "artifactId") === artifactId && - Reflect.get(candidate, "artifactType") === "skill_revision", - ).length; +function licenseTerms(body: typeof LicenseDraftTermsBody.Type) { + return { + copyrightHolder: body.copyright_holder, + licensedOrganization: body.licensed_organization, + year: body.year, + }; } export function backedArtifact(value: unknown, skillId: string) { - const subject = property(value, "subject"); - const snapshot = property(value, "snapshot"); - const rawSnapshotId = property(snapshot, "snapshotId"); - const rawSubjectSkillId = property(subject, "skillId"); - const rawSubjectSnapshotId = property(subject, "snapshotId"); - const rawSubjectArtifactId = property(subject, "artifactId"); - const snapshotId = typeof rawSnapshotId === "string" ? rawSnapshotId : null; - const subjectSkillId = typeof rawSubjectSkillId === "string" ? rawSubjectSkillId : null; - const subjectSnapshotId = typeof rawSubjectSnapshotId === "string" ? rawSubjectSnapshotId : null; - const subjectArtifactId = typeof rawSubjectArtifactId === "string" ? rawSubjectArtifactId : null; - const artifactParts = subjectArtifactId?.split("/") ?? []; - if ( - subjectSkillId !== skillId || - subjectSnapshotId !== snapshotId || - artifactParts.length !== 3 || - artifactParts[0] !== "backup-skill" || - artifactParts[1]?.length === 0 || - artifactParts[2]?.length === 0 - ) { - return null; - } - const rawArtifacts = property(snapshot, "artifacts"); - const rawSyncedArtifacts = property(value, "syncedArtifacts"); - return snapshotId && - subjectArtifactId && - matchingSkillRevisionCount(rawArtifacts, subjectArtifactId) === 1 && - matchingSkillRevisionCount(rawSyncedArtifacts, subjectArtifactId) === 1 - ? { snapshotId, artifactId: subjectArtifactId } - : null; + 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; } function invalidBody(operation: string, message: string): DashboardOperationError { @@ -110,6 +123,8 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary "/api/v2/library/backup", "/api/v2/library/install", "/api/v2/library/share", + "/api/v2/library/license/preview", + "/api/v2/library/license/apply", "/api/v2/skill-sets/share", ].includes(url.pathname) ) { @@ -117,6 +132,40 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary } const unauthorized = sameOriginFailure(request, allowedOrigins); if (unauthorized) return { response: unauthorized, installed: false }; + if (url.pathname === "/api/v2/library/license/preview") { + const body = yield* decode( + request, + "library.license.preview", + PreviewLicenseBody, + "skill_id and license terms are required.", + ); + return { + response: Response.json( + yield* operations.previewLicenseDraft(body.skill_id, licenseTerms(body.terms)), + { headers: dashboardCorsHeaders() }, + ), + installed: false, + }; + } + if (url.pathname === "/api/v2/library/license/apply") { + const body = yield* decode( + request, + "library.license.apply", + ApplyLicenseBody, + "skill_id, preview_id, and license terms are required.", + ); + return { + response: Response.json( + yield* operations.applyLicenseDraft( + body.skill_id, + body.preview_id, + licenseTerms(body.terms), + ), + { headers: dashboardCorsHeaders() }, + ), + installed: false, + }; + } if (url.pathname === "/api/v2/skill-sets/share") { const body = yield* decode( request, @@ -124,15 +173,23 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary ShareSkillSetBody, "set_id and share details are required.", ); + const input = + body.delivery === "email" + ? { + skillSetId: body.set_id, + mode: "private_single_claim" as const, + delivery: "email" as const, + recipientEmail: body.recipient_email, + } + : { + skillSetId: body.set_id, + mode: body.mode, + delivery: "copy_link" as const, + }; return { - response: Response.json( - yield* operations.remoteLibraryShare("create", { - skillSetId: body.set_id, - mode: body.mode, - delivery: body.delivery, - }), - { headers: dashboardCorsHeaders() }, - ), + response: Response.json(yield* operations.remoteLibraryShare("create", input), { + headers: dashboardCorsHeaders(), + }), installed: false, }; } @@ -165,16 +222,25 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary "The backed-up skill artifact could not be resolved.", ); } + const input = + body.delivery === "email" + ? { + skillId: body.skill_id, + ...artifact, + mode: "private_single_claim" as const, + delivery: "email" as const, + recipientEmail: body.recipient_email, + } + : { + skillId: body.skill_id, + ...artifact, + mode: body.mode, + delivery: "copy_link" as const, + }; return { - response: Response.json( - yield* operations.remoteLibraryShare("create", { - skillId: body.skill_id, - ...artifact, - mode: body.mode, - delivery: body.delivery, - }), - { headers: dashboardCorsHeaders() }, - ), + response: Response.json(yield* operations.remoteLibraryShare("create", input), { + headers: dashboardCorsHeaders(), + }), installed: false, }; } diff --git a/apps/local/src/routes/overview.ts b/apps/local/src/routes/overview.ts index 51a80d9b..3e3552a8 100644 --- a/apps/local/src/routes/overview.ts +++ b/apps/local/src/routes/overview.ts @@ -9,8 +9,6 @@ import type { Database } from "bun:sqlite"; import type { - AttentionItem, - AutonomousDecision, AutonomyStatus, AutonomyStatusLevel, DashboardShellResponse, @@ -25,6 +23,7 @@ import { getRecentDecisions, getSkillTrustSummaries, getSkillsList, + getTrayAttentionSummary, } from "@selftune/runtime/localdb/queries"; import { buildCreatorTestingOverview, @@ -48,6 +47,12 @@ export function summarizeOverview(response: OverviewResponse): DashboardShellRes }; } +export function summarizeTrayStatus( + response: Pick, +): Pick { + return { autonomy_status: response.autonomy_status }; +} + export function handleDashboardShell(db: Database, version: string): Response { const skills = getSkillsList(db, []); const latestEvolutions = db @@ -88,13 +93,14 @@ export function handleOverview( const trustWatchlist = buildTrustWatchlist(trustSummaries); const creatorTesting = buildCreatorTestingOverview(skills); - const autonomyStatus = buildAutonomyStatus( - db, - attentionQueue, - recentDecisions, - skills.length, + const autonomyStatus = buildAutonomyStatus(db, { + skillsObserved: skills.length, pendingReviews, - ); + attentionRequired: attentionQueue.length, + hasCritical: attentionQueue.some((item) => item.severity === "critical"), + criticalCount: attentionQueue.filter((item) => item.severity === "critical").length, + hasRecentDecision: recentDecisions.length > 0, + }); const enrichment = { watched_skills: loadWatchedSkills(), @@ -142,15 +148,28 @@ export function handleOverview( return Response.json({ overview, skills: paginatedSkills, version, ...enrichment }); } +export function handleTrayStatus(db: Database): Response { + const attention = getTrayAttentionSummary(db); + const autonomyStatus = buildAutonomyStatus(db, { + ...attention, + hasRecentDecision: getRecentDecisions(db, 1).length > 0, + }); + + return Response.json({ autonomy_status: autonomyStatus }); +} + // -- Internal helpers ---------------------------------------------------------- -function buildAutonomyStatus( - db: Database, - attentionQueue: AttentionItem[], - recentDecisions: AutonomousDecision[], - skillsObserved: number, - pendingReviews: number, -): AutonomyStatus { +interface AutonomyStatusInput { + readonly skillsObserved: number; + readonly pendingReviews: number; + readonly attentionRequired: number; + readonly hasCritical: boolean; + readonly criticalCount: number; + readonly hasRecentDecision: boolean; +} + +function buildAutonomyStatus(db: Database, input: AutonomyStatusInput): AutonomyStatus { let lastRun: string | null = null; try { const row = db @@ -161,18 +180,16 @@ function buildAutonomyStatus( // Table may not exist } - const hasCritical = attentionQueue.some((a) => a.severity === "critical"); - // "watching" means recent autonomous activity — last run within 24 hours // or recent decisions within the 7-day freshness window const hasRecentActivity = (lastRun != null && Date.now() - new Date(lastRun).getTime() < 24 * 60 * 60 * 1000) || - recentDecisions.length > 0; + input.hasRecentDecision; let level: AutonomyStatusLevel; - if (hasCritical) { + if (input.hasCritical) { level = "blocked"; - } else if (pendingReviews > 0) { + } else if (input.pendingReviews > 0) { level = "needs_review"; } else if (hasRecentActivity) { level = "watching"; @@ -186,15 +203,14 @@ function buildAutonomyStatus( summary = "No action needed. System is healthy."; break; case "blocked": { - const critCount = attentionQueue.filter((a) => a.severity === "critical").length; - summary = `${critCount} skill${critCount !== 1 ? "s" : ""} need${critCount === 1 ? "s" : ""} urgent attention after rollback.`; + summary = `${input.criticalCount} skill${input.criticalCount !== 1 ? "s" : ""} need${input.criticalCount === 1 ? "s" : ""} urgent attention after rollback.`; break; } case "needs_review": - summary = `selftune is watching ${skillsObserved} skill${skillsObserved !== 1 ? "s" : ""} and needs review on ${pendingReviews} proposal${pendingReviews !== 1 ? "s" : ""}.`; + summary = `selftune is watching ${input.skillsObserved} skill${input.skillsObserved !== 1 ? "s" : ""} and needs review on ${input.pendingReviews} proposal${input.pendingReviews !== 1 ? "s" : ""}.`; break; case "watching": - summary = `selftune is actively watching ${skillsObserved} skill${skillsObserved !== 1 ? "s" : ""}. No action needed.`; + summary = `selftune is actively watching ${input.skillsObserved} skill${input.skillsObserved !== 1 ? "s" : ""}. No action needed.`; break; } @@ -202,8 +218,8 @@ function buildAutonomyStatus( level, summary, last_run: lastRun, - skills_observed: skillsObserved, - pending_reviews: pendingReviews, - attention_required: attentionQueue.length, + skills_observed: input.skillsObserved, + pending_reviews: input.pendingReviews, + attention_required: input.attentionRequired, }; } diff --git a/apps/local/src/routes/trace-candidates.ts b/apps/local/src/routes/trace-candidates.ts index feb94caf..324fe44f 100644 --- a/apps/local/src/routes/trace-candidates.ts +++ b/apps/local/src/routes/trace-candidates.ts @@ -12,6 +12,7 @@ export interface TraceCandidateRoutes { export interface TraceCandidateRouteOptions { readonly prepare: (input: unknown) => Promise; + readonly evaluate?: (input: unknown) => Promise; } async function readBoundedJson(request: Request): Promise { @@ -42,14 +43,33 @@ export function createTraceCandidateRoutes( ): TraceCandidateRoutes { return { handle: async (request, url, allowedOrigins) => { - if (url.pathname !== "/api/v2/trace-candidates/prepare" || request.method !== "POST") { + const action = + url.pathname === "/api/v2/trace-candidates/prepare" + ? "prepare" + : url.pathname === "/api/v2/trace-candidates/evaluate" + ? "evaluate" + : null; + if (action === null || request.method !== "POST") { return null; } 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); - return Response.json(await options.prepare(input), { + const result = + action === "prepare" ? await options.prepare(input) : await options.evaluate!(input); + return Response.json(result, { headers: dashboardCorsHeaders(), }); } catch (error) { diff --git a/apps/local/src/routes/workspace-settings.ts b/apps/local/src/routes/workspace-settings.ts index 1e8a6f62..7ad21956 100644 --- a/apps/local/src/routes/workspace-settings.ts +++ b/apps/local/src/routes/workspace-settings.ts @@ -72,6 +72,9 @@ export const routeWorkspaceSettings = Effect.fn("WorkspaceSettings.route")(funct 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")); } diff --git a/apps/local/src/service/windows/installation/controller.ts b/apps/local/src/service/windows/installation/controller.ts index 4110c462..da6f5c9e 100644 --- a/apps/local/src/service/windows/installation/controller.ts +++ b/apps/local/src/service/windows/installation/controller.ts @@ -26,9 +26,7 @@ import { matchLegacyWindowsServiceTaskDefinition, matchWindowsServiceTaskDefinition, type WindowsServiceTaskDefinitionExpectation, - type WindowsServiceTaskDefinitionMatch, } from "./evidence.js"; -import { matchWindowsServiceTaskDefinitionWithAccountProof } from "./task-definition-proof.js"; import { expectAbsentWindowsServiceInstallationReceipt, expectWindowsServiceInstallationReceipt, @@ -424,8 +422,8 @@ export function makeWindowsServiceInstallationController( }, ); - const registeredDefinitionMatch = Effect.fn( - "SelfTuneService.windowsInstallation.taskDefinitionMatch", + const registeredDefinitionMatches = Effect.fn( + "SelfTuneService.windowsInstallation.taskDefinitionMatches", )(function* ( scheduler: WindowsTaskScheduler, expectation: WindowsServiceTaskDefinitionExpectation, @@ -433,20 +431,11 @@ export function makeWindowsServiceInstallationController( matchDefinition: ( xml: string, expectation: WindowsServiceTaskDefinitionExpectation, - ) => WindowsServiceTaskDefinitionMatch = matchWindowsServiceTaskDefinition, + ) => { readonly matches: boolean } = matchWindowsServiceTaskDefinition, ) { - if (!task.registered) return null; + if (!task.registered) return false; const definition = yield* mapFailure("read-task-definition", scheduler.readDefinition()); - if (definition === null) return null; - return yield* mapFailure( - "resolve-task-logon-user-sid", - matchWindowsServiceTaskDefinitionWithAccountProof( - definition, - expectation, - matchDefinition, - dependencies.store.resolveWindowsAccountSid, - ), - ); + return definition !== null && matchDefinition(definition, expectation).matches; }); const inspect = Effect.fn("SelfTuneService.windowsInstallation.inspect")(function* ( @@ -529,20 +518,14 @@ export function makeWindowsServiceInstallationController( if (receiptArtifactState !== "matching") { return refused(currentUserSid, task, "registered-task-artifact-missing"); } - const definitionMatch = yield* registeredDefinitionMatch( + const definitionMatches = yield* registeredDefinitionMatches( scheduler, taskExpectation(receipt.artifacts, receipt.boot, currentUserSid, plan.wscriptPath), task, ); - return definitionMatch?.matches + return definitionMatches ? owned(currentUserSid, task, receipt) - : refused( - currentUserSid, - task, - definitionMatch === null - ? "registered-task-definition-missing" - : `registered-task-definition-${definitionMatch.reason}`, - ); + : refused(currentUserSid, task, "registered-task-definition-mismatch"); } const legacyTaskName = plan.legacy?.taskName ?? plan.legacyTaskName; @@ -561,7 +544,7 @@ export function makeWindowsServiceInstallationController( if (matchingArtifacts === null) { return refused(currentUserSid, task, "legacy-artifact-digest-mismatch"); } - const definitionMatch = yield* registeredDefinitionMatch( + const definitionMatches = yield* registeredDefinitionMatches( scheduler, taskExpectation( plan.legacy.artifacts, @@ -572,7 +555,7 @@ export function makeWindowsServiceInstallationController( task, matchLegacyWindowsServiceTaskDefinition, ); - return definitionMatch?.matches + return definitionMatches ? legacyCompatible(currentUserSid, task, matchingArtifacts, plan.legacy.runtimeIdentity) : refused(currentUserSid, task, "legacy-task-definition-mismatch"); }); diff --git a/apps/local/src/service/windows/installation/evidence.ts b/apps/local/src/service/windows/installation/evidence.ts index 6525b9f2..fc43a26e 100644 --- a/apps/local/src/service/windows/installation/evidence.ts +++ b/apps/local/src/service/windows/installation/evidence.ts @@ -12,7 +12,6 @@ const WINDOWS_TASK_NAMESPACE = "http://schemas.microsoft.com/windows/2004/02/mit export interface WindowsServiceTaskDefinitionExpectation { readonly boot: boolean; readonly launcherPath: string; - readonly provenLogonTriggerUserId?: string; readonly userSid: string; readonly wscriptPath: string; } @@ -156,10 +155,6 @@ function sameSid(left: string, right: string): boolean { return left.trim().toLocaleLowerCase("en-US") === right.trim().toLocaleLowerCase("en-US"); } -function sameWindowsAccountName(left: string, right: string): boolean { - return left.trim().toLocaleLowerCase("en-US") === right.trim().toLocaleLowerCase("en-US"); -} - function quotedWindowsPath(value: string): string | null { const match = /^"([^"\r\n]+)"$/.exec(value); if (!match) return null; @@ -213,39 +208,6 @@ export function inspectWindowsServiceTaskPrincipalScope( : { _tag: "DifferentUser" }; } -export function inspectWindowsServiceTaskLogonTriggerUserId(xml: string): string | null { - const parseErrors: string[] = []; - const document = new DOMParser({ - errorHandler: { - error: (message) => parseErrors.push(String(message)), - fatalError: (message) => parseErrors.push(String(message)), - warning: (message) => parseErrors.push(String(message)), - }, - }).parseFromString(xml, "application/xml"); - if ( - parseErrors.length > 0 || - !document.documentElement || - nodeLocalName(document.documentElement) !== "Task" || - document.documentElement.namespaceURI !== WINDOWS_TASK_NAMESPACE - ) { - return null; - } - const triggers = descendantElements(document, "Triggers"); - if (triggers.length !== 1) return null; - const triggerNodes = directElementChildren(triggers[0]); - if ( - triggerNodes.length !== 1 || - triggerNodes[0].namespaceURI !== WINDOWS_TASK_NAMESPACE || - nodeLocalName(triggerNodes[0]) !== "LogonTrigger" - ) { - return null; - } - const userIds = directChildrenNamed(triggerNodes[0], "UserId"); - if (userIds.length !== 1) return null; - const userId = nodeText(userIds[0]).trim(); - return userId.length > 0 ? userId : null; -} - function matchRequiredWindowsServiceTaskSettings( settings: Element, ): WindowsServiceTaskDefinitionMatch { @@ -264,7 +226,7 @@ function matchRequiredWindowsServiceTaskSettings( if (!hasSingleTextChild(settings, "ExecutionTimeLimit", "PT0S")) { return mismatch("execution-time-limit-mismatch"); } - if (!hasOptionalTextChild(settings, "Enabled", "true")) { + if (!hasSingleTextChild(settings, "Enabled", "true")) { return mismatch("task-enabled-mismatch"); } const restartNodes = directChildrenNamed(settings, "RestartOnFailure"); @@ -286,10 +248,10 @@ function matchModernWindowsServiceTaskSettings( ): WindowsServiceTaskDefinitionMatch { const required = matchRequiredWindowsServiceTaskSettings(settings); if (!required.matches) return required; - if (!hasOptionalTextChild(settings, "AllowHardTerminate", "true")) { + if (!hasSingleTextChild(settings, "AllowHardTerminate", "true")) { return mismatch("allow-hard-terminate-mismatch"); } - if (!hasOptionalTextChild(settings, "RunOnlyIfNetworkAvailable", "false")) { + if (!hasSingleTextChild(settings, "RunOnlyIfNetworkAvailable", "false")) { return mismatch("run-only-if-network-available-mismatch"); } const idleSettingsNodes = directChildrenNamed(settings, "IdleSettings"); @@ -304,17 +266,17 @@ function matchModernWindowsServiceTaskSettings( if (!hasExactChildren(idleSettings, ["StopOnIdleEnd", "RestartOnIdle"])) { return mismatch("idle-settings-shape-mismatch"); } - if (!hasOptionalTextChild(settings, "AllowStartOnDemand", "true")) { + if (!hasSingleTextChild(settings, "AllowStartOnDemand", "true")) { return mismatch("allow-start-on-demand-mismatch"); } - if (!hasOptionalTextChild(settings, "Hidden", "false")) return mismatch("hidden-mismatch"); - if (!hasOptionalTextChild(settings, "RunOnlyIfIdle", "false")) { + if (!hasSingleTextChild(settings, "Hidden", "false")) return mismatch("hidden-mismatch"); + if (!hasSingleTextChild(settings, "RunOnlyIfIdle", "false")) { return mismatch("run-only-if-idle-mismatch"); } - if (!hasOptionalTextChild(settings, "WakeToRun", "false")) { + if (!hasSingleTextChild(settings, "WakeToRun", "false")) { return mismatch("wake-to-run-mismatch"); } - if (!hasOptionalTextChild(settings, "Priority", "7")) return mismatch("priority-mismatch"); + if (!hasSingleTextChild(settings, "Priority", "7")) return mismatch("priority-mismatch"); if (!hasOptionalTextChild(settings, "DeleteExpiredTaskAfter", "PT0S")) { return mismatch("delete-expired-task-after-mismatch"); } @@ -330,24 +292,20 @@ function matchModernWindowsServiceTaskSettings( "MultipleInstancesPolicy", "DisallowStartIfOnBatteries", "StopIfGoingOnBatteries", - "StartWhenAvailable", - "IdleSettings", - "RestartOnFailure", - "ExecutionTimeLimit", - ], - [ "AllowHardTerminate", + "StartWhenAvailable", "RunOnlyIfNetworkAvailable", + "IdleSettings", "AllowStartOnDemand", "Enabled", "Hidden", "RunOnlyIfIdle", "WakeToRun", + "RestartOnFailure", + "ExecutionTimeLimit", "Priority", - "DeleteExpiredTaskAfter", - "UseUnifiedSchedulingEngine", - "DisallowStartOnRemoteAppSession", ], + ["DeleteExpiredTaskAfter", "UseUnifiedSchedulingEngine", "DisallowStartOnRemoteAppSession"], ) ? authorityMatch() : mismatch("settings-shape-mismatch"); @@ -398,13 +356,13 @@ function matchLegacyWindowsServiceTaskSettings( "StartWhenAvailable", "RestartOnFailure", "ExecutionTimeLimit", + "Enabled", ], [ "AllowHardTerminate", "RunOnlyIfNetworkAvailable", "IdleSettings", "AllowStartOnDemand", - "Enabled", "Hidden", "RunOnlyIfIdle", "WakeToRun", @@ -487,7 +445,7 @@ function matchWindowsServiceTaskDefinitionWithSettings( if (principalNodes.length !== 1) return mismatch("principal-count-mismatch"); const principal = principalNodes[0]; if (principal.getAttribute("id") !== "Author") return mismatch("principal-id-mismatch"); - if (!hasExpectedChildren(principal, ["UserId", "LogonType"], ["DisplayName", "RunLevel"])) { + if (!hasExpectedChildren(principal, ["UserId", "LogonType", "RunLevel"], ["DisplayName"])) { return mismatch("principal-shape-mismatch"); } const principalUserIds = directChildrenNamed(principal, "UserId"); @@ -502,10 +460,7 @@ function matchWindowsServiceTaskDefinitionWithSettings( return mismatch("principal-logon-type-mismatch"); } const expectedRunLevel = expectation.boot ? "HighestAvailable" : "LeastPrivilege"; - const runLevelMatches = expectation.boot - ? hasSingleTextChild(principal, "RunLevel", expectedRunLevel) - : hasOptionalTextChild(principal, "RunLevel", expectedRunLevel); - if (!runLevelMatches) { + if (!hasSingleTextChild(principal, "RunLevel", expectedRunLevel)) { return mismatch("principal-run-level-mismatch"); } const triggers = descendantElements(document, "Triggers"); @@ -520,22 +475,17 @@ function matchWindowsServiceTaskDefinitionWithSettings( return mismatch("trigger-kind-mismatch"); } const trigger = triggerNodes[0]; - if (!hasOptionalTextChild(trigger, "Enabled", "true")) { + const expectedTriggerShape = expectation.boot ? ["Enabled"] : ["Enabled", "UserId"]; + if (!hasSingleTextChild(trigger, "Enabled", "true")) { return mismatch("trigger-enabled-mismatch"); } if (!expectation.boot) { const triggerUserIds = directChildrenNamed(trigger, "UserId"); - const triggerUserId = triggerUserIds.length === 1 ? nodeText(triggerUserIds[0]) : null; - if ( - triggerUserId === null || - (!sameSid(triggerUserId, expectation.userSid) && - (expectation.provenLogonTriggerUserId === undefined || - !sameWindowsAccountName(triggerUserId, expectation.provenLogonTriggerUserId))) - ) { + if (triggerUserIds.length !== 1 || !sameSid(nodeText(triggerUserIds[0]), expectation.userSid)) { return mismatch("logon-trigger-sid-mismatch"); } } - if (!hasExpectedChildren(trigger, expectation.boot ? [] : ["UserId"], ["Enabled"])) { + if (!hasExactChildren(trigger, expectedTriggerShape)) { return mismatch("trigger-shape-mismatch"); } 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 a06fe981..5989937d 100644 --- a/apps/local/src/service/windows/installation/legacy-cleanup-controller.ts +++ b/apps/local/src/service/windows/installation/legacy-cleanup-controller.ts @@ -3,7 +3,6 @@ import * as Schema from "effect/Schema"; import type { WindowsServiceInstallationArtifactStore } from "../artifact-store.js"; import { matchLegacyWindowsServiceTaskDefinition } from "./evidence.js"; -import { matchWindowsServiceTaskDefinitionWithAccountProof } from "./task-definition-proof.js"; import { canonicalWindowsPathIdentity, sha256Hex, @@ -101,22 +100,15 @@ export function makeWindowsServiceLegacyCleanupController( "read-legacy-cleanup-task-definition", scheduler.readDefinition(), ); - if (definition === null) return false; - const match = yield* mapFailure( - "resolve-legacy-cleanup-task-logon-user-sid", - matchWindowsServiceTaskDefinitionWithAccountProof( - definition, - { - boot: journal.boot, - launcherPath: journal.artifacts.launcher.path, - userSid: journal.userSid, - wscriptPath: journal.wscriptPath, - }, - matchLegacyWindowsServiceTaskDefinition, - dependencies.store.resolveWindowsAccountSid, - ), + return ( + definition !== null && + matchLegacyWindowsServiceTaskDefinition(definition, { + boot: journal.boot, + launcherPath: journal.artifacts.launcher.path, + userSid: journal.userSid, + wscriptPath: journal.wscriptPath, + }).matches ); - return match.matches; }); const removeArtifact = Effect.fn("SelfTuneService.windowsLegacyCleanup.removeArtifact")( function* ( diff --git a/apps/local/src/service/windows/installation/store.ts b/apps/local/src/service/windows/installation/store.ts index 002b2a86..a8d6e4be 100644 --- a/apps/local/src/service/windows/installation/store.ts +++ b/apps/local/src/service/windows/installation/store.ts @@ -44,7 +44,6 @@ const RECEIPT_JSON_SCHEMA = Schema.fromJsonString(WindowsServiceInstallationRece const LEGACY_CLEANUP_JSON_SCHEMA = Schema.fromJsonString(WindowsServiceLegacyCleanupJournalSchema); const WINDOWS_SID_PATTERN = /^S-\d(?:-\d+)+$/i; const LOCAL_APP_DATA_MARKER = "SELFTUNE_LOCAL_APP_DATA_V1:"; -const RESOLVED_ACCOUNT_SID_MARKER = "SELFTUNE_RESOLVED_ACCOUNT_SID_V1:"; export interface WindowsInstallationCommandResult { readonly code: number; @@ -170,9 +169,6 @@ export interface WindowsServiceInstallationStore { cleanup: Effect.Effect, ) => Effect.Effect; readonly resolveCurrentUserSid: () => Effect.Effect; - readonly resolveWindowsAccountSid?: ( - accountName: string, - ) => Effect.Effect; readonly writeReceipt: ( receipt: WindowsServiceInstallationReceipt, expectedPrior: WindowsServiceInstallationReceiptExpectation, @@ -266,16 +262,6 @@ export function parseWhoamiUserCsv(output: string): string | null { return WINDOWS_SID_PATTERN.test(sid) ? sid.toUpperCase() : null; } -export function parseResolvedWindowsAccountSid(output: string): string | null { - const lines = output - .split(/\r?\n/) - .map((line) => line.replace(/^\ufeff/, "").trim()) - .filter((line) => line.length > 0); - if (lines.length !== 1 || !lines[0].startsWith(RESOLVED_ACCOUNT_SID_MARKER)) return null; - const sid = lines[0].slice(RESOLVED_ACCOUNT_SID_MARKER.length); - return WINDOWS_SID_PATTERN.test(sid) ? sid.toUpperCase() : null; -} - export function parseLocalAppDataOutput(output: string): string | null { const lines = output .split(/\r?\n/) @@ -379,54 +365,6 @@ export function makeWindowsServiceInstallationStore( return sid; }, ); - const resolveWindowsAccountSid = Effect.fn( - "SelfTuneService.windowsInstallation.resolveWindowsAccountSid", - )(function* (accountName: string) { - const normalized = accountName.trim(); - if ( - normalized.length === 0 || - normalized.length > 1024 || - normalized.includes("\0") || - normalized.includes("\r") || - normalized.includes("\n") - ) { - return null; - } - if (WINDOWS_SID_PATTERN.test(normalized)) return normalized.toUpperCase(); - // Only this generated Base64 alphabet is interpolated; the task-supplied identifier is never PowerShell source. - const encodedAccount = Buffer.from(normalized, "utf8").toString("base64"); - const script = [ - "$ErrorActionPreference = 'Stop'", - "Set-StrictMode -Version Latest", - `$account = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedAccount}'))`, - "$identity = [System.Security.Principal.NTAccount]::new($account)", - "$sid = $identity.Translate([System.Security.Principal.SecurityIdentifier]).Value", - `Write-Output ('${RESOLVED_ACCOUNT_SID_MARKER}' + $sid)`, - ].join("\n"); - const result = yield* mapFailure( - "resolve-windows-account-sid", - dependencies.process.execute(powershell, [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - ]), - ); - if (result.code !== 0) return null; - const sid = parseResolvedWindowsAccountSid(result.stdout); - if (sid === null) { - return yield* Effect.fail( - failure( - "resolve-windows-account-sid", - "Windows returned an invalid structured account SID record.", - ), - ); - } - return sid; - }); const resolveLocalAppData = Effect.fn("SelfTuneService.windowsInstallation.resolveLocalAppData")( function* () { @@ -515,8 +453,6 @@ export function makeWindowsServiceInstallationStore( const script = [ "$ErrorActionPreference = 'Stop'", "Set-StrictMode -Version Latest", - "$securityModule = Join-Path $PSHOME 'Modules\\Microsoft.PowerShell.Security\\Microsoft.PowerShell.Security.psd1'", - "Import-Module -Name $securityModule -Force -ErrorAction Stop", `$path = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedPath}'))`, `$userSid = [System.Security.Principal.SecurityIdentifier]::new('${userSid}')`, `$systemSid = [System.Security.Principal.SecurityIdentifier]::new('${WINDOWS_SYSTEM_SID}')`, @@ -893,7 +829,6 @@ export function makeWindowsServiceInstallationStore( removeLegacyCleanup, removeReceiptAfterCleanup, resolveCurrentUserSid, - resolveWindowsAccountSid, requireLegacyCleanup, writeReceipt, }; diff --git a/apps/local/src/service/windows/installation/task-definition-proof.ts b/apps/local/src/service/windows/installation/task-definition-proof.ts deleted file mode 100644 index 4f593a9e..00000000 --- a/apps/local/src/service/windows/installation/task-definition-proof.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as Effect from "effect/Effect"; - -import { - inspectWindowsServiceTaskLogonTriggerUserId, - type WindowsServiceTaskDefinitionExpectation, - type WindowsServiceTaskDefinitionMatch, -} from "./evidence.js"; - -export type WindowsServiceTaskDefinitionMatcher = ( - xml: string, - expectation: WindowsServiceTaskDefinitionExpectation, -) => WindowsServiceTaskDefinitionMatch; - -export function matchWindowsServiceTaskDefinitionWithAccountProof( - definition: string, - expectation: WindowsServiceTaskDefinitionExpectation, - matchDefinition: WindowsServiceTaskDefinitionMatcher, - resolveWindowsAccountSid: - | ((accountName: string) => Effect.Effect) - | undefined, -): Effect.Effect { - const initialMatch = matchDefinition(definition, expectation); - if ( - initialMatch.matches || - initialMatch.reason !== "logon-trigger-sid-mismatch" || - resolveWindowsAccountSid === undefined - ) { - return Effect.succeed(initialMatch); - } - const observedUserId = inspectWindowsServiceTaskLogonTriggerUserId(definition); - if (observedUserId === null) return Effect.succeed(initialMatch); - return resolveWindowsAccountSid(observedUserId).pipe( - Effect.map((resolvedSid) => { - if ( - resolvedSid === null || - resolvedSid.toLocaleLowerCase("en-US") !== expectation.userSid.toLocaleLowerCase("en-US") - ) { - return initialMatch; - } - return matchDefinition(definition, { - ...expectation, - provenLogonTriggerUserId: observedUserId, - }); - }), - ); -} diff --git a/apps/local/src/service/windows/lock-compatibility.ts b/apps/local/src/service/windows/lock-compatibility.ts index dea44f1a..9d5e3796 100644 --- a/apps/local/src/service/windows/lock-compatibility.ts +++ b/apps/local/src/service/windows/lock-compatibility.ts @@ -623,7 +623,7 @@ export function makeLiveWindowsServiceLockCompatibility(): WindowsServiceLockCom let acquired = false; try { database.run("PRAGMA busy_timeout = 0"); - database.run("BEGIN IMMEDIATE"); + database.run("BEGIN EXCLUSIVE"); acquired = true; return database; } finally { diff --git a/apps/local/src/service/windows/mutation-lock.ts b/apps/local/src/service/windows/mutation-lock.ts index ffe35e40..b7e74ed6 100644 --- a/apps/local/src/service/windows/mutation-lock.ts +++ b/apps/local/src/service/windows/mutation-lock.ts @@ -124,7 +124,7 @@ export function makeWindowsUserServiceMutationLock( let acquired = false; try { database.run("PRAGMA busy_timeout = 0"); - database.run("BEGIN IMMEDIATE"); + database.run("BEGIN EXCLUSIVE"); const lease: WindowsUserServiceMutationLockLease = { ...scope, path, diff --git a/apps/local/src/skill-set-pack-import.ts b/apps/local/src/skill-set-pack-import.ts new file mode 100644 index 00000000..e3c8ab59 --- /dev/null +++ b/apps/local/src/skill-set-pack-import.ts @@ -0,0 +1,123 @@ +import { createHash } from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + MAXIMUM_PORTABLE_SKILL_SET_ENVELOPE_BYTES, + parseSkillSetPackUrl, + SkillSetPackPreview, +} from "@selftune/control-plane"; +import { importPortableSkillSetPack, type SkillSetManifest } from "@selftune/library"; +import { remoteLibrarySettings } from "@selftune/runtime/remote-library/config"; + +const CLOUD_PACK_ORIGIN = "https://cloud.selftune.dev"; +const MAXIMUM_PACK_PREVIEW_BYTES = 256 * 1024; + +function allowedPackOrigins(configRoot: string): ReadonlySet { + const configured = remoteLibrarySettings(configRoot).url; + const origins = new Set([CLOUD_PACK_ORIGIN]); + if (configured) origins.add(new URL(configured).origin); + return origins; +} + +async function resolvedPackUrl(value: string, configRoot: string) { + const parsed = await Effect.runPromise(parseSkillSetPackUrl(value.trim())); + if (!allowedPackOrigins(configRoot).has(parsed.url.origin)) { + throw new Error( + "This Pack origin is not trusted. Connect that self-host server in Settings first.", + ); + } + return parsed; +} + +async function fetchBounded(url: URL, accept: string): Promise { + const response = await fetch(url, { + headers: { accept }, + redirect: "error", + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error(`Pack request failed with HTTP ${response.status}.`); + return response; +} + +async function readBoundedBody(response: Response, maximumBytes: number): Promise { + const declaredLength = Number(response.headers.get("content-length") ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) { + throw new Error("The Pack response exceeds SelfTune's size limit."); + } + if (!response.body) throw new Error("The Pack response body is missing."); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + total += chunk.value.byteLength; + if (total > maximumBytes) { + await reader.cancel(); + throw new Error("The Pack response exceeds SelfTune's size limit."); + } + chunks.push(chunk.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function previewSkillSetPack( + packUrl: string, + configRoot: string, +): Promise<{ readonly packUrl: string; readonly preview: SkillSetPackPreview }> { + const parsed = await resolvedPackUrl(packUrl, configRoot); + const response = await fetchBounded(parsed.previewUrl, "application/json"); + const previewBytes = await readBoundedBody(response, MAXIMUM_PACK_PREVIEW_BYTES); + const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(previewBytes)); + const preview = await Effect.runPromise( + Schema.decodeUnknownEffect(SkillSetPackPreview)(value, { + errors: "all", + onExcessProperty: "error", + }), + ); + return { packUrl: parsed.url.href, preview }; +} + +export async function importSkillSetPack(input: { + readonly packUrl: string; + readonly expectedObjectSha256: string; + readonly configRoot: string; +}): Promise<{ + readonly manifest: SkillSetManifest; + readonly sourceRevisionSha256: string; + readonly objectSha256: string; +}> { + const { packUrl, preview } = await previewSkillSetPack(input.packUrl, input.configRoot); + if (preview.objectSha256 !== input.expectedObjectSha256) { + throw new Error("The Pack changed after preview. Review it again before importing."); + } + const parsed = await resolvedPackUrl(packUrl, input.configRoot); + const response = await fetchBounded( + parsed.contentUrl, + "application/vnd.selftune.portable-skill-set+json;version=1", + ); + const bytes = await readBoundedBody(response, MAXIMUM_PORTABLE_SKILL_SET_ENVELOPE_BYTES); + if (bytes.byteLength === 0 || bytes.byteLength > MAXIMUM_PORTABLE_SKILL_SET_ENVELOPE_BYTES) { + throw new Error("The Pack content size is invalid."); + } + const objectSha256 = createHash("sha256").update(bytes).digest("hex"); + const responseSha256 = response.headers.get("x-selftune-content-sha256"); + if (objectSha256 !== preview.objectSha256 || responseSha256 !== preview.objectSha256) { + throw new Error("The downloaded Pack does not match the reviewed immutable object."); + } + const imported = importPortableSkillSetPack(bytes, { configRoot: input.configRoot }); + if ( + imported.sourceRevisionSha256 !== preview.skillSetRevisionSha256 || + imported.objectSha256 !== preview.objectSha256 + ) { + throw new Error("The imported Pack identity does not match its preview."); + } + return imported; +} diff --git a/apps/local/src/skill-set-plugin-install.ts b/apps/local/src/skill-set-plugin-install.ts new file mode 100644 index 00000000..b678e378 --- /dev/null +++ b/apps/local/src/skill-set-plugin-install.ts @@ -0,0 +1,467 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; + +import { projectSkillSetPlugin, type SkillSetServiceOptions } from "@selftune/library"; +import { SELFTUNE_CONFIG_DIR } from "@selftune/runtime/constants"; +import { CLIError } from "@selftune/runtime/utils/cli-error"; + +export type NativePluginHost = "claude" | "codex"; + +export interface NativePluginHostPreview { + readonly host: NativePluginHost; + readonly label: string; + readonly available: boolean; + readonly installedVersion: string | null; + readonly status: "unavailable" | "ready" | "already_current" | "update_available"; + readonly activation: string; +} + +export interface SkillSetPluginInstallPreview { + readonly setId: string; + readonly setName: string; + readonly revisionHash: string; + readonly pluginName: string; + readonly pluginVersion: string; + readonly marketplaceName: string; + readonly skillNames: ReadonlyArray; + readonly hosts: ReadonlyArray; +} + +export interface SkillSetPluginInstallReceipt { + readonly setId: string; + readonly setName: string; + readonly revisionHash: string; + readonly pluginName: string; + readonly pluginVersion: string; + readonly marketplaceName: string; + readonly installedAt: string; + readonly hosts: ReadonlyArray<{ + readonly host: NativePluginHost; + readonly pluginId: string; + readonly result: "installed" | "updated" | "already_current"; + readonly activation: string; + }>; +} + +export interface PluginInstallCommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface PluginInstallRuntime { + readonly which: (command: string) => string | null; + readonly run: (command: string, args: ReadonlyArray) => PluginInstallCommandResult; + readonly now: () => Date; +} + +const defaultRuntime: PluginInstallRuntime = { + which: (command) => Bun.which(command), + run: (command, args) => { + const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe" }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + }; + }, + 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 : []; +} + +function hostLabel(host: NativePluginHost): string { + return host === "claude" ? "Claude" : "Codex"; +} + +function hostActivation(host: NativePluginHost): string { + return host === "claude" + ? "Run /reload-plugins or start a new Claude session." + : "Start a new Codex session."; +} + +function pluginVersion(revisionHash: string): string { + return `0.0.0-selftune.${revisionHash.slice(0, 12)}`; +} + +function marketplaceName(setId: string): string { + const identity = createHash("sha256").update(setId).digest("hex").slice(0, 12); + return `selftune-${identity}`; +} + +function commandOutput(result: PluginInstallCommandResult): string { + const output = result.stderr.trim() || result.stdout.trim(); + return output.slice(0, 2_000); +} + +function runJson( + runtime: PluginInstallRuntime, + command: string, + args: ReadonlyArray, +): unknown { + const result = runtime.run(command, args); + return result.exitCode === 0 ? parseJson(result.stdout) : null; +} + +function hostState( + runtime: PluginInstallRuntime, + host: NativePluginHost, + pluginId: string, + desiredVersion: string, +): NativePluginHostPreview { + const executable = runtime.which(host === "claude" ? "claude" : "codex"); + const activation = hostActivation(host); + if (!executable) { + return { + host, + label: hostLabel(host), + available: false, + installedVersion: null, + status: "unavailable", + 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"); + return { + host, + label: hostLabel(host), + available: true, + installedVersion, + status: + installedVersion === desiredVersion + ? "already_current" + : installedVersion + ? "update_available" + : "ready", + activation, + }; +} + +function projection( + setId: string, + options: SkillSetServiceOptions, +): ReturnType { + return projectSkillSetPlugin(setId, "all", options); +} + +export function previewSkillSetPluginInstall( + setId: string, + options: SkillSetServiceOptions = {}, + runtime: PluginInstallRuntime = defaultRuntime, +): SkillSetPluginInstallPreview { + const projected = projection(setId, options); + const version = pluginVersion(projected.revisionHash); + const market = marketplaceName(projected.setId); + const pluginId = `${projected.pluginName}@${market}`; + return { + setId: projected.setId, + setName: projected.setName, + revisionHash: projected.revisionHash, + pluginName: projected.pluginName, + pluginVersion: version, + marketplaceName: market, + skillNames: projected.skillNames, + hosts: [ + hostState(runtime, "claude", pluginId, version), + hostState(runtime, "codex", pluginId, version), + ], + }; +} + +function safeOutputPath(root: string, relativePath: string): string { + const normalized = relativePath.replaceAll("\\", "/"); + if ( + !normalized || + normalized.startsWith("/") || + normalized.includes("\0") || + normalized.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new CLIError(`Plugin contains an unsafe path: ${relativePath}`, "GUARD_BLOCKED"); + } + const output = resolve(root, normalized); + if (!output.startsWith(`${resolve(root)}${sep}`)) { + throw new CLIError(`Plugin path escapes its marketplace: ${relativePath}`, "GUARD_BLOCKED"); + } + return output; +} + +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)) { + throw new CLIError(`Plugin manifest is invalid: ${path}`, "GUARD_BLOCKED"); + } + return new TextEncoder().encode(`${JSON.stringify({ ...decoded, version }, null, 2)}\n`); +} + +function materializeMarketplace(input: { + readonly configRoot: string; + readonly marketplaceName: string; + readonly pluginName: string; + readonly pluginVersion: string; + readonly description: string; + readonly files: ReadonlyArray<{ readonly path: string; readonly content: Uint8Array }>; +}): string { + const parent = resolve(input.configRoot, "plugin-marketplaces"); + const root = resolve(parent, input.marketplaceName); + const staging = resolve(parent, `.install-${input.marketplaceName}-${randomUUID()}`); + const backup = resolve(parent, `.previous-${input.marketplaceName}-${randomUUID()}`); + mkdirSync(staging, { recursive: true, mode: 0o700 }); + try { + const pluginRoot = join(staging, "plugins", input.pluginName); + for (const file of input.files) { + const output = safeOutputPath(pluginRoot, file.path); + mkdirSync(dirname(output), { recursive: true, mode: 0o700 }); + writeFileSync(output, versionedManifest(file.path, file.content, input.pluginVersion), { + mode: 0o600, + }); + } + const catalogPath = join(staging, ".claude-plugin", "marketplace.json"); + mkdirSync(dirname(catalogPath), { recursive: true, mode: 0o700 }); + writeFileSync( + catalogPath, + `${JSON.stringify( + { + name: input.marketplaceName, + version: "1.0.0", + description: "Locally installed SelfTune Skill Sets", + owner: { name: "SelfTune" }, + plugins: [ + { + name: input.pluginName, + version: input.pluginVersion, + description: input.description, + source: `./plugins/${input.pluginName}`, + }, + ], + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + if (existsSync(root)) renameSync(root, backup); + try { + renameSync(staging, root); + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); + } catch (cause) { + if (existsSync(backup) && !existsSync(root)) renameSync(backup, root); + throw cause; + } + return root; + } finally { + if (existsSync(staging)) rmSync(staging, { recursive: true, force: true }); + } +} + +function configuredMarketplaceRoot( + runtime: PluginInstallRuntime, + executable: string, + 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, + ); + if (!entry) return null; + return stringField(entry, host === "claude" ? "path" : "root"); +} + +function runRequired( + runtime: PluginInstallRuntime, + command: string, + args: ReadonlyArray, + action: string, +): void { + const result = runtime.run(command, args); + if (result.exitCode !== 0) { + throw new CLIError( + `${action} failed${commandOutput(result) ? `: ${commandOutput(result)}` : "."}`, + "OPERATION_FAILED", + "Open the host's plugin manager, resolve the reported issue, and retry from SelfTune.", + ); + } +} + +function installIntoHost(input: { + readonly runtime: PluginInstallRuntime; + readonly host: NativePluginHost; + readonly marketplaceRoot: string; + readonly marketplaceName: string; + readonly pluginName: string; + readonly preview: NativePluginHostPreview; +}): SkillSetPluginInstallReceipt["hosts"][number] { + const executable = input.runtime.which(input.host === "claude" ? "claude" : "codex"); + if (!executable) { + throw new CLIError( + `${hostLabel(input.host)} is not installed on this machine.`, + "FILE_NOT_FOUND", + ); + } + 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) { + const args = ["plugin", "marketplace", "add", input.marketplaceRoot]; + if (input.host === "codex") args.push("--json"); + runRequired( + input.runtime, + executable, + args, + `Registering SelfTune with ${hostLabel(input.host)}`, + ); + } + if (input.preview.status === "already_current") { + return { + host: input.host, + pluginId, + result: "already_current", + activation: hostActivation(input.host), + }; + } + if (input.host === "claude") { + runRequired( + input.runtime, + executable, + input.preview.installedVersion + ? ["plugin", "update", pluginId, "--scope", "user"] + : ["plugin", "install", pluginId, "--scope", "user"], + `${input.preview.installedVersion ? "Updating" : "Installing"} ${input.pluginName} in Claude`, + ); + } else { + if (input.preview.installedVersion) { + runRequired( + input.runtime, + executable, + ["plugin", "remove", pluginId, "--json"], + `Preparing the ${input.pluginName} update in Codex`, + ); + } + runRequired( + input.runtime, + executable, + ["plugin", "add", pluginId, "--json"], + `${input.preview.installedVersion ? "Updating" : "Installing"} ${input.pluginName} in Codex`, + ); + } + return { + host: input.host, + pluginId, + result: input.preview.installedVersion ? "updated" : "installed", + activation: hostActivation(input.host), + }; +} + +export function installSkillSetPlugin( + input: { + readonly setId: string; + readonly expectedRevisionHash: string; + readonly hosts: ReadonlyArray; + }, + options: SkillSetServiceOptions = {}, + runtime: PluginInstallRuntime = defaultRuntime, +): SkillSetPluginInstallReceipt { + const hosts = [...new Set(input.hosts)]; + if (hosts.length === 0) throw new CLIError("Choose at least one plugin host.", "INVALID_FLAG"); + const preview = previewSkillSetPluginInstall(input.setId, options, runtime); + if (preview.revisionHash !== input.expectedRevisionHash) { + throw new CLIError( + "The Skill Set changed after the install review.", + "GUARD_BLOCKED", + "Review the current revision and confirm the plugin installation again.", + ); + } + for (const host of hosts) { + const state = preview.hosts.find((candidate) => candidate.host === host); + if (!state?.available) { + throw new CLIError(`${hostLabel(host)} is not installed on this machine.`, "FILE_NOT_FOUND"); + } + } + const projected = projection(input.setId, options); + const configRoot = resolve(options.configRoot ?? SELFTUNE_CONFIG_DIR); + const marketRoot = materializeMarketplace({ + configRoot, + marketplaceName: preview.marketplaceName, + pluginName: preview.pluginName, + pluginVersion: preview.pluginVersion, + 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"); + } + return installIntoHost({ + runtime, + host, + marketplaceRoot: marketRoot, + marketplaceName: preview.marketplaceName, + pluginName: preview.pluginName, + preview: hostPreview, + }); + }); + const receipt: SkillSetPluginInstallReceipt = { + setId: preview.setId, + setName: preview.setName, + revisionHash: preview.revisionHash, + pluginName: preview.pluginName, + pluginVersion: preview.pluginVersion, + marketplaceName: preview.marketplaceName, + installedAt: runtime.now().toISOString(), + hosts: installedHosts, + }; + const receiptPath = resolve(configRoot, "plugin-installs", `${preview.marketplaceName}.json`); + mkdirSync(dirname(receiptPath), { recursive: true, mode: 0o700 }); + writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + return receipt; +} diff --git a/apps/local/src/trace-candidate-contract.ts b/apps/local/src/trace-candidate-contract.ts new file mode 100644 index 00000000..c76bd3bd --- /dev/null +++ b/apps/local/src/trace-candidate-contract.ts @@ -0,0 +1,62 @@ +import { Context, Effect, Schema } from "effect"; + +export interface TraceCandidateReview { + readonly draft_id: string | null; + readonly pattern_id: string; + readonly cohort_fingerprint: string | null; + readonly target_revision: string | null; + readonly readiness: "review_ready" | "not_ready"; + readonly failure_reason: string | null; + readonly evidence: { + readonly cohort_entries: number; + readonly resolved_entries: number; + }; + readonly candidate: { + readonly body: string; + readonly rationale: string; + readonly diff: { + readonly changed_lines: number; + readonly target_section: string; + }; + readonly uncertainty: readonly string[]; + } | null; + readonly search?: { + readonly requested_candidates: number; + readonly generated_candidates: number; + readonly calibrated_candidates: number; + readonly required_calibration_repetitions: number; + readonly current_calibration_passed_repetitions: number; + readonly frontier_candidate_ids: readonly string[]; + readonly selected_candidate_id: string | null; + readonly selection_method: "pareto_calibration_frontier"; + readonly candidate_summaries: readonly { + readonly proposal_id: string; + readonly calibration_passed: boolean; + readonly scored_repetitions: number; + readonly passed_repetitions: number; + readonly calibration_score: number; + readonly changed_lines: number; + readonly input_tokens: number | null; + readonly output_tokens: number | null; + readonly wall_time_ms: number | null; + readonly frontier_member: boolean; + readonly selected: boolean; + }[]; + } | null; +} + +export class TraceCandidatePreparationError extends Schema.TaggedErrorClass()( + "TraceCandidatePreparationError", + { message: Schema.String }, +) {} + +export interface TraceCandidatePreparationService { + readonly prepare: ( + input: unknown, + ) => Effect.Effect; +} + +export class TraceCandidatePreparation extends Context.Service< + TraceCandidatePreparation, + TraceCandidatePreparationService +>()("@selftune/local/TraceCandidatePreparation") {} diff --git a/apps/local/src/trace-candidate-service.ts b/apps/local/src/trace-candidate-service.ts index d6ee3147..5d6ad254 100644 --- a/apps/local/src/trace-candidate-service.ts +++ b/apps/local/src/trace-candidate-service.ts @@ -1,157 +1,129 @@ /** Local-only preparation of a review candidate from a supported trace pattern. */ -import { Context, Effect, Layer, Schema } from "effect"; +import { Effect, Layer, Schema } from "effect"; import type { Database } from "bun:sqlite"; import { createHash } from "node:crypto"; import { createOrGetPreparedEvaluationSubmissionDraft } from "@selftune/local-store"; -import { - DuckDbAnalyticalStore, - EvidenceCohortEntry, - materializeEvidenceCohort, -} from "@selftune/observability"; +import { DuckDbAnalyticalStore, materializeEvidenceCohort } from "@selftune/observability"; import { ResolvedEvidenceReference, evolveBodyFromEvidenceCohort, type CohortBodyEvolutionDeps, type CohortBodyTeacher, } from "@selftune/runtime/evolution/evidence-cohort-body-adapter"; -import { callViaSubagent } from "@selftune/runtime/utils/llm-call"; +import { callViaSubagent, type LlmBackedAgent } from "@selftune/runtime/utils/llm-call"; import { computeSkillVersionHash, findInstalledSkillPackages, getDefaultSkillSearchDirs, } from "@selftune/runtime/utils/skill-discovery"; +import { + boundedHistoricalTask, + latestPackageMtimeMs, + pathCanUseInstalledSnapshot, + redactedPortableText, +} from "./historical-evidence-safety.js"; +import { + prepareHistoricalTaskCandidate, + type HistoricalTaskCalibrator, +} from "./historical-task-candidate.js"; +import { + TraceCandidatePreparation, + TraceCandidatePreparationError, + type TraceCandidateReview, +} from "./trace-candidate-contract.js"; -/** - * The durable hand-off is intentionally smaller than an EvidenceCohort: it - * never persists a local path or source transcript. It is decoded again at - * the submission boundary before it can become a portable Cloud request. - */ -const preparedDraftSchema = Schema.Struct({ - schema_version: Schema.Literal(1), - cohort: Schema.Struct({ - schema_version: Schema.Literal("1.0.0"), - selector_version: Schema.String.check(Schema.isMaxLength(256)), - pattern: Schema.Struct({ - pattern_id: Schema.String.check(Schema.isMaxLength(256)), - kind: Schema.Literal("repeated_correlated_errors"), - skill_id: Schema.String.check(Schema.isMaxLength(256)), - skill_name: Schema.String.check(Schema.isMaxLength(256)), - }), - target_skill: Schema.Struct({ - skill_id: Schema.String.check(Schema.isMaxLength(256)), - skill_name: Schema.String.check(Schema.isMaxLength(256)), - revision: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), - }), - excerpt_limit_bytes: Schema.Number, - request_limit_bytes: Schema.Number, - entries: Schema.Array(EvidenceCohortEntry).check(Schema.isMaxLength(14)), - fingerprint: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)), - }), - candidate: Schema.Struct({ - proposal_id: Schema.String.check(Schema.isMaxLength(128)), - target_revision: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), - proposed_body: Schema.String.check(Schema.isMaxLength(16_000)), - rationale: Schema.String.check(Schema.isMaxLength(2_000)), - }), - resolved_evidence: Schema.Array(ResolvedEvidenceReference).check(Schema.isMaxLength(14)), -}); -export type PreparedTraceCandidateDraft = typeof preparedDraftSchema.Type; - -const redactedPortableText = (value: string): string => - value - .replace( - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, - "[redacted-private-key]", - ) - .replace( - /\b(?:api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*[^\s,;]+/gi, - "[redacted]", - ) - .replace(/(?:^|([\s"'`]))(?:\/(?:[^\s"'`]+)|[a-zA-Z]:\\[^\s"'`]+)/g, "$1[local-path]"); +export { boundedHistoricalTask } from "./historical-evidence-safety.js"; +export { decodePreparedTraceCandidateDraft } from "./prepared-trace-candidate-draft.js"; +export { TraceCandidatePreparation } from "./trace-candidate-contract.js"; -export const decodePreparedTraceCandidateDraft = (value: unknown) => - Schema.decodeUnknownEffect(preparedDraftSchema)(value); +function taskMentionsSkill(task: string, skillName: string): boolean { + const normalizedTask = task.toLowerCase(); + const normalizedSkill = skillName.trim().toLowerCase(); + 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; -export interface TraceCandidateReview { - readonly draft_id: string | null; - readonly pattern_id: string; - readonly cohort_fingerprint: string | null; - readonly target_revision: string | null; - readonly readiness: "review_ready" | "not_ready"; - readonly failure_reason: string | null; - readonly evidence: { - readonly cohort_entries: number; - readonly resolved_entries: number; - }; - readonly candidate: { - readonly body: string; - readonly rationale: string; - readonly diff: { - readonly changed_lines: number; - readonly target_section: string; - }; - readonly uncertainty: readonly string[]; - } | null; -} - -export class TraceCandidatePreparationError extends Schema.TaggedErrorClass()( - "TraceCandidatePreparationError", - { message: Schema.String }, -) {} - -export interface TraceCandidatePreparationService { - readonly prepare: ( - input: unknown, - ) => Effect.Effect; -} - const supportedPatternThreshold = { uniqueTraces: 3, errorTraces: 2, - errorRatio: 0.5, + successTraces: 1, } as const; -export class TraceCandidatePreparation extends Context.Service< - TraceCandidatePreparation, - TraceCandidatePreparationService ->()("@selftune/local/TraceCandidatePreparation") {} +export function supportsContrastiveRepeatedErrorPattern(input: { + readonly uniqueTraceCount: number; + readonly errorTraceCount: number; +}): boolean { + const successTraceCount = input.uniqueTraceCount - input.errorTraceCount; + return ( + input.uniqueTraceCount >= supportedPatternThreshold.uniqueTraces && + input.errorTraceCount >= supportedPatternThreshold.errorTraces && + successTraceCount >= supportedPatternThreshold.successTraces + ); +} + +export function executionPatternIdForSkill(skillName: string): string { + const id = skillName.trim().toLowerCase(); + const digest = createHash("sha256") + .update(`repeated_correlated_errors:${id}`) + .digest("hex") + .slice(0, 16); + return `execution-pattern-${digest}`; +} function matchingInstalledSkill(patternId: string, searchDirs: readonly string[]) { return findInstalledSkillPackages([...searchDirs]).find((skill) => { - const id = skill.name.trim().toLowerCase(); - const digest = createHash("sha256") - .update(`repeated_correlated_errors:${id}`) - .digest("hex") - .slice(0, 16); - return patternId === `execution-pattern-${digest}`; + return patternId === executionPatternIdForSkill(skill.name); }); } -export const liveCohortBodyTeacher: CohortBodyTeacher = async (input) => { - const raw = await callViaSubagent({ - agentName: "evidence-cohort-teacher", - prompt: `Return ONLY JSON matching this exact schema: {"schema_version":1,"proposed_body":string,"rationale":string,"confidence":number,"target_section":string,"scope":"section_local"|"skill_specific"|"task_family"|"general","mutation_operation":"add"|"refine"|"replace"|"remove","principle":string,"applicability":string,"failure_mode":string,"preserved_constraints":string[],"superseded_guidance":string[],"uncertainty":string[]}.\nCreate a minimal review-only SKILL.md body change. Do not include transcript text.\n${JSON.stringify(input)}`, - maxTurns: 1, - }); - try { - return JSON.parse(raw); - } catch { - throw new Error("The local teacher did not return a JSON object."); - } -}; +export function makeLiveCohortBodyTeacher(options?: { + readonly agent?: LlmBackedAgent; +}): CohortBodyTeacher { + return async (input) => { + const raw = await callViaSubagent({ + agent: options?.agent, + agentName: "evidence-cohort-teacher", + prompt: `Return ONLY JSON matching this exact schema: {"schema_version":1,"proposed_body":string,"rationale":string,"confidence":number,"target_section":string,"scope":"section_local"|"skill_specific"|"task_family"|"general","mutation_operation":"add"|"refine"|"replace"|"remove","principle":string,"applicability":string,"failure_mode":string,"preserved_constraints":string[],"superseded_guidance":string[],"uncertainty":string[]}.\nCreate one minimal review-only SKILL.md body change. When search context is present, follow its strategy and make this proposal materially distinct from other likely candidates. Preserve current_body byte-for-byte outside one contiguous changed region. Add, replace, or remove at most 40 changed lines. Do not reflow headings, tables, or unrelated prose. Return the complete proposed body. Do not include transcript text.\n${JSON.stringify(input)}`, + maxTurns: 1, + }); + try { + return JSON.parse(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; + studentAgent?: LlmBackedAgent; + studentModel?: string; searchDirs?: readonly string[]; computeRevision?: (skillPath: string) => string | undefined; evolutionDeps?: Omit; + historicalTaskCalibrator?: HistoricalTaskCalibrator; }): Layer.Layer { return Layer.effect( TraceCandidatePreparation, @@ -178,7 +150,14 @@ export function makeTraceCandidatePreparationLayer(options: { return yield* new TraceCandidatePreparationError({ message: "Could not resolve the installed skill revision.", }); - const candidates = yield* analytical + const historicalSignal = (yield* analytical + .querySkillSignals() + .pipe( + Effect.mapError( + (error) => new TraceCandidatePreparationError({ message: error.message }), + ), + )).find((signal) => signal.skill_name.trim().toLowerCase() === skillId); + const analyticalCandidates = yield* analytical .queryEvidenceCohortCandidates({ pattern: { pattern_id: input.pattern_id, @@ -193,7 +172,7 @@ export function makeTraceCandidatePreparationLayer(options: { ), ); const byTrace = new Map(); - for (const candidate of candidates) { + for (const candidate of analyticalCandidates) { const existing = byTrace.get(candidate.trace_id) ?? { hasError: false, }; @@ -202,12 +181,114 @@ export function makeTraceCandidatePreparationLayer(options: { } const uniqueTraceCount = byTrace.size; const errorTraceCount = [...byTrace.values()].filter((trace) => trace.hasError).length; - const errorRatio = uniqueTraceCount === 0 ? 0 : errorTraceCount / uniqueTraceCount; - if ( - uniqueTraceCount < supportedPatternThreshold.uniqueTraces || - errorTraceCount < supportedPatternThreshold.errorTraces || - errorRatio < supportedPatternThreshold.errorRatio - ) { + if (!supportsContrastiveRepeatedErrorPattern({ uniqueTraceCount, errorTraceCount })) { + const sessionOnlyHistory = + uniqueTraceCount === 0 && (historicalSignal?.trace_count ?? 0) > 0; + if (sessionOnlyHistory) { + return yield* prepareHistoricalTaskCandidate({ + analytical, + sqlite: options.sqlite, + teacher: options.teacher ?? liveCohortBodyTeacher, + patternId: input.pattern_id, + installed, + skillId, + revision, + computeRevision, + candidateCount: input.candidate_count ?? 3, + calibrationRepetitions: input.calibration_repetitions ?? 3, + calibrator: options.historicalTaskCalibrator, + }); + } + return { + draft_id: null, + pattern_id: input.pattern_id, + cohort_fingerprint: null, + target_revision: revision, + readiness: "not_ready", + failure_reason: + "The exact pattern is no longer supported: it requires at least 3 unique traces, 2 error traces, and 1 successful counterexample.", + evidence: { cohort_entries: 0, resolved_entries: 0 }, + candidate: null, + } satisfies TraceCandidateReview; + } + const ids = analyticalCandidates.map((entry) => entry.skill_invocation_id); + const placeholders = ids.map(() => "?").join(","); + const rows = + ids.length === 0 + ? [] + : (options.sqlite + .query( + `SELECT + invocation.skill_invocation_id, + invocation.query, + prompt.prompt_text AS matched_prompt, + invocation.triggered, + invocation.invocation_mode, + invocation.source, + invocation.capture_mode, + invocation.skill_version_hash, + invocation.occurred_at, + invocation.skill_path + FROM skill_invocations invocation + 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; + }>); + const packageMtimeMs = yield* Effect.try({ + try: () => latestPackageMtimeMs(installed.package_path), + catch: (error) => + new TraceCandidatePreparationError({ + message: `Could not inspect the installed skill snapshot: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + const resolvedRows = new Map( + rows.flatMap((row) => { + const occurredAt = row.occurred_at ? Date.parse(row.occurred_at) : Number.NaN; + const revisionResolution = + row.skill_version_hash === revision + ? ("captured" as const) + : !row.skill_version_hash && + Number.isFinite(occurredAt) && + packageMtimeMs <= occurredAt && + pathCanUseInstalledSnapshot(row.skill_path, installed.skill_path) + ? ("stable_installed_snapshot" as const) + : null; + const task = + boundedHistoricalTask(row.matched_prompt) ?? boundedHistoricalTask(row.query); + const sourceExact = row.capture_mode === "hook" || row.source === "claude_code_replay"; + const eligibleTask = + task !== null && + (row.invocation_mode === "explicit" || + sourceExact || + taskMentionsSkill(task, installed.name)) + ? task + : null; + return eligibleTask && revisionResolution + ? [ + [ + row.skill_invocation_id, + { row, task: eligibleTask, revisionResolution }, + ] as const, + ] + : []; + }), + ); + const candidates = analyticalCandidates.flatMap((candidate) => { + const resolution = resolvedRows.get(candidate.skill_invocation_id); + return resolution ? [{ ...candidate, source_excerpt: resolution.task }] : []; + }); + if (candidates.length === 0) { return { draft_id: null, pattern_id: input.pattern_id, @@ -215,7 +296,7 @@ export function makeTraceCandidatePreparationLayer(options: { target_revision: revision, readiness: "not_ready", failure_reason: - "The exact pattern is no longer supported: it requires at least 3 unique traces, 2 error traces, and a 0.5 error-trace ratio.", + "Historical traces exist, but none can be resolved to a bounded task at the exact installed skill revision.", evidence: { cohort_entries: 0, resolved_entries: 0 }, candidate: null, } satisfies TraceCandidateReview; @@ -258,31 +339,16 @@ export function makeTraceCandidatePreparationLayer(options: { candidate: null, } satisfies TraceCandidateReview; } - const ids = materializedCohort.entries.map((entry) => entry.source.skill_invocation_id); - const placeholders = ids.map(() => "?").join(","); - const rows = - ids.length === 0 - ? [] - : (options.sqlite - .query( - `SELECT skill_invocation_id, query, triggered, skill_version_hash FROM skill_invocations WHERE skill_invocation_id IN (${placeholders}) AND skill_version_hash = ?`, - ) - .all(...ids, revision) as Array<{ - skill_invocation_id: string; - query: string | null; - triggered: number | null; - skill_version_hash: string; - }>); - const byId = new Map(rows.map((row) => [row.skill_invocation_id, row])); const resolved = materializedCohort.entries.flatMap((entry) => { - const row = byId.get(entry.source.skill_invocation_id); - if (!row?.query) return []; + const resolution = resolvedRows.get(entry.source.skill_invocation_id); + if (!resolution) return []; return [ ResolvedEvidenceReference.make({ ...entry.source, - skill_revision: row.skill_version_hash, - query: row.query, - should_trigger: Boolean(row.triggered), + skill_revision: revision, + revision_resolution: resolution.revisionResolution, + query: resolution.task, + should_trigger: Boolean(resolution.row.triggered), }), ]; }); @@ -293,6 +359,8 @@ export function makeTraceCandidatePreparationLayer(options: { cohort: materializedCohort, resolved_evidence: resolved, teacher: options.teacher ?? liveCohortBodyTeacher, + student_agent: options.studentAgent, + student_model: options.studentModel, }, { ...options.evolutionDeps, diff --git a/apps/local/tests/cloud-billing.test.ts b/apps/local/tests/cloud-billing.test.ts index 8ca69547..236a7f65 100644 --- a/apps/local/tests/cloud-billing.test.ts +++ b/apps/local/tests/cloud-billing.test.ts @@ -13,7 +13,7 @@ const preferences = { decisionHistory: true, }; -function cloudConfig(url = "https://api.selftune.dev"): RemoteLibraryConfig { +function cloudConfig(url = "https://cloud.selftune.dev"): RemoteLibraryConfig { return { version: 2, url, @@ -40,16 +40,12 @@ describe("Cloud billing sidecar transport", () => { const fetch: typeof globalThis.fetch = async (input, init) => { const request = new Request(input, init); requests.push(request); - if (request.url.endsWith("/checkout/finalize")) { - return Response.json({ - finalized: true, - billing: billingStatus, - sessionStatus: "complete", - paymentStatus: "paid", - }); - } - if (request.url.endsWith("/status")) return Response.json(billingStatus); - return Response.json({ url: "https://billing.stripe.test/session" }); + return Response.json({ + workspaceId: "workspace-1", + plan: "free", + status: "none", + currentPeriodEnd: null, + }); }; const billing = makeCloudBillingOperations("/unused", { fetch, @@ -57,25 +53,22 @@ describe("Cloud billing sidecar transport", () => { }); await billing.status(); - await billing.checkout({ plan: "team", seats: 3 }); - await billing.portal(); + await expect(billing.checkout({ plan: "team", seats: 3 })).resolves.toEqual({ + url: "https://cloud.selftune.dev/?billing=team", + }); + await expect(billing.portal()).resolves.toEqual({ + url: "https://cloud.selftune.dev/?billing=portal", + }); await billing.finalize({ sessionId: "cs_test_123" }); expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual( - [ - "GET /api/v1/cloud/billing/status", - "POST /api/v1/cloud/billing/checkout", - "POST /api/v1/cloud/billing/portal", - "POST /api/v1/cloud/billing/checkout/finalize", - ], + ["GET /api/v1/desktop/state", "GET /api/v1/desktop/state"], ); expect( requests.every( (request) => request.headers.get("authorization") === "Bearer remote-library-secret", ), ).toBe(true); - expect(await requests[1]?.json()).toEqual({ plan: "team", seats: 3 }); - expect(await requests[3]?.json()).toEqual({ sessionId: "cs_test_123" }); }); test("maps unreachable and invalid Cloud responses to retryable operational errors", async () => { diff --git a/apps/local/tests/cloud-team-collaboration.test.ts b/apps/local/tests/cloud-team-collaboration.test.ts new file mode 100644 index 00000000..842d72a8 --- /dev/null +++ b/apps/local/tests/cloud-team-collaboration.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; + +import type { RemoteLibraryConfig } from "@selftune/library/remote/config"; + +import { + CloudTeamCollaborationError, + makeCloudTeamCollaborationOperations, +} from "../src/cloud-team-collaboration.js"; + +const hashA = "a".repeat(64); +const hashB = "b".repeat(64); +const timestamp = "2026-08-01T08:00:00.000Z"; +const snapshot = { + entries: [ + { + id: "entry/one", + name: "Deploy safely", + rolloutPolicy: "manual", + currentVersion: "1.0.0", + pendingContributions: 1, + installations: 1, + conflicts: 0, + }, + ], + contributions: [ + { + id: "candidate one", + entryId: "entry/one", + entryName: "Deploy safely", + baseVersionId: "version-base", + baseVersion: "1.0.0", + candidateVersion: "1.1.0", + candidateContentHash: hashB, + files: [{ path: "SKILL.md", hash: hashB, size: 42 }], + changes: [ + { + path: "SKILL.md", + kind: "modified", + baseHash: hashA, + candidateHash: hashB, + }, + ], + summary: "Clarify the deployment check.", + submittedBy: "user-2", + submittedByName: "Ada", + status: "pending", + reviewedBy: null, + adoptedVersionId: null, + createdAt: timestamp, + reviewedAt: null, + }, + ], + installations: [ + { + id: "installation-1", + entryId: "entry/one", + entryName: "Deploy safely", + deviceId: "device-1", + installedVersion: "1.0.0", + installedContentHash: hashA, + latestVersion: "1.0.0", + latestContentHash: hashA, + rolloutPolicy: "manual", + updateStatus: "current", + lastSyncedAt: timestamp, + lastConflictAt: null, + lastReceiptId: null, + }, + ], +} as const; + +const teamStatus = { + currentUserId: "user-1", + currentRole: "admin", + readOnly: false, + seatUsage: 2, + seatLimit: 10, + billingPath: "/settings/billing", + members: [], + invitations: [], +} as const; + +function remoteConfig(url = "http://127.0.0.1:4399"): RemoteLibraryConfig { + return { + version: 2, + url, + apiKey: "desktop-device-key", + credentialProvider: "environment", + preferences: { + releasedSkills: true, + drafts: false, + skillSets: true, + metadata: true, + decisionHistory: true, + }, + }; +} + +describe("Cloud team collaboration sidecar transport", () => { + test("uses the configured remote and stored credential for every canonical endpoint", async () => { + const requests: Request[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const path = new URL(request.url).pathname; + if (path === "/api/v1/cloud/team") return Response.json(teamStatus); + if (path === "/api/v1/collaboration") return Response.json(snapshot); + if (path.endsWith("/rollout-policy")) { + return Response.json({ entryId: "entry/one", policy: "automatic" }); + } + return Response.json({ + id: "candidate one", + status: path.endsWith("/rollback") ? "rolled_back" : "adopted", + }); + }; + const collaboration = makeCloudTeamCollaborationOperations("/unused", { + fetch, + loadRemoteLibraryConfig: () => remoteConfig(), + }); + + expect(await collaboration.access()).toEqual({ currentRole: "admin", readOnly: false }); + expect(await collaboration.snapshot()).toEqual(snapshot); + await collaboration.updateRolloutPolicy("entry/one", "automatic"); + await collaboration.decide("candidate one", "adopt"); + await collaboration.decide("candidate one", "reject"); + await collaboration.decide("candidate one", "rollback"); + + expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual( + [ + "GET /api/v1/cloud/team", + "GET /api/v1/collaboration", + "PATCH /api/v1/collaboration/registry/entry%2Fone/rollout-policy", + "POST /api/v1/collaboration/contributions/candidate%20one/adopt", + "POST /api/v1/collaboration/contributions/candidate%20one/reject", + "POST /api/v1/collaboration/contributions/candidate%20one/rollback", + ], + ); + expect( + requests.every( + (request) => request.headers.get("authorization") === "Bearer desktop-device-key", + ), + ).toBe(true); + expect( + requests.every((request) => new URL(request.url).origin === "http://127.0.0.1:4399"), + ).toBe(true); + expect(await requests[2]?.json()).toEqual({ policy: "automatic" }); + }); + + test("rejects malformed success payloads before they reach the renderer", async () => { + const collaboration = makeCloudTeamCollaborationOperations("/unused", { + fetch: async () => Response.json({ entries: [], contributions: [], installations: "bad" }), + loadRemoteLibraryConfig: () => remoteConfig(), + }); + + await expect(collaboration.snapshot()).rejects.toMatchObject({ + _tag: "CloudTeamCollaborationError", + code: "API_ERROR", + status: 502, + retryable: true, + } satisfies Partial); + }); + + test("preserves structured Cloud authorization failures", async () => { + const collaboration = makeCloudTeamCollaborationOperations("/unused", { + fetch: async () => + Response.json( + { error: { code: "forbidden", message: "Admins only", retryable: false } }, + { status: 403 }, + ), + loadRemoteLibraryConfig: () => remoteConfig(), + }); + + await expect(collaboration.decide("candidate", "adopt")).rejects.toMatchObject({ + _tag: "CloudTeamCollaborationError", + code: "forbidden", + message: "Admins only", + status: 403, + retryable: false, + } satisfies Partial); + }); +}); diff --git a/apps/local/tests/correction-studies-route.test.ts b/apps/local/tests/correction-studies-route.test.ts index cb055c0f..b4ea96d6 100644 --- a/apps/local/tests/correction-studies-route.test.ts +++ b/apps/local/tests/correction-studies-route.test.ts @@ -69,7 +69,34 @@ describe("correction-study routes", () => { expect(await response?.json()).toEqual({ recorded: true, applies_skill: false }); }); - test("lists a bounded persisted review projection", async () => { + test("rejects a review decision mutation without Origin before recording it", async () => { + let recorded = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => ({}), + lookup: async () => ({}), + recordReviewDecision: async () => { + recorded = true; + return {}; + }, + }); + const path = "/api/v2/correction-studies/review-decisions"; + const response = await routes.handle( + new Request(`${origin}${path}`, { + method: "POST", + headers: { + authorization: "Bearer local-dashboard-session", + "content-type": "application/json", + }, + body: JSON.stringify({ candidate_id: "candidate-1", action: "defer" }), + }), + new URL(`${origin}${path}`), + allowedOrigins, + ); + expect(response?.status).toBe(403); + expect(recorded).toBeFalse(); + }); + + test("lists a bounded persisted review projection for a same-origin dashboard GET without Origin", async () => { const routes = createCorrectionStudyRoutes({ captureExplicitCorrection: async () => ({}), lookup: async () => ({}), @@ -80,7 +107,7 @@ describe("correction-study routes", () => { }); const response = await routes.handle( new Request(`${origin}/api/v2/correction-studies/reviews?limit=7`, { - headers: { origin }, + headers: { authorization: "Bearer local-dashboard-session" }, }), new URL(`${origin}/api/v2/correction-studies/reviews?limit=7`), allowedOrigins, @@ -92,6 +119,51 @@ describe("correction-study routes", () => { }); }); + test("rejects a review-list GET with an untrusted Origin", async () => { + let listed = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => ({}), + lookup: async () => ({}), + listReviews: async () => { + listed = true; + return { items: [] }; + }, + }); + const path = "/api/v2/correction-studies/reviews"; + const response = await routes.handle( + new Request(`${origin}${path}`, { headers: { origin: "https://evil.example" } }), + new URL(`${origin}${path}`), + allowedOrigins, + ); + expect(response?.status).toBe(403); + expect(listed).toBeFalse(); + }); + + test("rejects an explicit-correction mutation without Origin before capture", async () => { + let captured = false; + const routes = createCorrectionStudyRoutes({ + captureExplicitCorrection: async () => { + captured = true; + return {}; + }, + lookup: async () => ({}), + }); + const response = await routes.handle( + new Request(`${origin}${capturePath}`, { + method: "POST", + headers: { + authorization: "Bearer local-dashboard-session", + "content-type": "application/json", + }, + body: "{}", + }), + new URL(`${origin}${capturePath}`), + allowedOrigins, + ); + expect(response?.status).toBe(403); + expect(captured).toBeFalse(); + }); + test("rejects a wrong origin before capture", async () => { let captured = false; const routes = createCorrectionStudyRoutes({ diff --git a/apps/local/tests/historical-skill-improvement-service.test.ts b/apps/local/tests/historical-skill-improvement-service.test.ts new file mode 100644 index 00000000..2bc8aa5e --- /dev/null +++ b/apps/local/tests/historical-skill-improvement-service.test.ts @@ -0,0 +1,400 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createOrGetPreparedEvaluationSubmissionDraft, + listLatestCorrectionCandidateEvaluations, + openDb, +} from "@selftune/local-store"; +import { replaceBody } from "@selftune/runtime/evolution/deploy-proposal"; +import { computeSkillVersionHash } from "@selftune/runtime/utils/skill-discovery"; +import { qualifyVerifierInstrument } from "@selftune/skill-intelligence/verifier-instruments"; +import { Effect, Layer } from "effect"; + +import { + HistoricalSkillImprovement, + makeHistoricalSkillImprovementLayer, +} from "../src/historical-skill-improvement-service.js"; +import { TraceCandidatePreparation } from "../src/trace-candidate-service.js"; + +const directories: string[] = []; +afterEach(() => + directories.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true })), +); + +const hash = (value: string): string => createHash("sha256").update(value).digest("hex"); +const fingerprint = (value: string): string => `sha256:${hash(value)}`; + +function verifier() { + return qualifyVerifierInstrument({ + instrument: { + verifier_id: "deterministic-task-check", + version: "v1", + kind: "deterministic", + 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, + })), + }); +} + +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"); + + 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", + }, + 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"), + }, + 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: [], + }, + }), + }), + ); + 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); + }, + }, + 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"), + }, + 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)), + ); + + 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(); + }); + + test("evaluates a neutral historical-task draft and returns a concrete before/after", async () => { + const database = openDb(":memory:"); + const root = mkdtempSync(join(tmpdir(), "selftune-historical-task-e2-")); + directories.push(root); + const skillDirectory = join(root, "skills", "to-issues"); + mkdirSync(skillDirectory, { recursive: true }); + const skillPath = join(skillDirectory, "SKILL.md"); + const installedContent = + "---\nname: to-issues\ndescription: Break plans into issues.\n---\n\n# To Issues\n\nAsk for approval before drafting.\n"; + const proposedBody = + "For explicit create requests, produce complete issue drafts without repeating approval."; + writeFileSync(skillPath, installedContent); + const installedRevision = computeSkillVersionHash(skillPath); + if (!installedRevision) throw new Error("fixture revision missing"); + const roles = ["calibration", "calibration", "selection", "audit_holdout"] as const; + const entries = roles.map((role, index) => ({ + role, + source: { + source_id: `source-${index}`, + source_revision: `revision-${index}`, + trace_id: `${index + 1}`.repeat(32), + span_id: `${index + 1}`.repeat(16), + skill_invocation_id: `invocation-${index}`, + }, + redacted_task: `Create release issues task ${index}`, + })); + const payload = { + schema_version: 2 as const, + cohort: { + schema_version: "1.0.0" as const, + selector_version: "local-historical-task-quality/v1", + pattern: { + pattern_id: "execution-pattern-to-issues", + kind: "historical_task_quality" as const, + skill_id: "to-issues", + skill_name: "to-issues", + }, + target_skill: { + skill_id: "to-issues", + skill_name: "to-issues", + revision: installedRevision, + }, + request_limit_bytes: 8_192, + entries, + fingerprint: fingerprint("historical-task-cohort"), + }, + candidate: { + proposal_id: "historical-task-proposal", + target_revision: installedRevision, + proposed_body: proposedBody, + rationale: "Honor explicit issue-creation approval.", + }, + }; + const draftId = "eval-draft-historical-task"; + await Effect.runPromise( + createOrGetPreparedEvaluationSubmissionDraft(database, { + draft_id: draftId, + pattern_id: payload.cohort.pattern.pattern_id, + cohort_fingerprint: payload.cohort.fingerprint, + skill_name: "to-issues", + 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: 4, resolved_entries: 4 }, + candidate: { + body: proposedBody, + rationale: payload.candidate.rationale, + diff: { changed_lines: 1, target_section: "Process" }, + uncertainty: [], + }, + }), + }), + ); + const layer = Layer.provide( + makeHistoricalSkillImprovementLayer({ + sqlite: database, + executorFactory: { + create: (context) => + Effect.succeed({ + execute: (request) => { + const passed = request.arm === "candidate_skill"; + context.recordObservation?.({ + caseId: request.case.case_id, + task: request.case.task_payload, + arm: request.arm, + repetition: request.repetition, + passed, + output: passed + ? "## 1. Release slice\nAcceptance criteria: verified\nBlocked by: none" + : "Does the granularity feel right?", + }); + return Effect.succeed({ + kind: "scored" as const, + passed, + executed_revision: request.revision, + }); + }, + }), + }, + 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.6-luna", + config_digest: fingerprint("luna-max-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: 20, + estimated_cost_usd: 5, + }, + recorded_at: "2026-08-06T10:01:00.000Z", + }); + }).pipe(Effect.provide(layer)), + ); + + expect(result).toMatchObject({ + status: "review_ready", + evidence_level: "E2", + reason: "selected", + cases: { calibration: 2, selection: 1, audit_holdout: 1 }, + before_after: { + task: expect.stringContaining("task"), + current: { passed: false, output: "Does the granularity feel right?" }, + candidate: { passed: true, output: expect.stringContaining("Acceptance criteria") }, + }, + }); + database.close(); + }); +}); diff --git a/apps/local/tests/historical-skill-replay-executor.test.ts b/apps/local/tests/historical-skill-replay-executor.test.ts new file mode 100644 index 00000000..49849e75 --- /dev/null +++ b/apps/local/tests/historical-skill-replay-executor.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, test } from "bun:test"; + +import { qualifyVerifierInstrument } from "@selftune/skill-intelligence/verifier-instruments"; +import { Effect } from "effect"; + +import { + assessToIssuesDraftQuality, + historicalRoutingVerifierQualification, + historicalTaskQualityVerifierQualification, + makeHostHistoricalSkillReplayExecutorFactory, + verifyToIssuesDraftQuality, +} from "../src/historical-skill-replay-executor.js"; + +const revision = (character: string): string => character.repeat(64); + +const benchmarkCase = { + case_id: "historical-case-1", + task_payload: "Break this plan into independently grabbable issues.", + task_fingerprint: `sha256:${"d".repeat(64)}`, + partition: "selection" as const, + regression_case: false, +}; + +describe("historical host replay executor", () => { + test("qualifies and applies the deterministic to-issues execution contract", () => { + expect(historicalTaskQualityVerifierQualification().status).toBe("qualified"); + expect( + verifyToIssuesDraftQuality( + "Create release issues /to-issues", + "## 1. Release slice\n\n## Acceptance criteria\n- [ ] Verified\n\n## Blocked by\nNone - can start immediately", + ), + ).toBe(true); + expect( + verifyToIssuesDraftQuality( + "Create release issues /to-issues", + "## 1. Release slice\nAcceptance criteria: verified\nBlocked by: none\nDoes the granularity feel right (too coarse / too fine)?", + ), + ).toBe(false); + expect(assessToIssuesDraftQuality("Create release issues", "1. Release slice")).toEqual({ + passed: false, + failures: ["missing_acceptance_criteria", "missing_blocked_by_dependencies"], + }); + expect( + verifyToIssuesDraftQuality( + "Create release issues", + "1. **Release slice**\n- Verify smoke tests.\n- Record evidence.\n- Blocked by: None.", + ), + ).toBe(true); + expect( + verifyToIssuesDraftQuality( + "Create release issues", + "1. **Release slice**\n- Verify smoke tests.\n- Record evidence.\n- Blocked by: None.\nDoes this granularity and dependency order look right?", + ), + ).toBe(false); + }); + + test("runs the execution-quality arms with Luna max and records bounded outputs", async () => { + const calls: Array<{ + body: string; + includeTargetSkill: boolean; + model: string; + reasoningEffort: string; + }> = []; + const observations: Array<{ arm: string; passed: boolean; output: string }> = []; + const factory = makeHostHistoricalSkillReplayExecutorFactory({ + findExecutable: () => "/usr/local/bin/codex", + runTaskReplay: async (input) => { + calls.push({ + body: input.body, + includeTargetSkill: input.includeTargetSkill, + model: input.model, + reasoningEffort: input.reasoningEffort, + }); + const output = + input.body === "candidate body" + ? "## 1. Release slice\n\n## Acceptance criteria\n- [ ] Verified\n\n## Blocked by\nNone - can start immediately" + : "Does the granularity feel right (too coarse / too fine)?"; + return { + output, + raw_output: "", + session_id: "task-replay", + duration_ms: 25, + input_tokens: 20, + output_tokens: 10, + }; + }, + }); + const qualification = historicalTaskQualityVerifierQualification(); + const executor = await Effect.runPromise( + factory.create({ + skillName: "to-issues", + skillPath: "/tmp/skills/to-issues/SKILL.md", + currentBody: "current body", + candidateBody: "candidate body", + currentRevision: revision("a"), + candidateRevision: revision("b"), + runtime: { + harness: "codex", + model: "gpt-5.6-luna", + config_digest: `sha256:${"c".repeat(64)}`, + }, + qualifiedVerifier: qualification, + recordObservation: (observation) => observations.push(observation), + }), + ); + const result = await Effect.runPromise( + executor.execute({ + case: benchmarkCase, + arm: "candidate_skill", + revision: revision("b"), + repetition: 1, + attempt: 1, + runtime: { + harness: "codex", + model: "gpt-5.6-luna", + config_digest: `sha256:${"c".repeat(64)}`, + }, + verifier_id: qualification.instrument.verifier_id, + verifier_version: qualification.instrument.version, + }), + ); + + expect(calls).toEqual([ + { + body: "candidate body", + includeTargetSkill: true, + model: "gpt-5.6-luna", + reasoningEffort: "max", + }, + ]); + expect(result).toMatchObject({ + kind: "scored", + passed: true, + executed_revision: revision("b"), + process: { input_tokens: 20, output_tokens: 10, wall_time_ms: 25 }, + }); + expect(observations).toMatchObject([ + { + arm: "candidate_skill", + passed: true, + output: + "## 1. Release slice\n\n## Acceptance criteria\n- [ ] Verified\n\n## Blocked by\nNone - can start immediately", + }, + ]); + }); + + test("stages no, current, and candidate skill arms and retains process evidence", async () => { + const observed: Array<{ + body: string; + includeTargetSkill: boolean; + model?: string; + reasoningEffort?: string; + }> = []; + const factory = makeHostHistoricalSkillReplayExecutorFactory({ + findExecutable: () => "/usr/local/bin/codex", + runReplay: async (input) => { + observed.push({ + body: input.routing, + includeTargetSkill: input.includeTargetSkill ?? true, + model: input.model, + reasoningEffort: input.reasoningEffort, + }); + const passed = input.includeTargetSkill === true && input.routing === "candidate body"; + return [ + { + query: input.evalSet[0]?.query ?? "", + should_trigger: true, + triggered: passed, + passed, + runtime_metrics: { + input_tokens: 12, + output_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: null, + duration_ms: 45, + num_turns: 1, + }, + }, + ]; + }, + }); + const currentRevision = revision("a"); + const candidateRevision = revision("b"); + const executor = await Effect.runPromise( + factory.create({ + skillName: "to-issues", + skillPath: "/tmp/skills/to-issues/SKILL.md", + currentBody: "current body", + candidateBody: "candidate body", + currentRevision, + candidateRevision, + runtime: { + harness: "codex", + model: "gpt-5.6-luna", + config_digest: `sha256:${"c".repeat(64)}`, + }, + qualifiedVerifier: historicalRoutingVerifierQualification(), + }), + ); + + const execute = (arm: "no_skill" | "current_skill" | "candidate_skill") => + Effect.runPromise( + executor.execute({ + case: benchmarkCase, + arm, + revision: + arm === "no_skill" + ? null + : arm === "current_skill" + ? currentRevision + : candidateRevision, + repetition: 1, + attempt: 1, + runtime: { + harness: "codex", + model: "gpt-5.6-luna", + config_digest: `sha256:${"c".repeat(64)}`, + }, + verifier_id: historicalRoutingVerifierQualification().instrument.verifier_id, + verifier_version: historicalRoutingVerifierQualification().instrument.version, + }), + ); + const [withoutSkill, current, candidate] = await Promise.all([ + execute("no_skill"), + execute("current_skill"), + execute("candidate_skill"), + ]); + + expect(observed).toEqual([ + { + body: "current body", + includeTargetSkill: false, + model: "gpt-5.6-luna", + reasoningEffort: "max", + }, + { + body: "current body", + includeTargetSkill: true, + model: "gpt-5.6-luna", + reasoningEffort: "max", + }, + { + body: "candidate body", + includeTargetSkill: true, + model: "gpt-5.6-luna", + reasoningEffort: "max", + }, + ]); + expect(withoutSkill).toMatchObject({ kind: "scored", passed: false, executed_revision: null }); + expect(current).toMatchObject({ + kind: "scored", + passed: false, + executed_revision: currentRevision, + }); + expect(candidate).toMatchObject({ + kind: "scored", + passed: true, + executed_revision: candidateRevision, + process: { turns: 1, input_tokens: 12, output_tokens: 3, wall_time_ms: 45 }, + }); + }); + + test("rejects a verifier whose claim does not match the deterministic routing check", async () => { + const unrelatedVerifier = qualifyVerifierInstrument({ + instrument: { + verifier_id: "unrelated-check", + version: "v1", + kind: "deterministic", + 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, + })), + }); + const result = await Effect.runPromiseExit( + makeHostHistoricalSkillReplayExecutorFactory({ + findExecutable: () => "/usr/local/bin/codex", + }).create({ + skillName: "to-issues", + skillPath: "/tmp/skills/to-issues/SKILL.md", + currentBody: "current body", + candidateBody: "candidate body", + currentRevision: revision("a"), + candidateRevision: revision("b"), + runtime: { + harness: "codex", + model: "configured-default", + config_digest: `sha256:${"c".repeat(64)}`, + }, + qualifiedVerifier: unrelatedVerifier, + }), + ); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(String(result.cause)).toContain( + "requires verifier selftune.skill-routing-selection@v1", + ); + } + }); +}); diff --git a/apps/local/tests/historical-task-candidate.test.ts b/apps/local/tests/historical-task-candidate.test.ts new file mode 100644 index 00000000..1f3c5933 --- /dev/null +++ b/apps/local/tests/historical-task-candidate.test.ts @@ -0,0 +1,293 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { getEvaluationSubmissionDraft, openDb } from "@selftune/local-store"; +import { DuckDbAnalyticalStore } from "@selftune/observability"; +import { computeSkillVersionHash } from "@selftune/runtime/utils/skill-discovery"; +import { Effect, Layer } from "effect"; + +import { + TraceCandidatePreparation, + decodePreparedTraceCandidateDraft, + executionPatternIdForSkill, + makeTraceCandidatePreparationLayer, +} from "../src/trace-candidate-service.js"; + +const directories: string[] = []; +afterEach(() => + directories.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true })), +); + +describe("historical task-quality candidate", () => { + test("uses three exact explicit DuckDB-linked tasks with separate blind partitions", async () => { + const sqlite = openDb(":memory:"); + const root = mkdtempSync(join(tmpdir(), "selftune-historical-task-quality-")); + directories.push(root); + const skillDirectory = join(root, "skills", "to-issues"); + mkdirSync(skillDirectory, { recursive: true }); + const skillPath = join(skillDirectory, "SKILL.md"); + const currentBody = "Break plans into vertical issue slices."; + writeFileSync( + skillPath, + `---\nname: to-issues\ndescription: Break plans into issues.\n---\n\n# To Issues\n\n${currentBody}\n`, + ); + const revision = computeSkillVersionHash(skillPath); + if (!revision) throw new Error("fixture revision missing"); + const tasks = [ + "create all of them as issues /to-issues", + "break ADR 0005 into tracked GitHub issues", + "create a go-live checklist and then create any issues /to-issues", + ]; + for (const [index, task] of tasks.entries()) { + const sessionId = `historical-session-${index}`; + const invocationId = `historical-invocation-${index}`; + sqlite.run( + "INSERT INTO sessions (session_id, platform, capture_mode) VALUES (?, 'claude_code', 'replay')", + [sessionId], + ); + sqlite.run( + `INSERT INTO skill_invocations + (skill_invocation_id, session_id, occurred_at, skill_name, invocation_mode, + triggered, query, skill_path, source, platform, capture_mode, skill_version_hash) + VALUES (?, ?, '2026-08-06T00:00:00.000Z', 'to-issues', 'explicit', + 1, ?, ?, 'claude_code_replay', 'claude_code', 'replay', ?)`, + [invocationId, sessionId, task, skillPath, revision], + ); + } + const references = tasks.map((_, index) => ({ + trace_id: (index + 1).toString(16).repeat(32), + span_id: (index + 5).toString(16).repeat(16), + skill_invocation_id: `historical-invocation-${index}`, + source_id: `source-${index}`, + source_revision: `source-revision-${index}`, + trace_boundary: "session" as const, + capture_mode: "replay" as const, + source_authority: "source_truth" as const, + evidence_quality: "source_exact" as const, + model: "claude-opus", + })); + const analytical = Layer.succeed( + DuckDbAnalyticalStore, + DuckDbAnalyticalStore.of({ + hasExactBatchReceipt: () => Effect.succeed(false), + ingest: () => Effect.die("unused"), + querySkillSignals: () => + Effect.succeed([ + { + skill_name: "to-issues", + invocation_count: 3, + trace_count: 3, + error_trace_count: 2, + duration_ms: 0, + input_tokens: 0, + output_tokens: 0, + error_count: 62, + tool_call_count: 3, + }, + ]), + queryEvidenceCohortCandidates: () => Effect.succeed([]), + queryHistoricalSkillTaskReferences: () => Effect.succeed(references), + queryHistoricalMetricRollups: () => Effect.die("unused"), + health: () => Effect.die("unused"), + }), + ); + let teacherCalls = 0; + const calibratedBodies: string[] = []; + const layer = Layer.provide( + makeTraceCandidatePreparationLayer({ + sqlite, + searchDirs: [join(root, "skills")], + historicalTaskCalibrator: async (input) => { + calibratedBodies.push(input.body); + const candidateIndex = Number(input.body.match(/candidate-(\d+)/)?.[1] ?? "0"); + return candidateIndex === 0 || candidateIndex === 3 + ? { + passed: false, + score: 0.2, + output: "I need another approval round before drafting issues.", + feedback: "missing_acceptance_criteria, missing_blocked_by_dependencies", + process: { + input_tokens: 20, + output_tokens: 20, + wall_time_ms: 20, + }, + } + : { + passed: true, + score: candidateIndex === 2 ? 1 : 0.9, + output: `Complete issue draft from candidate ${candidateIndex}.`, + feedback: "none", + process: { + input_tokens: 100, + output_tokens: candidateIndex === 2 ? 80 : 40, + wall_time_ms: candidateIndex === 2 ? 200 : 100, + }, + }; + }, + teacher: async (input) => ({ + schema_version: 1, + proposed_body: `${input.current_body}\n\ncandidate-${++teacherCalls}: When explicitly asked to create issues, produce complete drafts with acceptance criteria and Blocked by dependencies.`, + rationale: "Avoids a redundant approval round trip for explicit publish requests.", + confidence: 0.8, + target_section: "Process", + scope: "section_local", + mutation_operation: "add", + principle: "Honor explicit approval.", + applicability: "Explicit issue creation requests.", + failure_mode: "The workflow asks for approval that the user already supplied.", + preserved_constraints: ["Keep external publishing review-only during evaluation."], + superseded_guidance: [], + uncertainty: ["Execution replay must confirm the effect."], + }), + }), + analytical, + ); + const review = await Effect.runPromise( + Effect.gen(function* () { + const preparation = yield* TraceCandidatePreparation; + return yield* preparation.prepare({ + pattern_id: executionPatternIdForSkill("to-issues"), + candidate_count: 3, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(review).toMatchObject({ + readiness: "review_ready", + evidence: { cohort_entries: 3, resolved_entries: 3 }, + candidate: { body: expect.stringContaining("candidate-2") }, + search: { + requested_candidates: 3, + generated_candidates: 3, + calibrated_candidates: 3, + required_calibration_repetitions: 3, + current_calibration_passed_repetitions: 0, + selected_candidate_id: expect.any(String), + selection_method: "pareto_calibration_frontier", + }, + }); + expect(teacherCalls).toBe(3); + expect(calibratedBodies).toHaveLength(10); + expect(calibratedBodies[0]).toBe(currentBody); + const stored = await Effect.runPromise( + getEvaluationSubmissionDraft(sqlite, review.draft_id ?? "missing"), + ); + const decoded = await Effect.runPromise( + decodePreparedTraceCandidateDraft(JSON.parse(stored?.payload_json ?? "null")), + ); + expect(decoded.schema_version).toBe(2); + if (decoded.schema_version === 2) { + expect(decoded.cohort.pattern.kind).toBe("historical_task_quality"); + expect(decoded.cohort.entries.map((entry) => entry.redacted_task)).not.toContain( + expect.stringContaining("/to-issues"), + ); + expect(decoded.cohort.entries.map((entry) => entry.role)).toEqual([ + "calibration", + "selection", + "audit_holdout", + ]); + expect(decoded.search?.requested_candidates).toBe(3); + expect(decoded.search?.candidate_summaries).toHaveLength(3); + expect(decoded.search?.candidate_summaries.map((entry) => entry.scored_repetitions)).toEqual([ + 3, 3, 1, + ]); + expect(decoded.search?.frontier_candidate_ids.length).toBeGreaterThan(0); + expect(decoded.candidate).not.toBeNull(); + if (decoded.candidate !== null) { + expect(decoded.search?.selected_candidate_id).toBe(decoded.candidate.proposal_id); + } + expect(JSON.stringify(decoded)).not.toContain("error_count"); + expect( + new TextEncoder().encode(JSON.stringify(decoded.cohort.entries)).byteLength, + ).toBeLessThanOrEqual(8_192); + } + + let losingTeacherCalls = 0; + const losingLayer = Layer.provide( + makeTraceCandidatePreparationLayer({ + sqlite, + searchDirs: [join(root, "skills")], + historicalTaskCalibrator: async () => ({ + passed: false, + score: 0.2, + output: "Still missing complete issue drafts.", + feedback: "missing_acceptance_criteria", + process: { input_tokens: 20, output_tokens: 20, wall_time_ms: 20 }, + }), + teacher: async (input) => ({ + schema_version: 1, + proposed_body: `${input.current_body}\n\nlosing-candidate-${++losingTeacherCalls}`, + rationale: "A bounded candidate that does not fix the measured failure.", + confidence: 0.5, + target_section: "Process", + scope: "section_local", + mutation_operation: "add", + principle: "Try a minimal instruction change.", + applicability: "Issue drafting requests.", + failure_mode: "The candidate does not improve the calibration task.", + preserved_constraints: ["Keep evaluation review-only."], + superseded_guidance: [], + uncertainty: ["Calibration is expected to reject this candidate."], + }), + }), + analytical, + ); + const noWinner = await Effect.runPromise( + Effect.gen(function* () { + const preparation = yield* TraceCandidatePreparation; + return yield* preparation.prepare({ + pattern_id: executionPatternIdForSkill("to-issues"), + candidate_count: 2, + calibration_repetitions: 3, + }); + }).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), + candidate: null, + search: { + requested_candidates: 2, + generated_candidates: 2, + calibrated_candidates: 2, + required_calibration_repetitions: 3, + current_calibration_passed_repetitions: 0, + frontier_candidate_ids: [], + selected_candidate_id: null, + candidate_summaries: [ + expect.objectContaining({ + calibration_passed: false, + scored_repetitions: 1, + passed_repetitions: 0, + frontier_member: false, + selected: false, + }), + expect.objectContaining({ + calibration_passed: false, + scored_repetitions: 1, + passed_repetitions: 0, + frontier_member: false, + selected: false, + }), + ], + }, + }); + const noWinnerStored = await Effect.runPromise( + getEvaluationSubmissionDraft(sqlite, noWinnerDraftId ?? "missing"), + ); + const noWinnerDecoded = await Effect.runPromise( + decodePreparedTraceCandidateDraft(JSON.parse(noWinnerStored?.payload_json ?? "null")), + ); + expect(noWinnerDecoded.schema_version).toBe(2); + if (noWinnerDecoded.schema_version === 2) { + expect(noWinnerDecoded.candidate).toBeNull(); + expect(noWinnerDecoded.search?.selected_candidate_id).toBeNull(); + expect(noWinnerDecoded.search?.candidate_summaries).toHaveLength(2); + } + sqlite.close(); + }); +}); diff --git a/apps/local/tests/hosted-state.test.ts b/apps/local/tests/hosted-state.test.ts new file mode 100644 index 00000000..82bbae4c --- /dev/null +++ b/apps/local/tests/hosted-state.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; + +import type { LibrarySnapshot } from "@selftune/runtime/dashboard-contract"; +import { makeHostedStateOperations } from "../src/hosted-state.js"; + +const library: LibrarySnapshot = { + generatedAt: "2026-08-26T10:00:00.000Z", + counts: { total: 1, active: 1, library: 0, draft: 0, archived: 0 }, + skills: [ + { + skillId: "research", + name: "Research", + lifecycle: "active", + revisions: [{ contentHash: "sha256:revision", locations: [] }], + locations: [ + { + sourceKind: "installed", + packagePath: "/private/skills/research", + skillPath: "/private/skills/research/SKILL.md", + harness: "codex", + scope: "project", + projectRoot: "/private/project", + active: true, + modifiedAt: "2026-08-26T10:00:00.000Z", + lastUsedAt: null, + origin: null, + updateStatus: "available", + }, + ], + lastUsedAt: null, + lastModifiedAt: "2026-08-26T10:00:00.000Z", + origins: [], + updateStatus: "available", + }, + ], +}; + +describe("Desktop hosted-state adapter", () => { + test("publishes only a privacy-safe manifest through the linked device credential", async () => { + let request: Request | null = null; + const operations = makeHostedStateOperations("/config", () => library, { + loadConfig: () => ({ + version: 2, + url: "https://cloud.selftune.dev", + apiKey: "st_live_secret", + preferences: { + releasedSkills: true, + drafts: false, + skillSets: true, + metadata: true, + decisionHistory: false, + }, + credentialProvider: "file", + }), + deviceName: () => "Daniel's Mac", + platform: () => "darwin", + fetch: async (input, init) => { + request = new Request(input, init); + return Response.json({ uploaded: 1, unchanged: 0 }); + }, + }); + + await expect(operations.sync()).resolves.toEqual({ + uploaded: 1, + unchanged: 0, + }); + expect(request).not.toBeNull(); + if (request === null) throw new Error("Expected hosted-state request"); + expect(request.url).toBe("https://cloud.selftune.dev/api/v1/desktop/manifest"); + expect(request.headers.get("authorization")).toBe("Bearer st_live_secret"); + const body = await request.json(); + expect(body.skills).toEqual([ + { + identity: "research", + revision_hash: "sha256:revision", + scope: "project", + connections: ["codex"], + update_status: "available", + usage_status: "none", + }, + ]); + expect(JSON.stringify(body)).not.toContain("/private/"); + expect(JSON.stringify(body)).not.toContain("SKILL.md"); + }); + + test("uploads an explicit package and returns a secure Cloud link", async () => { + const requests: Request[] = []; + const operations = makeHostedStateOperations("/config", () => library, { + loadConfig: () => ({ + version: 2, + url: "https://cloud.selftune.dev", + apiKey: "st_live_secret", + preferences: { + releasedSkills: false, + drafts: false, + skillSets: false, + metadata: false, + decisionHistory: false, + }, + credentialProvider: "file", + }), + packageForShare: async () => ({ + bytes: new TextEncoder().encode("portable skill package"), + label: "research", + }), + fetch: async (input, init) => { + const request = new Request(input, init); + requests.push(request); + if (request.url.endsWith("/share/upload")) + return Response.json({ + upload_url: "https://upload.example/package", + }); + if (request.url === "https://upload.example/package") + return Response.json({ storageId: "storage_123" }); + return Response.json({ + share_id: "share_123", + share_url: "https://cloud.selftune.dev/share/claim_token", + expires_at: Date.parse("2026-08-27T10:00:00.000Z"), + }); + }, + }); + + await expect( + operations.share({ + skillId: "research", + snapshotId: "local", + artifactId: "research", + mode: "private_single_claim", + delivery: "copy_link", + }), + ).resolves.toEqual({ + shareId: "share_123", + mode: "private_single_claim", + delivery: "copy_link", + shareUrl: "https://cloud.selftune.dev/share/claim_token", + expiresAt: "2026-08-27T10:00:00.000Z", + }); + expect(requests.map((request) => request.url)).toEqual([ + "https://cloud.selftune.dev/api/v1/desktop/share/upload", + "https://upload.example/package", + "https://cloud.selftune.dev/api/v1/desktop/share/issue", + ]); + expect(requests[2]?.headers.get("authorization")).toBe("Bearer st_live_secret"); + }); +}); diff --git a/apps/local/tests/library-transfer-routes.test.ts b/apps/local/tests/library-transfer-routes.test.ts deleted file mode 100644 index 0e7d2b66..00000000 --- a/apps/local/tests/library-transfer-routes.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import * as Effect from "effect/Effect"; -import * as ManagedRuntime from "effect/ManagedRuntime"; - -import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; -import { handleDashboardApplicationRoute } from "../src/routes/application.js"; - -const origin = "http://127.0.0.1:3141"; - -describe("library transfer routes", () => { - test("shares the exact immutable backup artifact for a catalog skill ID", async () => { - const shareInputs: unknown[] = []; - const runtime = ManagedRuntime.make( - makeDashboardOperationsLayer({ - remoteLibrarySkillBackup: (skillId) => ({ - snapshot: { - snapshotId: "snapshot-1", - parentSnapshotId: null, - createdAt: "2026-07-30T00:00:00.000Z", - artifacts: [ - { - artifactId: "backup-skill/Another Skill/revision-other", - artifactType: "skill_revision", - objectHash: "object-other", - revisionHash: "revision-other", - updatedAt: "2026-07-30T00:00:00.000Z", - }, - { - artifactId: "backup-skill/Code Reviewer/revision-1", - artifactType: "skill_revision", - objectHash: "object-1", - revisionHash: "revision-1", - updatedAt: "2026-07-30T00:00:00.000Z", - }, - ], - }, - uploaded: 1, - unchanged: 0, - syncedArtifacts: [ - { - artifactId: "backup-skill/Code Reviewer/revision-1", - artifactType: "skill_revision", - objectHash: "object-1", - revisionHash: "revision-1", - updatedAt: "2026-07-30T00:00:00.000Z", - }, - ], - subject: { - skillId, - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/revision-1", - }, - }), - remoteLibraryShareAction: (_action, input) => { - shareInputs.push(input); - return { - shareId: "share-1", - mode: "reusable_unlisted", - delivery: "copy_link", - shareUrl: "https://cloud.selftune.dev/share/share-1", - expiresAt: "2026-08-06T00:00:00.000Z", - }; - }, - }), - ); - const request = new Request(`${origin}/api/v2/library/share`, { - method: "POST", - headers: { Origin: origin, "Content-Type": "application/json" }, - body: JSON.stringify({ - skill_id: "code reviewer", - mode: "reusable_unlisted", - delivery: "copy_link", - }), - }); - - try { - const response = await runtime.runPromise( - Effect.gen(function* () { - yield* DashboardOperations; - return yield* handleDashboardApplicationRoute(request, new URL(request.url), { - allowedOrigins: new Set([origin]), - }); - }), - ); - - expect(response?.status).toBe(200); - expect(shareInputs).toEqual([ - { - skillId: "code reviewer", - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/revision-1", - mode: "reusable_unlisted", - delivery: "copy_link", - }, - ]); - } finally { - await runtime.dispose(); - } - }); - - test("rejects mismatched and partial backup references before sharing", async () => { - let shareCalls = 0; - const cases = [ - { - label: "mismatched", - skillId: "mismatched request", - subject: { - skillId: "another skill", - snapshotId: "snapshot-1", - artifactId: "backup-skill/Code Reviewer/revision-1", - }, - snapshot: { - snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "backup-skill/Code Reviewer/revision-1", - artifactType: "skill_revision", - }, - ], - }, - }, - { - label: "partial", - skillId: "partial request", - subject: { - skillId: "partial request", - snapshotId: "snapshot-1", - }, - snapshot: { - snapshotId: "snapshot-1", - artifacts: [ - { - artifactId: "backup-skill/Code Reviewer/revision-1", - artifactType: "skill_revision", - }, - ], - }, - }, - ]; - const runtime = ManagedRuntime.make( - makeDashboardOperationsLayer({ - remoteLibrarySkillBackup: (skillId) => - cases.find((candidate) => candidate.skillId === skillId), - remoteLibraryShareAction: () => { - shareCalls += 1; - return { shareId: "unexpected" }; - }, - }), - ); - - try { - const results = await Promise.all( - cases.map(async ({ label, skillId }) => { - const request = new Request(`${origin}/api/v2/library/share`, { - method: "POST", - headers: { Origin: origin, "Content-Type": "application/json" }, - body: JSON.stringify({ - skill_id: skillId, - mode: "reusable_unlisted", - delivery: "copy_link", - }), - }); - const response = await runtime.runPromise( - Effect.gen(function* () { - yield* DashboardOperations; - return yield* handleDashboardApplicationRoute(request, new URL(request.url), { - allowedOrigins: new Set([origin]), - }); - }), - ); - return { label, response, payload: await response?.json() }; - }), - ); - for (const { label, response, payload } of results) { - expect(response?.status, label).toBe(400); - expect(payload, label).toMatchObject({ - error: { code: "MISSING_FLAG" }, - }); - } - expect(shareCalls).toBe(0); - } finally { - await runtime.dispose(); - } - }); - - test("rejects email and private-claim issuance before invoking the remote", async () => { - let calls = 0; - const runtime = ManagedRuntime.make( - makeDashboardOperationsLayer({ - remoteLibraryShareAction: () => { - calls += 1; - return { shareId: "unexpected" }; - }, - }), - ); - const unsupported = [ - { - path: "/api/v2/library/share", - body: { - skill_id: "research", - mode: "private_single_claim", - delivery: "email", - recipient_email: "recipient@example.test", - }, - }, - { - path: "/api/v2/library/share", - body: { - skill_id: "research", - mode: "private_single_claim", - delivery: "copy_link", - }, - }, - { - path: "/api/v2/skill-sets/share", - body: { - set_id: "research-set", - mode: "private_single_claim", - delivery: "email", - recipient_email: "recipient@example.test", - }, - }, - { - path: "/api/v2/skill-sets/share", - body: { - set_id: "research-set", - mode: "private_single_claim", - delivery: "copy_link", - }, - }, - ]; - - try { - const results = await Promise.all( - unsupported.map(async ({ path, body }) => { - const request = new Request(`${origin}${path}`, { - method: "POST", - headers: { Origin: origin, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const response = await runtime.runPromise( - Effect.gen(function* () { - yield* DashboardOperations; - return yield* handleDashboardApplicationRoute(request, new URL(request.url), { - allowedOrigins: new Set([origin]), - }); - }), - ); - return { response, payload: await response?.json() }; - }), - ); - for (const { response, payload } of results) { - expect(response?.status).toBe(400); - expect(payload).toMatchObject({ - error: { code: "MISSING_FLAG" }, - }); - } - expect(calls).toBe(0); - } finally { - await runtime.dispose(); - } - }); -}); diff --git a/apps/local/tests/otlp-dashboard-runtime.test.ts b/apps/local/tests/otlp-dashboard-runtime.test.ts index 19323759..478b4346 100644 --- a/apps/local/tests/otlp-dashboard-runtime.test.ts +++ b/apps/local/tests/otlp-dashboard-runtime.test.ts @@ -106,6 +106,30 @@ async function countRows(databasePath: string, table: string): Promise { } } +async function countRowsInChild(databasePath: string, table: string): Promise { + const script = [ + 'import { DuckDBInstance } from "@duckdb/node-api";', + "const instance = await DuckDBInstance.create(process.argv[1], { access_mode: 'READ_ONLY' });", + "const connection = await instance.connect();", + "const reader = await connection.runAndReadAll(`SELECT count(*) AS count FROM ${process.argv[2]}`);", + "process.stdout.write(String(reader.getRowObjects()[0]?.count ?? 0));", + "connection.closeSync();", + "instance.closeSync();", + ].join("\n"); + const processHandle = Bun.spawn([process.execPath, "-e", script, databasePath, table], { + cwd: join(import.meta.dir, "../../.."), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + processHandle.exited, + new Response(processHandle.stdout).text(), + new Response(processHandle.stderr).text(), + ]); + if (exitCode !== 0) throw new Error(stderr.trim() || `DuckDB reader exited ${exitCode}`); + return Number(stdout.trim()); +} + test("imports authenticated loopback OTLP trace and log exports through the shared runtime", async () => { const configDirectory = mkdtempSync(join(tmpdir(), "selftune-otlp-dashboard-")); directories.push(configDirectory); @@ -159,6 +183,41 @@ test("imports authenticated loopback OTLP trace and log exports through the shar } }); +test("does not pin the DuckDB writer lock between OTLP requests", async () => { + const configDirectory = mkdtempSync(join(tmpdir(), "selftune-otlp-dashboard-lock-")); + directories.push(configDirectory); + const server = await startDashboardServer({ + port: 0, + host: "127.0.0.1", + authToken: AUTH_TOKEN, + openBrowser: false, + manageProcessSignals: false, + skillSetConfigRoot: configDirectory, + }); + servers.push(server); + const paths = resolveSelftunePaths({ + environment: { SELFTUNE_CONFIG_DIR: configDirectory }, + homeDirectory: configDirectory, + }); + + expect(await countRowsInChild(paths.localAnalyticsPath, "observability_ingested_batches")).toBe( + 0, + ); + + const response = await fetch(`http://127.0.0.1:${server.port}/v1/traces`, { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify(otlpTrace()), + }); + expect(response.status).toBe(200); + expect(await countRowsInChild(paths.localAnalyticsPath, "observability_ingested_batches")).toBe( + 1, + ); +}); + test("does not expose OTLP without loopback authentication", async () => { const configDirectory = mkdtempSync(join(tmpdir(), "selftune-otlp-dashboard-disabled-")); directories.push(configDirectory); diff --git a/apps/local/tests/plugin-inventory.test.ts b/apps/local/tests/plugin-inventory.test.ts new file mode 100644 index 00000000..88b6db51 --- /dev/null +++ b/apps/local/tests/plugin-inventory.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + discoverPluginInventory, + managePluginInstallation, + type PluginInventoryRuntime, +} from "../src/plugin-inventory.js"; + +function fixtureRuntime(calls: string[]): PluginInventoryRuntime { + let claudeEnabled = true; + let codexInstalled = true; + return { + which: (command) => `/tools/${command}`, + now: () => new Date("2026-08-11T09:30:00.000Z"), + run: (command, args) => { + const invocation = [command, ...args].join(" "); + calls.push(invocation); + if (args.join(" ") === "plugin list --json") { + if (command.endsWith("claude")) { + return { + exitCode: 0, + stdout: JSON.stringify([ + { + id: "paper-desktop@paper", + version: "0.1.0", + scope: "user", + enabled: claudeEnabled, + }, + { + id: "research@selftune-123", + version: "0.0.0-selftune.abc", + scope: "user", + enabled: true, + }, + ]), + stderr: "", + }; + } + return { + exitCode: 0, + stdout: JSON.stringify({ + installed: codexInstalled + ? [ + { + pluginId: "paper-desktop@paper", + marketplaceName: "paper", + version: "0.2.0", + enabled: true, + source: { source: "local" }, + }, + ] + : [], + }), + stderr: "", + }; + } + if (args[1] === "disable") claudeEnabled = false; + if (args[1] === "remove") codexInstalled = false; + return { exitCode: 0, stdout: "{}", stderr: "" }; + }, + }; +} + +describe("plugin inventory", () => { + test("groups the same plugin across hosts and marks receipt-owned installs", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-inventory-")); + const calls: string[] = []; + try { + mkdirSync(join(root, "plugin-installs"), { recursive: true }); + writeFileSync( + join(root, "plugin-installs", "research.json"), + JSON.stringify({ hosts: [{ pluginId: "research@selftune-123" }] }), + ); + const inventory = discoverPluginInventory({ configRoot: root }, fixtureRuntime(calls)); + expect(inventory.totalPlugins).toBe(2); + expect(inventory.managedPlugins).toBe(1); + expect(inventory.hosts.map((host) => host.installedCount)).toEqual([2, 1]); + expect( + inventory.plugins.find((plugin) => plugin.pluginId === "paper-desktop@paper"), + ).toMatchObject({ + versionDrift: true, + installations: [{ host: "claude" }, { host: "codex" }], + }); + expect( + inventory.plugins.find((plugin) => plugin.pluginId === "research@selftune-123"), + ).toMatchObject({ managedBySelfTune: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses host-supported actions and preserves Claude plugin data on removal", () => { + const calls: string[] = []; + const runtime = fixtureRuntime(calls); + const disabled = managePluginInstallation( + { host: "claude", pluginId: "paper-desktop@paper", action: "disable" }, + {}, + runtime, + ); + expect(disabled.inventory.plugins[0]?.installations[0]?.enabled).toBe(false); + managePluginInstallation( + { host: "claude", pluginId: "paper-desktop@paper", action: "remove" }, + {}, + runtime, + ); + managePluginInstallation( + { host: "codex", pluginId: "paper-desktop@paper", action: "remove" }, + {}, + runtime, + ); + expect(calls).toContain("/tools/claude plugin disable paper-desktop@paper --scope user"); + expect(calls).toContain( + "/tools/claude plugin uninstall paper-desktop@paper --keep-data --scope user", + ); + expect(calls).toContain("/tools/codex plugin remove paper-desktop@paper --json"); + }); + + test("reports unavailable hosts without failing the whole inventory", () => { + const runtime: PluginInventoryRuntime = { + which: () => null, + now: () => new Date("2026-08-11T09:30:00.000Z"), + run: () => ({ exitCode: 1, stdout: "", stderr: "not available" }), + }; + const inventory = discoverPluginInventory({}, runtime); + expect(inventory.plugins).toEqual([]); + expect(inventory.hosts.map((host) => host.status)).toEqual(["unavailable", "unavailable"]); + }); +}); diff --git a/apps/local/tests/plugin-routes.test.ts b/apps/local/tests/plugin-routes.test.ts new file mode 100644 index 00000000..3852b7cf --- /dev/null +++ b/apps/local/tests/plugin-routes.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import * as ManagedRuntime from "effect/ManagedRuntime"; + +import type { + PluginInventoryModel, + PluginManagementInputModel, +} from "@selftune/dashboard-core/models"; +import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; +import { handleDashboardApplicationRoute } from "../src/routes/application.js"; + +const origin = "http://127.0.0.1:3141"; +const inventory: PluginInventoryModel = { + hosts: [], + plugins: [], + totalPlugins: 0, + managedPlugins: 0, + refreshedAt: "2026-08-11T09:30:00.000Z", +}; + +function routeRequest( + runtime: ManagedRuntime.ManagedRuntime, + path: string, + body?: unknown, + 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) }), + }); + return runtime.runPromise( + handleDashboardApplicationRoute(request, new URL(request.url), { + allowedOrigins: new Set([origin]), + }), + ); +} + +describe("Plugin management application routes", () => { + test("loads inventory and maps an explicit host action", async () => { + const calls: PluginManagementInputModel[] = []; + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + pluginInventoryLoader: () => inventory, + pluginManager: (input) => { + calls.push(input); + return { + ...input, + completedAt: "2026-08-11T09:31:00.000Z", + inventory, + }; + }, + }), + ); + + try { + const loaded = await routeRequest(runtime, "/api/v2/plugins"); + const managed = await routeRequest(runtime, "/api/v2/plugins/manage", { + host: "claude", + plugin_id: "paper-desktop@paper", + action: "disable", + }); + + expect(await loaded?.json()).toEqual(inventory); + expect(managed?.status).toBe(200); + expect(calls).toEqual([ + { host: "claude", pluginId: "paper-desktop@paper", action: "disable" }, + ]); + } finally { + await runtime.dispose(); + } + }); + + test("rejects a plugin mutation without same-origin proof", async () => { + let called = false; + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + pluginManager: (input) => { + called = true; + return { ...input, completedAt: "2026-08-11T09:31:00.000Z", inventory }; + }, + }), + ); + + try { + const response = await routeRequest( + runtime, + "/api/v2/plugins/manage", + { host: "codex", plugin_id: "paper-desktop@paper", action: "remove" }, + false, + ); + expect(response?.status).toBe(403); + expect(called).toBe(false); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/apps/local/tests/proactive-correction-e2-service.test.ts b/apps/local/tests/proactive-correction-e2-service.test.ts index 31fa1882..c74c8deb 100644 --- a/apps/local/tests/proactive-correction-e2-service.test.ts +++ b/apps/local/tests/proactive-correction-e2-service.test.ts @@ -235,6 +235,19 @@ describe("proactive missed-correction E2 coordinator", () => { ); expect(headingResult.status).toBe("review_ready"); + const insertedLine = { + ...input(), + candidate: { + ...input().candidate, + installed_body: "Keep the first rule.\nKeep the last rule.", + proposed_body: "Keep the first rule.\nAdd one focused rule.\nKeep the last rule.", + }, + }; + const insertedLineResult = await Effect.runPromise( + runProactiveCorrectionE2(insertedLine, executor("selected"), persistence()), + ); + expect(insertedLineResult.status).toBe("review_ready"); + const invalidVerifier = { ...input(), protocol: { diff --git a/apps/local/tests/skill-set-plugin-install.test.ts b/apps/local/tests/skill-set-plugin-install.test.ts new file mode 100644 index 00000000..742f7b3e --- /dev/null +++ b/apps/local/tests/skill-set-plugin-install.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createSkillSet } from "@selftune/library"; + +import { + installSkillSetPlugin, + previewSkillSetPluginInstall, + type PluginInstallRuntime, +} from "../src/skill-set-plugin-install.js"; + +function fixture(root: string) { + const packagePath = join(root, "skills", "research"); + mkdirSync(packagePath, { recursive: true }); + writeFileSync( + join(packagePath, "SKILL.md"), + "---\nname: research\ndescription: Research carefully.\n---\n\n# Research\n", + ); + const configRoot = join(root, "config"); + const manifest = createSkillSet( + { + name: "Research team", + description: "Shared research workflows", + harnesses: ["claude_code", "codex"], + skills: [{ name: "research", package_path: packagePath }], + }, + { configRoot }, + ); + return { configRoot, manifest }; +} + +function runtime( + calls: string[], + installed: { readonly claude?: string; readonly codex?: string } = {}, + marketplaceRoot?: string, +): PluginInstallRuntime { + return { + which: (command) => `/tools/${command}`, + now: () => new Date("2026-08-09T12:00:00.000Z"), + run: (command, args) => { + const invocation = [command, ...args].join(" "); + calls.push(invocation); + if (args.join(" ") === "plugin list --json") { + if (command.endsWith("claude")) { + return { + exitCode: 0, + stdout: JSON.stringify( + installed.claude + ? [{ id: "research-team@selftune-placeholder", version: installed.claude }] + : [], + ), + stderr: "", + }; + } + return { + exitCode: 0, + stdout: JSON.stringify({ + installed: installed.codex + ? [{ pluginId: "research-team@selftune-placeholder", version: installed.codex }] + : [], + }), + stderr: "", + }; + } + if (args.join(" ") === "plugin marketplace list --json") { + if (!marketplaceRoot) { + return { + exitCode: 0, + stdout: command.endsWith("claude") ? "[]" : '{"marketplaces":[]}', + stderr: "", + }; + } + const name = marketplaceRoot.split("/").at(-1); + return { + exitCode: 0, + stdout: command.endsWith("claude") + ? JSON.stringify([{ name, path: marketplaceRoot }]) + : JSON.stringify({ marketplaces: [{ name, root: marketplaceRoot }] }), + stderr: "", + }; + } + return { exitCode: 0, stdout: "{}", stderr: "" }; + }, + }; +} + +describe("Skill Set native plugin installation", () => { + test("materializes one local marketplace and delegates installation to both host CLIs", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-install-")); + const calls: string[] = []; + try { + const { configRoot, manifest } = fixture(root); + const preview = previewSkillSetPluginInstall(manifest.set_id, { configRoot }, runtime(calls)); + expect(preview.hosts.map((host) => host.status)).toEqual(["ready", "ready"]); + + const receipt = installSkillSetPlugin( + { + setId: manifest.set_id, + expectedRevisionHash: preview.revisionHash, + hosts: ["claude", "codex"], + }, + { configRoot }, + runtime(calls), + ); + + const marketplaceRoot = join(configRoot, "plugin-marketplaces", preview.marketplaceName); + expect(receipt.hosts.map((host) => host.result)).toEqual(["installed", "installed"]); + expect(existsSync(join(marketplaceRoot, ".claude-plugin", "marketplace.json"))).toBe(true); + expect( + existsSync( + join(marketplaceRoot, "plugins", "research-team", "skills", "research", "SKILL.md"), + ), + ).toBe(true); + expect( + JSON.parse( + readFileSync( + join(marketplaceRoot, "plugins", "research-team", ".codex-plugin", "plugin.json"), + "utf8", + ), + ).version, + ).toBe(preview.pluginVersion); + expect(calls.some((call) => call.includes("claude plugin marketplace add"))).toBe(true); + expect(calls.some((call) => call.includes("claude plugin install"))).toBe(true); + expect(calls.some((call) => call.includes("codex plugin marketplace add"))).toBe(true); + expect(calls.some((call) => call.includes("codex plugin add"))).toBe(true); + expect( + existsSync(join(configRoot, "plugin-installs", `${preview.marketplaceName}.json`)), + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects stale confirmation before writing the marketplace", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-stale-")); + const calls: string[] = []; + try { + const { configRoot, manifest } = fixture(root); + expect(() => + installSkillSetPlugin( + { + setId: manifest.set_id, + expectedRevisionHash: "0".repeat(64), + hosts: ["claude"], + }, + { configRoot }, + runtime(calls), + ), + ).toThrow(/changed after the install review/); + expect(existsSync(join(configRoot, "plugin-marketplaces"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("reports an unavailable host without attempting installation", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-host-")); + try { + const { configRoot, manifest } = fixture(root); + const unavailableRuntime: PluginInstallRuntime = { + which: () => null, + now: () => new Date("2026-08-09T12:00:00.000Z"), + run: () => ({ exitCode: 1, stdout: "", stderr: "not available" }), + }; + const preview = previewSkillSetPluginInstall( + manifest.set_id, + { configRoot }, + unavailableRuntime, + ); + expect(preview.hosts.every((host) => host.status === "unavailable")).toBe(true); + expect(() => + installSkillSetPlugin( + { + setId: manifest.set_id, + expectedRevisionHash: preview.revisionHash, + hosts: ["codex"], + }, + { configRoot }, + unavailableRuntime, + ), + ).toThrow(/not installed on this machine/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/local/tests/team-collaboration-routes.test.ts b/apps/local/tests/team-collaboration-routes.test.ts new file mode 100644 index 00000000..c9d1357f --- /dev/null +++ b/apps/local/tests/team-collaboration-routes.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import * as ManagedRuntime from "effect/ManagedRuntime"; + +import { DashboardOperations, makeDashboardOperationsLayer } from "../src/dashboard-operations.js"; +import { handleDashboardApplicationRoute } from "../src/routes/application.js"; + +const origin = "http://127.0.0.1:3141"; +const snapshot = { entries: [], contributions: [], installations: [] }; + +function routeRequest( + runtime: ManagedRuntime.ManagedRuntime, + path: string, + method: "GET" | "PATCH" | "POST" = "GET", + body?: unknown, + 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) }), + }); + return runtime.runPromise( + handleDashboardApplicationRoute(request, new URL(request.url), { + allowedOrigins: new Set([origin]), + }), + ); +} + +describe("Team collaboration application routes", () => { + test("maps access, snapshot, rollout, and review actions through DashboardOperations", async () => { + const calls: Array<{ action: string; id?: string; policy?: string }> = []; + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + teamCollaborationAccessLoader: () => ({ currentRole: "admin", readOnly: false }), + teamCollaborationSnapshotLoader: () => snapshot, + teamCollaborationRolloutPolicyUpdater: (entryId, policy) => { + calls.push({ action: "rollout", id: entryId, policy }); + return { entryId, policy }; + }, + teamCollaborationContributionDecider: (contributionId, action) => { + calls.push({ action, id: contributionId }); + return { id: contributionId, status: action === "rollback" ? "rolled_back" : "adopted" }; + }, + }), + ); + + try { + const access = await routeRequest(runtime, "/api/v2/team-collaboration/access"); + const loaded = await routeRequest(runtime, "/api/v2/team-collaboration"); + const rollout = await routeRequest( + runtime, + "/api/v2/team-collaboration/registry/entry%2Fone/rollout-policy", + "PATCH", + { policy: "automatic" }, + ); + const adopt = await routeRequest( + runtime, + "/api/v2/team-collaboration/contributions/candidate%20one/adopt", + "POST", + ); + const reject = await routeRequest( + runtime, + "/api/v2/team-collaboration/contributions/candidate%20one/reject", + "POST", + ); + const rollback = await routeRequest( + runtime, + "/api/v2/team-collaboration/contributions/candidate%20one/rollback", + "POST", + ); + + expect(await access?.json()).toEqual({ currentRole: "admin", readOnly: false }); + expect(await loaded?.json()).toEqual(snapshot); + for (const response of [rollout, adopt, reject, rollback]) { + expect(response?.status).toBe(200); + } + expect(calls).toEqual([ + { action: "rollout", id: "entry/one", policy: "automatic" }, + { action: "adopt", id: "candidate one" }, + { action: "reject", id: "candidate one" }, + { action: "rollback", id: "candidate one" }, + ]); + } finally { + await runtime.dispose(); + } + }); + + test("fails closed before invoking a collaboration mutation without same-origin proof", async () => { + let called = false; + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + teamCollaborationContributionDecider: (id) => { + called = true; + return { id, status: "adopted" }; + }, + }), + ); + + try { + const response = await routeRequest( + runtime, + "/api/v2/team-collaboration/contributions/candidate/adopt", + "POST", + undefined, + false, + ); + expect(response?.status).toBe(403); + expect(called).toBe(false); + } finally { + await runtime.dispose(); + } + }); + + test("validates rollout policy before invoking the Cloud transport", async () => { + let called = false; + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + teamCollaborationRolloutPolicyUpdater: (entryId, policy) => { + called = true; + return { entryId, policy }; + }, + }), + ); + + try { + const response = await routeRequest( + runtime, + "/api/v2/team-collaboration/registry/entry/rollout-policy", + "PATCH", + { policy: "surprise" }, + ); + expect(response?.status).toBe(400); + expect(called).toBe(false); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/apps/local/tests/trace-candidate-route.test.ts b/apps/local/tests/trace-candidate-route.test.ts index 98eb331e..6c38fbe0 100644 --- a/apps/local/tests/trace-candidate-route.test.ts +++ b/apps/local/tests/trace-candidate-route.test.ts @@ -53,4 +53,47 @@ describe("trace candidate preparation route", () => { }, }); }); + + test("fails closed when historical evaluation has no managed replay harness", async () => { + const routes = createTraceCandidateRoutes({ prepare: async () => ({}) }); + 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" }), + }), + url, + allowedOrigins, + ); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ + error: { + code: "HISTORICAL_REPLAY_UNAVAILABLE", + message: "No managed replay harness is registered for historical evaluation.", + }, + }); + }); + + test("delegates historical evaluation to the registered product service", async () => { + const routes = createTraceCandidateRoutes({ + prepare: async () => ({}), + evaluate: async (input) => ({ status: "review_ready", input }), + }); + 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" }), + }), + url, + allowedOrigins, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ + status: "review_ready", + input: { pattern_id: "execution-pattern-test" }, + }); + }); }); diff --git a/apps/local/tests/trace-candidate-threshold.test.ts b/apps/local/tests/trace-candidate-threshold.test.ts new file mode 100644 index 00000000..73e5b877 --- /dev/null +++ b/apps/local/tests/trace-candidate-threshold.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { + boundedHistoricalTask, + supportsContrastiveRepeatedErrorPattern, +} from "../src/trace-candidate-service.js"; + +describe("historical trace pattern threshold", () => { + test("retains repeated failures when a mature skill has many successful traces", () => { + expect( + supportsContrastiveRepeatedErrorPattern({ uniqueTraceCount: 98, errorTraceCount: 4 }), + ).toBe(true); + }); + + test("requires repeated failures and a successful counterexample", () => { + expect( + supportsContrastiveRepeatedErrorPattern({ uniqueTraceCount: 3, errorTraceCount: 1 }), + ).toBe(false); + expect( + supportsContrastiveRepeatedErrorPattern({ uniqueTraceCount: 3, errorTraceCount: 3 }), + ).toBe(false); + expect( + supportsContrastiveRepeatedErrorPattern({ uniqueTraceCount: 3, errorTraceCount: 2 }), + ).toBe(true); + }); +}); + +describe("historical task boundary", () => { + test("rejects evaluator wrappers and bounds redacted user tasks by bytes", () => { + expect( + boundedHistoricalTask( + "The following is the Codex agent history whose request action you are assessing. transcript", + ), + ).toBeNull(); + expect(boundedHistoricalTask("# AGENTS.md instructions for /Users/daniel/project")).toBeNull(); + + const task = boundedHistoricalTask( + `create issues /to-issues token=super-secret /Users/daniel/project ${"x".repeat(1_000)}`, + ); + expect(task).not.toContain("super-secret"); + expect(task).not.toContain("/Users/daniel"); + expect(new TextEncoder().encode(task ?? "").byteLength).toBeLessThanOrEqual(512); + }); + + test("removes an explicit skill invocation without treating it as a local path", () => { + expect(boundedHistoricalTask("create all of them as issues /to-issues", "to-issues")).toBe( + "create all of them as issues", + ); + }); +}); diff --git a/apps/selfhost/CHANGELOG.md b/apps/selfhost/CHANGELOG.md deleted file mode 100644 index 5655d5ff..00000000 --- a/apps/selfhost/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# @selftune/selfhost - -## 0.1.2 - -### Patch Changes - -- [#146](https://github.com/selftune-dev/selftune/pull/146) [`f527f71`](https://github.com/selftune-dev/selftune/commit/f527f7193a8a71a8901545e7a8f23525434c524f) Thanks [@WellDunDun](https://github.com/WellDunDun)! - Use the PragSys Developer ID identity for signed macOS releases and include the - DuckDB native runtime required by the compiled self-host image. - -## 0.1.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/local@1.0.1 - - @selftune/runtime@1.0.1 diff --git a/apps/selfhost/Dockerfile b/apps/selfhost/Dockerfile index d90c6816..94f2e8e6 100644 --- a/apps/selfhost/Dockerfile +++ b/apps/selfhost/Dockerfile @@ -1,17 +1,10 @@ # syntax=docker/dockerfile:1.7 FROM oven/bun:1.3.14 AS build -ARG TARGETARCH WORKDIR /build COPY . . RUN bun install --frozen-lockfile --ignore-scripts RUN bun run build:dashboard -RUN case "$TARGETARCH" in \ - amd64) bun_target="bun-linux-x64" ;; \ - arm64) bun_target="bun-linux-arm64" ;; \ - *) echo "Unsupported self-host architecture: $TARGETARCH" >&2; exit 1 ;; \ - esac && \ - BUN_TARGET="$bun_target" bun run --cwd apps/desktop build:sidecar RUN mkdir -p /out && \ bun build apps/selfhost/src/server.ts \ --compile \ @@ -37,7 +30,6 @@ RUN apt-get update && \ COPY --from=build --chown=selftune:selftune /out/selftune-selfhost /usr/local/bin/selftune-selfhost COPY --from=build --chown=selftune:selftune /build/apps/local-dashboard/dist /app/dashboard -COPY --from=build --chown=selftune:selftune /build/apps/desktop/resources/selftune/node_modules /app/node_modules ENV HOME=/data \ SELFTUNE_CONFIG_DIR=/data/runtime \ @@ -45,7 +37,6 @@ ENV HOME=/data \ SELFTUNE_HOST=0.0.0.0 \ SELFTUNE_PORT=8787 \ SELFTUNE_SPA_DIR=/app/dashboard \ - SELFTUNE_DESKTOP_RESOURCE_DIR=/app \ SELFTUNE_VERSION=${VERSION} USER 10001:10001 diff --git a/apps/selfhost/README.md b/apps/selfhost/README.md index 41e5b74b..e96dfba3 100644 --- a/apps/selfhost/README.md +++ b/apps/selfhost/README.md @@ -1,6 +1,16 @@ # SelfTune Self-Host -SelfTune Self-Host runs the canonical local dashboard and a cloud-compatible Remote Library in one container. SQLite, immutable skill objects, Skill Sets, users, private shares, and audit records all live in one `/data` volume. Raw transcripts are never uploaded. +SelfTune Self-Host is the customer-operated host for SelfTune's non-commerce +collaboration journeys. It runs the canonical local dashboard and Remote +Library in one container. SQLite, immutable skill objects, Skill Sets, users, +private shares, consented contributor signals, device manifests, updates, and +audit records live in one `/data` volume. Raw prompts, transcripts, sessions, +local paths, evaluations, and improvements never sync. + +A deployment belongs to one customer trust boundary. It may have multiple +users, roles, and workspaces. PragSys billing, trials, invoices, public signup, +cross-customer SaaS operations, and vendor fleet administration exist only in +the optional managed service and are not required to operate SelfTune. ## Start diff --git a/apps/selfhost/docker-compose.yml b/apps/selfhost/docker-compose.yml index ab7c2232..fffa1b10 100644 --- a/apps/selfhost/docker-compose.yml +++ b/apps/selfhost/docker-compose.yml @@ -13,7 +13,7 @@ services: env_file: - .env environment: - SELFTUNE_AUTH_TOKEN: ${SELFTUNE_AUTH_TOKEN:?Generate a token with openssl rand -hex 32 and set SELFTUNE_AUTH_TOKEN in .env} + SELFTUNE_AUTH_TOKEN: "SELFTUNE_AUTH_TOKEN_PLACEHOLDER" ports: - "${SELFTUNE_PORT:-8787}:${SELFTUNE_PORT:-8787}" volumes: diff --git a/apps/selfhost/package.json b/apps/selfhost/package.json index 8da05f36..dc400d31 100644 --- a/apps/selfhost/package.json +++ b/apps/selfhost/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/selfhost", - "version": "0.1.2", + "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/apps/selfhost/src/config.ts b/apps/selfhost/src/config.ts index b7eaa5bd..a2c74f23 100644 --- a/apps/selfhost/src/config.ts +++ b/apps/selfhost/src/config.ts @@ -29,6 +29,7 @@ export interface SelfHostConfig { readonly dataDir: string; readonly host: string; readonly maxObjectBytes: number; + readonly packLinkSecret?: string; readonly port: number; readonly publicUrl: string; readonly spaDir: string | undefined; @@ -214,6 +215,12 @@ export const loadSelfHostConfig = Effect.fn("SelfHostConfig.load")(function* ( error instanceof SelfHostConfigFailure ? error : configFailure(String(error)), }); const dataDir = resolve(environment.SELFTUNE_DATA_DIR ?? join(homedir(), ".selftune-selfhost")); + const packLinkSecret = environment.SELFTUNE_PACK_LINK_SECRET?.trim() || adminToken; + if (packLinkSecret.length < 32 || isPlaceholderToken(packLinkSecret)) { + return yield* Effect.fail( + configFailure("SELFTUNE_PACK_LINK_SECRET must contain at least 32 random characters."), + ); + } return { accounts, @@ -222,6 +229,7 @@ export const loadSelfHostConfig = Effect.fn("SelfHostConfig.load")(function* ( dataDir, host, maxObjectBytes, + packLinkSecret, port, publicUrl, spaDir: environment.SELFTUNE_SPA_DIR ? resolve(environment.SELFTUNE_SPA_DIR) : undefined, diff --git a/apps/selfhost/src/contract.ts b/apps/selfhost/src/contract.ts index 0b5eeaed..dc0b7b98 100644 --- a/apps/selfhost/src/contract.ts +++ b/apps/selfhost/src/contract.ts @@ -1,4 +1,9 @@ import * as Schema from "effect/Schema"; +import { + HostedContributorAggregate, + HostedContributorSignal, + HostedManifestRequest, +} from "@selftune/control-plane"; export const RemoteArtifactType = Schema.Literals([ "skill_revision", @@ -47,6 +52,49 @@ export const CreateShareRequest = Schema.Struct({ export type CreateShareRequest = typeof CreateShareRequest.Type; +export const CreatePackRequest = Schema.Struct({ + snapshot_id: Schema.String, + artifact_id: Schema.String, + mode: Schema.Literals(["reusable_unlisted", "private_single_claim"]), + expires_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export type CreatePackRequest = typeof CreatePackRequest.Type; + +export interface SelfHostPackPreview { + readonly protocol: "selftune.skill-set-pack.v1"; + readonly packId: string; + readonly artifactId: string; + readonly name: string; + readonly description: string; + readonly skillSetRevisionSha256: string; + readonly objectSha256: string; + readonly mode: "reusable_unlisted" | "private_single_claim"; + readonly expiresAt: string; + readonly requiresSignIn: false; + readonly components: ReadonlyArray<{ + readonly logicalSkillId: string; + readonly licenseExpression: string; + }>; +} + +export interface SelfHostPackManagementItem { + readonly packId: string; + readonly artifactId: string; + readonly name: string; + readonly description: string; + readonly mode: "reusable_unlisted" | "private_single_claim"; + readonly status: "active" | "claimed" | "expired" | "revoked"; + readonly packUrl: string | null; + readonly expiresAt: string; + readonly createdAt: string; + readonly claimedAt: string | null; + readonly revokedAt: string | null; + readonly skillSetRevisionSha256: string; + readonly objectSha256: string; + readonly componentCount: number; +} + export const SharedSetManifest = Schema.Struct({ skills: Schema.Array(Schema.Struct({ content_hash: Schema.String })), }); @@ -71,6 +119,14 @@ export interface ConfiguredUser { readonly token: string; } +export const ContributorSignalPayload = HostedContributorSignal; +export type ContributorSignalPayload = typeof HostedContributorSignal.Type; + +export const DesktopManifestPayload = HostedManifestRequest; +export type DesktopManifestPayload = typeof HostedManifestRequest.Type; + +export type ContributorSignalAggregate = typeof HostedContributorAggregate.Type; + export type RemoteShareStatus = "pending" | "accepted" | "imported" | "revoked" | "expired"; export interface RemoteShare { diff --git a/apps/selfhost/src/remote-api.test.ts b/apps/selfhost/src/remote-api.test.ts index 986f16e3..faab3341 100644 --- a/apps/selfhost/src/remote-api.test.ts +++ b/apps/selfhost/src/remote-api.test.ts @@ -3,6 +3,12 @@ import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import * as Effect from "effect/Effect"; +import { + encodeCanonicalSkillSetSourceManifest, + encodePortablePackageBundle, + encodePortableSkillSetEnvelope, +} from "@selftune/control-plane"; import type { SelfHostConfig } from "./config.js"; import type { RemoteApiHandle } from "./remote-api.js"; @@ -87,6 +93,210 @@ async function request( } describe("self-hosted Remote Library API", () => { + test("supports the same Desktop state and privacy-safe manifest journey as managed Cloud", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "selftune-selfhost-manifest-")); + temporaryDirectories.push(dataDir); + const handle = makeRemoteApi(config(dataDir)); + handles.push(handle); + const state = await request(handle, "/api/v1/desktop/state", ADMIN_TOKEN); + expect(state.status).toBe(200); + expect(await state.json()).toEqual({ + workspaceId: ADMIN_ORG_ID, + plan: "free", + status: "none", + currentPeriodEnd: null, + }); + const manifest = { + revision: "sha256:manifest", + device_name: "Daniel's Mac", + platform: "darwin-arm64", + skills: [ + { + identity: "research", + revision_hash: "sha256:skill", + scope: "global", + connections: ["codex"], + update_status: "current", + usage_status: "none", + }, + ], + }; + const publish = () => + request(handle, "/api/v1/desktop/manifest", ADMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manifest), + }); + expect(await (await publish()).json()).toEqual({ uploaded: 1, unchanged: 0 }); + expect(await (await publish()).json()).toEqual({ uploaded: 0, unchanged: 1 }); + }); + + test("relays only bounded contributor signals and deduplicates creator aggregates", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "selftune-selfhost-signals-")); + temporaryDirectories.push(dataDir); + const handle = makeRemoteApi(config(dataDir)); + handles.push(handle); + const payload = { + version: 1, + signal_type: "skill_session", + source_key: "0123456789abcdef", + relay_destination: ADMIN_ORG_ID, + skill_hash: "sk_sha256_123456abcdef", + user_cohort: "uc_sha256_123456abcdef", + signals: { triggered: true, execution_grade: "A" }, + timestamp_bucket: "2026-W35", + client_version: "0.4.0", + }; + const relay = () => + request(handle, "/api/v1/contributions/relay", MEMBER_TOKEN, { + method: "POST", + 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"); + const aggregate = await request( + handle, + `/api/v1/contributions/aggregates/${payload.skill_hash}`, + ADMIN_TOKEN, + ); + expect(aggregate.status).toBe(200); + expect(await aggregate.json()).toEqual({ + observations: 1, + cohorts: 1, + triggered: 1, + missed: 0, + grades: { A: 1, B: 0, C: 0, F: 0 }, + }); + }); + + test("issues branded, revocable Pack URLs for one immutable Skill Set envelope", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "selftune-selfhost-pack-")); + temporaryDirectories.push(dataDir); + const handle = makeRemoteApi(config(dataDir)); + handles.push(handle); + + const packageBytes = await Effect.runPromise( + encodePortablePackageBundle({ + files: [ + { + path: "SKILL.md", + content: new TextEncoder().encode( + "---\nname: review\ndescription: Review code\nlicense: MIT\n---\n# Review\n", + ), + }, + ], + }), + ); + const source = await Effect.runPromise( + encodeCanonicalSkillSetSourceManifest({ + skillSetId: "engineering", + name: "Engineering", + description: "Pinned engineering skills", + harnesses: ["codex"], + components: [ + { + ordinal: 0, + logicalSkillId: "review", + sourceRevisionSha256: "1".repeat(64), + sourcePackageObjectSha256: createHash("sha256").update(packageBytes).digest("hex"), + }, + ], + }), + ); + const pack = await Effect.runPromise( + encodePortableSkillSetEnvelope({ + sourceManifestBytes: source.bytes, + components: [ + { + ordinal: 0, + logicalSkillId: "review", + sourceRevisionSha256: "1".repeat(64), + sourcePackageObjectSha256: createHash("sha256").update(packageBytes).digest("hex"), + sealedPackageBytes: packageBytes, + terms: { licenseExpression: "MIT", noticePaths: [] }, + }, + ], + }), + ); + const objectSha256 = createHash("sha256").update(pack.bytes).digest("hex"); + expect( + ( + await request(handle, `/api/v1/remote-library/objects/${objectSha256}`, ADMIN_TOKEN, { + method: "PUT", + headers: { "Content-Type": "application/vnd.selftune.portable-skill-set+json;version=1" }, + body: new Blob([pack.bytes]), + }) + ).status, + ).toBe(201); + const artifactId = `skill-set/engineering/${pack.envelope.skillSetRevisionSha256}`; + const committed = await request(handle, "/api/v1/remote-library/snapshots", ADMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_version: "selftune.remote-library.snapshot.v1", + expected_parent_id: null, + artifacts: [ + { + artifact_id: artifactId, + artifact_type: "skill_set", + object_sha256: objectSha256, + revision: pack.envelope.skillSetRevisionSha256, + metadata: { name: "Engineering" }, + }, + ], + }), + }); + const snapshotId = stringField(field(await committed.json(), "snapshot"), "id"); + const issued = await request(handle, "/api/v1/remote-library/packs", ADMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + snapshot_id: snapshotId, + artifact_id: artifactId, + mode: "reusable_unlisted", + }), + }); + expect(issued.status).toBe(201); + const issuedBody = await issued.json(); + const packId = stringField(issuedBody, "packId"); + const packUrl = new URL(stringField(issuedBody, "packUrl")); + expect(packUrl.origin).toBe(ORIGIN); + expect(packUrl.pathname).toMatch(/^\/p\/[A-Za-z0-9_-]{43}$/); + const token = packUrl.pathname.split("/").at(-1)!; + + const landing = await request(handle, `/p/${token}`); + expect(landing.status).toBe(200); + const landingHtml = await landing.text(); + expect(landingHtml).toContain("Engineering"); + expect(landingHtml).toContain("Open in SelfTune Desktop"); + expect(landingHtml).toContain("review"); + + 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); + + const preview = await request(handle, `/api/v1/public/packs/${token}`); + expect(preview.status).toBe(200); + expect(field(await preview.json(), "objectSha256")).toBe(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); + expect(new Uint8Array(await content.arrayBuffer())).toEqual(pack.bytes); + + expect( + ( + await request(handle, `/api/v1/remote-library/packs/${packId}`, ADMIN_TOKEN, { + method: "DELETE", + }) + ).status, + ).toBe(200); + expect((await request(handle, `/api/v1/public/packs/${token}`)).status).toBe(404); + }); + test("reports repository initialization failures as stable 503 responses", async () => { const parent = mkdtempSync(join(tmpdir(), "selftune-selfhost-invalid-")); temporaryDirectories.push(parent); @@ -127,10 +337,8 @@ describe("self-hosted Remote Library API", () => { const preflight = await request(handle, "/api/v1/remote-library/objects/hash", undefined, { method: "OPTIONS", }); - expect(preflight.status).toBe(503); - expect(field(field(await preflight.json(), "error"), "code")).toBe( - "RemoteLibraryUnavailable", - ); + expect(preflight.status).toBe(204); + expect(await preflight.text()).toBe(""); await new Promise((resolve) => setTimeout(resolve, 0)); expect(unhandledRejections).toEqual([]); diff --git a/apps/selfhost/src/remote-api.ts b/apps/selfhost/src/remote-api.ts index 39a54e8a..80a679c5 100644 --- a/apps/selfhost/src/remote-api.ts +++ b/apps/selfhost/src/remote-api.ts @@ -1,11 +1,15 @@ import * as Effect from "effect/Effect"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Schema from "effect/Schema"; +import { renderSkillSetPackLandingPage, SkillSetPackPreview } from "@selftune/control-plane"; import type { SelfHostConfig } from "./config.js"; import { + CreatePackRequest, CreateShareRequest, CreateSnapshotRequest, + ContributorSignalPayload, + DesktopManifestPayload, isSha256, isUuid, type SelfHostUser, @@ -72,6 +76,52 @@ const decodeShareRequest = Effect.fn("SelfHostApi.decodeShare")(function* (reque ); }); +const decodePackRequest = Effect.fn("SelfHostApi.decodePack")(function* (request: Request) { + const input = yield* Effect.tryPromise({ + try: (): Promise => request.json(), + 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, + }), + ), + ); +}); + +const decodeContribution = Effect.fn("SelfHostApi.decodeContribution")(function* ( + request: Request, +) { + const input = yield* Effect.tryPromise({ + try: (): Promise => request.json(), + 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, + }), + ), + ); +}); + +const decodeDesktopManifest = Effect.fn("SelfHostApi.decodeDesktopManifest")(function* ( + request: Request, +) { + const input = yield* Effect.tryPromise({ + try: (): Promise => request.json(), + 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, + }), + ), + ); +}); + function objectHeaders(object: { readonly contentType: string; readonly sha256: string; @@ -123,7 +173,7 @@ function withCors(response: Response, request: Request, config: SelfHostConfig): const headers = new Headers(response.headers); headers.set("Access-Control-Allow-Origin", origin); headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type"); - headers.set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS"); + headers.set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS"); headers.set("Access-Control-Max-Age", "600"); headers.append("Vary", "Origin"); return new Response(response.body, { @@ -185,6 +235,16 @@ const routeRequest = Effect.fn("SelfHostApi.route")(function* ( ], raw_transcripts_synced: false, max_object_bytes: config.maxObjectBytes, + hosted_state: { + accounts: true, + devices: true, + privacy_safe_manifests: true, + private_sharing: true, + contributor_signals: true, + audit: true, + billing: false, + cross_customer_saas: false, + }, }); } @@ -289,6 +349,35 @@ const routeRequest = Effect.fn("SelfHostApi.route")(function* ( return Response.json({ share }, { status: 201 }); } + if (path === "/packs" && request.method === "POST") { + yield* requireRole(user, "admin"); + const issued = yield* repository.createPack(user, yield* decodePackRequest(request)); + return Response.json( + { + protocol: "selftune.skill-set-pack.v1", + packId: issued.id, + mode: issued.mode, + packUrl: `${config.publicUrl.replace(/\/$/, "")}/p/${issued.token}`, + expiresAt: issued.expiresAt, + skillSetRevisionSha256: issued.skillSetRevisionSha256, + objectSha256: issued.objectSha256, + }, + { status: 201 }, + ); + } + + if (path === "/packs" && request.method === "GET") { + yield* requireRole(user, "viewer"); + return Response.json(yield* repository.listPacks(user)); + } + + const packMatch = path.match(/^\/packs\/([^/]+)$/); + const packId = packMatch?.[1]; + if (packId && request.method === "DELETE") { + yield* requireRole(user, "admin"); + return Response.json(yield* repository.revokePack(user, packId)); + } + const shareMatch = path.match(/^\/shares\/([^/]+)$/); const shareId = shareMatch?.[1]; if (shareId && request.method === "GET") { @@ -322,6 +411,72 @@ const routeRequest = Effect.fn("SelfHostApi.route")(function* ( ); }); +const routeContributionRequest = Effect.fn("SelfHostApi.contributions")(function* ( + request: Request, +) { + const repository = yield* SelfHostRepository; + const token = bearerToken(request); + if (!token) { + return yield* Effect.fail( + failure("AUTH_MISSING", 401, "A valid SelfTune bearer token is required."), + ); + } + const user = yield* repository.authenticate(token); + if (!user) { + return yield* Effect.fail( + failure("AUTH_INVALID", 401, "The SelfTune bearer token is not valid."), + ); + } + const url = new URL(request.url); + if (url.pathname === "/api/v1/contributions/relay" && request.method === "POST") { + const status = yield* repository.receiveContribution(user, yield* decodeContribution(request)); + return Response.json({ status }, { headers: { "Cache-Control": "no-store" } }); + } + const aggregate = /^\/api\/v1\/contributions\/aggregates\/(sk_sha256_[a-f0-9]{12})$/.exec( + url.pathname, + ); + if (aggregate?.[1] && request.method === "GET") { + yield* requireRole(user, "viewer"); + return Response.json(yield* repository.contributionAggregate(user, aggregate[1]), { + headers: { "Cache-Control": "no-store" }, + }); + } + return yield* Effect.fail( + failure("ContributorRouteNotFound", 404, "Contributor signal endpoint not found."), + ); +}); + +const routeHostedStateRequest = Effect.fn("SelfHostApi.hostedState")(function* (request: Request) { + const repository = yield* SelfHostRepository; + const token = bearerToken(request); + if (!token) { + return yield* Effect.fail( + failure("AUTH_MISSING", 401, "A valid SelfTune bearer token is required."), + ); + } + const user = yield* repository.authenticate(token); + if (!user) { + return yield* Effect.fail( + failure("AUTH_INVALID", 401, "The SelfTune bearer token is not valid."), + ); + } + const url = new URL(request.url); + if (url.pathname === "/api/v1/desktop/state" && request.method === "GET") { + return Response.json(yield* repository.hostedState(user), { + headers: { "Cache-Control": "no-store" }, + }); + } + if (url.pathname === "/api/v1/desktop/manifest" && request.method === "POST") { + return Response.json( + yield* repository.publishManifest(user, yield* decodeDesktopManifest(request)), + { headers: { "Cache-Control": "no-store" } }, + ); + } + return yield* Effect.fail( + failure("HostedStateRouteNotFound", 404, "Hosted-state endpoint not found."), + ); +}); + const checkReadiness = Effect.fn("SelfHostApi.checkReadiness")(function* ( accounts: SelfHostConfig["accounts"], ) { @@ -349,6 +504,30 @@ const checkReadiness = Effect.fn("SelfHostApi.checkReadiness")(function* ( } }); +const routePublicPackRequest = Effect.fn("SelfHostApi.publicPack")(function* (request: Request) { + const url = new URL(request.url); + const match = /^\/api\/v1\/public\/packs\/([A-Za-z0-9_-]{43})(\/content)?$/.exec(url.pathname); + if (!match || request.method !== "GET") { + return yield* failure("RemoteLibraryRouteNotFound", 404, "Pack endpoint not found."); + } + const repository = yield* SelfHostRepository; + const token = match[1]!; + if (!match[2]) { + return Response.json(yield* repository.previewPack(token), { + headers: { "Cache-Control": "no-store" }, + }); + } + const content = yield* repository.getPackContent(token); + return new Response(new Blob([Uint8Array.from(content.bytes)]), { + headers: { + "Cache-Control": "no-store", + "Content-Length": String(content.bytes.byteLength), + "Content-Type": content.contentType, + "X-SelfTune-Content-Sha256": content.objectSha256, + }, + }); +}); + export interface RemoteApiHandle { readonly dispose: () => Promise; readonly handle: (request: Request) => Promise; @@ -368,6 +547,19 @@ export function makeRemoteApi(config: SelfHostConfig): RemoteApiHandle { return { async handle(request) { const url = new URL(request.url); + if (request.method === "OPTIONS" && url.pathname.startsWith(`${API_PREFIX}/`)) { + const origin = requestOrigin(request); + if (!origin || !config.allowedOrigins.includes(origin)) { + return errorResponse( + failure( + "RemoteLibraryOriginDenied", + 403, + "Request origin is not allowed by this SelfTune host.", + ), + ); + } + return withCors(new Response(null, { status: 204 }), request, config); + } if (url.pathname === "/healthz" && request.method === "GET") { return Response.json( { ok: true, service: "selftune-selfhost", check: "liveness" }, @@ -395,25 +587,77 @@ export function makeRemoteApi(config: SelfHostConfig): RemoteApiHandle { return unavailable(); } } + const landingMatch = /^\/p\/([A-Za-z0-9_-]{43})$/.exec(url.pathname); + if (landingMatch && request.method === "GET") { + const landingToken = landingMatch[1] ?? ""; + const brandedUrl = `${config.publicUrl.replace(/\/$/, "")}/p/${landingToken}`; + try { + await ready; + const preview = await runtime.runPromise( + Effect.flatMap(SelfHostRepository, (repository) => + repository.previewPack(landingToken), + ), + ); + return new Response( + renderSkillSetPackLandingPage({ + packUrl: brandedUrl, + preview: new SkillSetPackPreview(preview), + }), + { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }, + ); + } catch { + return new Response("Pack unavailable", { + status: 404, + headers: { "Cache-Control": "no-store" }, + }); + } + } + if (url.pathname.startsWith("/api/v1/public/packs/")) { + try { + await ready; + const result = await runtime.runPromise(Effect.result(routePublicPackRequest(request))); + return result._tag === "Failure" ? errorResponse(result.failure) : result.success; + } catch { + return unavailable(); + } + } + if (url.pathname.startsWith("/api/v1/contributions/")) { + try { + await ready; + const result = await runtime.runPromise(Effect.result(routeContributionRequest(request))); + return withCors( + result._tag === "Failure" ? errorResponse(result.failure) : result.success, + request, + config, + ); + } catch { + return withCors(unavailable(), request, config); + } + } + if (url.pathname === "/api/v1/desktop/state" || url.pathname === "/api/v1/desktop/manifest") { + try { + await ready; + const result = await runtime.runPromise(Effect.result(routeHostedStateRequest(request))); + return withCors( + result._tag === "Failure" ? errorResponse(result.failure) : result.success, + request, + config, + ); + } catch { + return withCors(unavailable(), request, config); + } + } if (!url.pathname.startsWith(`${API_PREFIX}/`) && url.pathname !== API_PREFIX) return null; try { await ready; } catch { return withCors(unavailable(), request, config); } - if (request.method === "OPTIONS") { - const origin = requestOrigin(request); - if (!origin || !config.allowedOrigins.includes(origin)) { - return errorResponse( - failure( - "RemoteLibraryOriginDenied", - 403, - "Request origin is not allowed by this SelfTune host.", - ), - ); - } - return withCors(new Response(null, { status: 204 }), request, config); - } try { const result = await runtime.runPromise(Effect.result(routeRequest(request, config))); return withCors( diff --git a/apps/selfhost/src/repository.ts b/apps/selfhost/src/repository.ts index abeea93e..8dab4e28 100644 --- a/apps/selfhost/src/repository.ts +++ b/apps/selfhost/src/repository.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, createHmac, randomUUID } from "node:crypto"; import { closeSync, existsSync, @@ -17,11 +17,16 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import { decodePortableSkillSetEnvelope } from "@selftune/control-plane"; import type { SelfHostConfig } from "./config.js"; import { + type CreatePackRequest, type CreateShareRequest, type CreateSnapshotRequest, + type ContributorSignalAggregate, + type ContributorSignalPayload, + type DesktopManifestPayload, isSha256, isUuid, type RemoteArtifact, @@ -32,6 +37,8 @@ import { type RemoteShareStatus, type RemoteSnapshot, type SelfHostUser, + type SelfHostPackManagementItem, + type SelfHostPackPreview, SharedSetManifest, type UserRole, } from "./contract.js"; @@ -66,7 +73,47 @@ export interface ShareImportResult { readonly snapshot: RemoteSnapshot | null; } +export interface PackIssueResult { + readonly id: string; + readonly token: string; + readonly mode: "reusable_unlisted" | "private_single_claim"; + readonly expiresAt: string; + readonly objectSha256: string; + readonly skillSetRevisionSha256: string; +} + +export interface PackContent { + readonly bytes: Uint8Array; + readonly contentType: string; + readonly objectSha256: string; +} + interface RepositoryService { + readonly hostedState: (user: SelfHostUser) => Effect.Effect< + { + readonly workspaceId: string; + readonly plan: "free"; + readonly status: "none"; + readonly currentPeriodEnd: null; + }, + SelfHostFailure + >; + readonly publishManifest: ( + user: SelfHostUser, + payload: DesktopManifestPayload, + ) => Effect.Effect<{ readonly uploaded: number; readonly unchanged: number }, SelfHostFailure>; + readonly receiveContribution: ( + user: SelfHostUser, + payload: ContributorSignalPayload, + ) => Effect.Effect<"accepted" | "duplicate", SelfHostFailure>; + readonly contributionAggregate: ( + user: SelfHostUser, + skillHash: string, + ) => Effect.Effect; + readonly createPack: ( + user: SelfHostUser, + request: CreatePackRequest, + ) => Effect.Effect; readonly acceptShare: ( user: SelfHostUser, shareId: string, @@ -81,6 +128,14 @@ interface RepositoryService { request: CreateShareRequest, ) => Effect.Effect; readonly diagnostics: (user: SelfHostUser) => Effect.Effect; + readonly getPackContent: (token: string) => Effect.Effect; + readonly previewPack: (token: string) => Effect.Effect; + readonly listPacks: ( + user: SelfHostUser, + ) => Effect.Effect< + { readonly packs: ReadonlyArray }, + SelfHostFailure + >; readonly getHead: (user: SelfHostUser) => Effect.Effect; readonly getObject: ( user: SelfHostUser, @@ -118,6 +173,10 @@ interface RepositoryService { user: SelfHostUser, shareId: string, ) => Effect.Effect; + readonly revokePack: ( + user: SelfHostUser, + packId: string, + ) => Effect.Effect<{ readonly id: string; readonly revokedAt: string }, SelfHostFailure>; } export class SelfHostRepository extends Context.Service()( @@ -167,6 +226,20 @@ interface ShareRow { readonly updated_at: string; } +interface PackRow { + readonly id: string; + readonly owner_org_id: string; + readonly source_snapshot_id: string; + readonly artifact_id: string; + readonly object_sha256: string; + readonly token_hash: string; + readonly mode: "reusable_unlisted" | "private_single_claim"; + readonly expires_at: string; + readonly claimed_at: string | null; + readonly revoked_at: string | null; + readonly created_at: string; +} + interface CountRow { readonly count: number; } @@ -196,6 +269,12 @@ function sha256(bytes: Uint8Array | string): string { return createHash("sha256").update(bytes).digest("hex"); } +function derivedPackToken(packId: string, secret: string): string { + return createHmac("sha256", secret) + .update(`selftune.skill-set-pack.v1:${packId}`) + .digest("base64url"); +} + function userFromRow(row: UserRow): SelfHostUser { return { id: row.id, @@ -325,6 +404,24 @@ CREATE INDEX IF NOT EXISTS remote_shares_owner_idx ON remote_shares (owner_org_i CREATE INDEX IF NOT EXISTS remote_shares_recipient_idx ON remote_shares (recipient_user_id, created_at DESC); +CREATE TABLE IF NOT EXISTS remote_pack_links ( + id TEXT PRIMARY KEY, + owner_org_id TEXT NOT NULL, + source_snapshot_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + object_sha256 TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + mode TEXT NOT NULL CHECK (mode IN ('reusable_unlisted', 'private_single_claim')), + expires_at TEXT NOT NULL, + claimed_at TEXT, + revoked_at TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS remote_pack_links_owner_idx + ON remote_pack_links (owner_org_id, created_at DESC); + CREATE TABLE IF NOT EXISTS remote_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, org_id TEXT NOT NULL, @@ -334,6 +431,52 @@ CREATE TABLE IF NOT EXISTS remote_audit ( metadata_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS contributor_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id TEXT NOT NULL, + source_key TEXT NOT NULL, + skill_hash TEXT NOT NULL, + user_cohort TEXT NOT NULL, + triggered INTEGER, + invocation_type TEXT, + execution_grade TEXT, + query_bucket TEXT, + miss_detected INTEGER, + timestamp_bucket TEXT NOT NULL, + client_version TEXT NOT NULL, + received_at TEXT NOT NULL, + UNIQUE (org_id, source_key) +); + +CREATE INDEX IF NOT EXISTS contributor_signals_org_skill_idx + ON contributor_signals (org_id, skill_hash, received_at DESC); + +CREATE TABLE IF NOT EXISTS hosted_devices ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + user_id TEXT NOT NULL, + device_key TEXT NOT NULL, + name TEXT NOT NULL, + platform TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + revoked_at TEXT, + UNIQUE (org_id, device_key) +); + +CREATE TABLE IF NOT EXISTS hosted_manifests ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + device_id TEXT NOT NULL, + revision TEXT NOT NULL, + skills_json TEXT NOT NULL, + observed_at TEXT NOT NULL, + UNIQUE (org_id, revision), + FOREIGN KEY (device_id) REFERENCES hosted_devices(id) +); + +CREATE INDEX IF NOT EXISTS hosted_manifests_org_observed_idx + ON hosted_manifests (org_id, observed_at DESC); `; function initializeDatabase(config: SelfHostConfig): Database { @@ -637,6 +780,40 @@ function getShareRow(db: Database, shareId: string): ShareRow | null { return db.query(`${SHARE_SELECTION} WHERE sh.id = ?`).get(shareId); } +function activePackRow(db: Database, token: string): PackRow { + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) { + throw failure("RemoteLibraryPackMissing", 404, "Skill Set Pack unavailable"); + } + const row = db + .query( + `SELECT id, owner_org_id, source_snapshot_id, artifact_id, object_sha256, + token_hash, mode, expires_at, claimed_at, revoked_at, created_at + FROM remote_pack_links WHERE token_hash = ?`, + ) + .get(sha256(token)); + if ( + !row || + row.revoked_at || + new Date(row.expires_at) <= new Date() || + (row.mode === "private_single_claim" && row.claimed_at) + ) { + throw failure("RemoteLibraryPackMissing", 404, "Skill Set Pack unavailable"); + } + return row; +} + +function decodePackObject(object: StoredObject) { + try { + return Effect.runSync(decodePortableSkillSetEnvelope(object.bytes)); + } catch { + throw failure( + "RemoteLibraryPackInvalid", + 409, + "The Skill Set Pack failed integrity validation", + ); + } +} + function assertShareVisible(row: ShareRow, user: SelfHostUser): void { if (row.owner_org_id !== user.orgId && row.recipient_user_id !== user.id) { throw failure( @@ -719,6 +896,332 @@ function makeRepository(db: Database, config: SelfHostConfig): RepositoryService Effect.try({ try: body, catch: (cause) => storageFailure(operation, cause) }); return { + hostedState: (user) => + run("hosted_state", () => ({ + workspaceId: user.orgId, + plan: "free" as const, + status: "none" as const, + currentPeriodEnd: null, + })), + + publishManifest: (user, payload) => + run("publish_manifest", () => { + if (payload.skills.length > 2_000) { + throw failure("HostedManifestInvalid", 400, "Manifest exceeds 2,000 skills"); + } + if ( + !payload.revision || + payload.revision.length > 200 || + !payload.device_name || + payload.device_name.length > 200 || + !payload.platform || + payload.platform.length > 100 + ) { + throw failure("HostedManifestInvalid", 400, "Manifest metadata is invalid"); + } + const deviceKey = sha256(`${user.id}:${payload.device_name}:${payload.platform}`); + const existingDevice = db + .query<{ readonly id: string }, [string, string]>( + "SELECT id FROM hosted_devices WHERE org_id = ? AND device_key = ?", + ) + .get(user.orgId, deviceKey); + const deviceId = existingDevice?.id ?? randomUUID(); + const now = new Date().toISOString(); + db.run( + `INSERT INTO hosted_devices + (id, org_id, user_id, device_key, name, platform, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(org_id, device_key) DO UPDATE SET + name = excluded.name, + platform = excluded.platform, + last_seen_at = excluded.last_seen_at`, + [deviceId, user.orgId, user.id, deviceKey, payload.device_name, payload.platform, now], + ); + const inserted = db.run( + `INSERT OR IGNORE INTO hosted_manifests + (id, org_id, device_id, revision, skills_json, observed_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + randomUUID(), + user.orgId, + deviceId, + payload.revision, + JSON.stringify(payload.skills), + now, + ], + ); + if (inserted.changes === 0) return { uploaded: 0, unchanged: payload.skills.length }; + audit(db, user, "manifest.published", payload.revision, { + device_id: deviceId, + skill_count: payload.skills.length, + }); + return { uploaded: payload.skills.length, unchanged: 0 }; + }), + + receiveContribution: (user, payload) => + run("receive_contribution", () => { + if (payload.relay_destination !== user.orgId) { + const destination = db + .query<{ readonly org_id: string }, [string]>( + "SELECT org_id FROM selfhost_users WHERE org_id = ? AND active = 1 LIMIT 1", + ) + .get(payload.relay_destination); + if (!destination) { + throw failure("ContributorDestinationMissing", 404, "Creator destination not found"); + } + } + if (!/^sk_sha256_[a-f0-9]{12}$/.test(payload.skill_hash)) { + throw failure("ContributorSignalInvalid", 400, "Skill hash is invalid"); + } + if (!/^uc_sha256_[a-f0-9]{12}$/.test(payload.user_cohort)) { + throw failure("ContributorSignalInvalid", 400, "Contributor cohort is invalid"); + } + db.run("DELETE FROM contributor_signals WHERE received_at < ?", [ + new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString(), + ]); + const sourceKey = payload.source_key; + const result = db.run( + `INSERT OR IGNORE INTO contributor_signals + (org_id, source_key, skill_hash, user_cohort, triggered, invocation_type, + execution_grade, query_bucket, miss_detected, timestamp_bucket, + client_version, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + payload.relay_destination, + sourceKey, + payload.skill_hash, + payload.user_cohort, + payload.signals.triggered === undefined ? null : Number(payload.signals.triggered), + payload.signals.invocation_type ?? null, + payload.signals.execution_grade ?? null, + payload.signals.query_bucket ?? null, + payload.signals.miss_detected === undefined + ? null + : Number(payload.signals.miss_detected), + payload.timestamp_bucket, + payload.client_version, + new Date().toISOString(), + ], + ); + audit(db, user, "contributor_signal.relayed", sourceKey, { + destination_org_id: payload.relay_destination, + skill_hash: payload.skill_hash, + }); + return result.changes === 1 ? "accepted" : "duplicate"; + }), + + contributionAggregate: (user, skillHash) => + run("contribution_aggregate", () => { + if (!/^sk_sha256_[a-f0-9]{12}$/.test(skillHash)) { + throw failure("ContributorSignalInvalid", 400, "Skill hash is invalid"); + } + const rows = db + .query< + { + readonly user_cohort: string; + readonly triggered: number | null; + readonly miss_detected: number | null; + readonly execution_grade: "A" | "B" | "C" | "F" | null; + }, + [string, string] + >( + `SELECT user_cohort, triggered, miss_detected, execution_grade + FROM contributor_signals + WHERE org_id = ? AND skill_hash = ? + ORDER BY received_at DESC LIMIT 5000`, + ) + .all(user.orgId, skillHash); + const grades = { A: 0, B: 0, C: 0, F: 0 }; + for (const row of rows) if (row.execution_grade) grades[row.execution_grade] += 1; + return { + observations: rows.length, + cohorts: new Set(rows.map((row) => row.user_cohort)).size, + triggered: rows.filter((row) => row.triggered === 1).length, + missed: rows.filter((row) => row.miss_detected === 1).length, + grades, + }; + }), + + createPack: (user, request) => + run("create_pack", () => { + if (!isUuid(request.snapshot_id)) { + throw failure("RemoteLibraryInvalidPack", 400, "Snapshot ID must be a UUID"); + } + const snapshotRow = getSnapshotRow(db, user.orgId, request.snapshot_id); + const snapshot = snapshotRow ? snapshotFromRow(snapshotRow) : null; + const artifact = snapshot?.artifacts.find( + (candidate) => candidate.artifact_id === request.artifact_id, + ); + if (!artifact || artifact.artifact_type !== "skill_set") { + throw failure( + "RemoteLibraryPackMissing", + 404, + "The immutable Skill Set artifact was not found", + ); + } + const object = readStoredObject(db, config.dataDir, user.orgId, artifact.object_sha256); + const decoded = decodePackObject(object); + if ( + decoded.envelope.components.length === 0 || + decoded.envelope.components.some( + (component) => component.terms.licenseExpression.trim().length === 0, + ) + ) { + throw failure( + "RemoteLibraryPackLicenseRequired", + 409, + "Every Skill Set component needs distributable license terms", + ); + } + const now = new Date(); + const expiresAt = request.expires_at + ? new Date(request.expires_at) + : new Date(now.getTime() + 30 * 24 * 60 * 60 * 1_000); + if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) { + throw failure("RemoteLibraryInvalidPack", 400, "Pack expiry must be in the future"); + } + const id = randomUUID(); + const token = derivedPackToken(id, config.packLinkSecret ?? config.adminToken); + db.run( + `INSERT INTO remote_pack_links + (id, owner_org_id, source_snapshot_id, artifact_id, object_sha256, + token_hash, mode, expires_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + user.orgId, + request.snapshot_id, + request.artifact_id, + artifact.object_sha256, + sha256(token), + request.mode, + expiresAt.toISOString(), + user.id, + now.toISOString(), + ], + ); + audit(db, user, "remote_library.pack.created", id, { + artifact_id: request.artifact_id, + mode: request.mode, + }); + return { + id, + token, + mode: request.mode, + expiresAt: expiresAt.toISOString(), + objectSha256: artifact.object_sha256, + skillSetRevisionSha256: decoded.envelope.skillSetRevisionSha256, + }; + }), + + listPacks: (user) => + run("list_packs", () => { + const rows = db + .query( + `SELECT id, owner_org_id, source_snapshot_id, artifact_id, object_sha256, + token_hash, mode, expires_at, claimed_at, revoked_at, created_at + FROM remote_pack_links WHERE owner_org_id = ? ORDER BY created_at DESC`, + ) + .all(user.orgId); + const now = new Date(); + const packs = rows.map((row): SelfHostPackManagementItem => { + const object = readStoredObject(db, config.dataDir, row.owner_org_id, row.object_sha256); + const decoded = decodePackObject(object); + const token = derivedPackToken(row.id, config.packLinkSecret ?? config.adminToken); + const status = row.revoked_at + ? "revoked" + : new Date(row.expires_at) <= now + ? "expired" + : row.claimed_at + ? "claimed" + : "active"; + return { + packId: row.id, + artifactId: row.artifact_id, + name: decoded.envelope.sourceManifest.name, + description: decoded.envelope.sourceManifest.description, + mode: row.mode, + status, + packUrl: + status === "active" && sha256(token) === row.token_hash + ? `${config.publicUrl.replace(/\/$/, "")}/p/${token}` + : null, + expiresAt: row.expires_at, + createdAt: row.created_at, + claimedAt: row.claimed_at, + revokedAt: row.revoked_at, + skillSetRevisionSha256: decoded.envelope.skillSetRevisionSha256, + objectSha256: row.object_sha256, + componentCount: decoded.envelope.components.length, + }; + }); + return { packs }; + }), + + previewPack: (token) => + run("preview_pack", () => { + const row = activePackRow(db, token); + const object = readStoredObject(db, config.dataDir, row.owner_org_id, row.object_sha256); + const decoded = decodePackObject(object); + return { + protocol: "selftune.skill-set-pack.v1", + packId: row.id, + artifactId: row.artifact_id, + name: decoded.envelope.sourceManifest.name, + description: decoded.envelope.sourceManifest.description, + skillSetRevisionSha256: decoded.envelope.skillSetRevisionSha256, + objectSha256: row.object_sha256, + mode: row.mode, + expiresAt: row.expires_at, + requiresSignIn: false, + components: decoded.envelope.components.map((component) => ({ + logicalSkillId: component.logicalSkillId, + licenseExpression: component.terms.licenseExpression, + })), + } satisfies SelfHostPackPreview; + }), + + getPackContent: (token) => + run("get_pack_content", () => { + const row = activePackRow(db, token); + if (row.mode === "private_single_claim") { + const claimedAt = new Date().toISOString(); + const result = db.run( + `UPDATE remote_pack_links SET claimed_at = ? + WHERE id = ? AND claimed_at IS NULL AND revoked_at IS NULL`, + [claimedAt, row.id], + ); + if (result.changes !== 1) { + throw failure("RemoteLibraryPackClaimed", 409, "Skill Set Pack already claimed"); + } + } + const object = readStoredObject(db, config.dataDir, row.owner_org_id, row.object_sha256); + decodePackObject(object); + return { + bytes: object.bytes, + contentType: object.contentType, + objectSha256: row.object_sha256, + }; + }), + + revokePack: (user, packId) => + run("revoke_pack", () => { + if (!isUuid(packId)) { + throw failure("RemoteLibraryInvalidPack", 400, "Pack ID must be a UUID"); + } + const revokedAt = new Date().toISOString(); + const result = db.run( + `UPDATE remote_pack_links SET revoked_at = ? + WHERE id = ? AND owner_org_id = ? AND revoked_at IS NULL`, + [revokedAt, packId, user.orgId], + ); + if (result.changes !== 1) { + throw failure("RemoteLibraryPackMissing", 404, "Skill Set Pack unavailable"); + } + audit(db, user, "remote_library.pack.revoked", packId); + return { id: packId, revokedAt }; + }), + authenticate: (token) => run("authenticate", () => { const row = db diff --git a/apps/use-once-helper/src/terminal-disclosure.ts b/apps/use-once-helper/src/terminal-disclosure.ts index 9a40bf65..34bb1201 100644 --- a/apps/use-once-helper/src/terminal-disclosure.ts +++ b/apps/use-once-helper/src/terminal-disclosure.ts @@ -2,103 +2,8 @@ import { createInterface } from "node:readline/promises"; import type { DisclosurePort } from "./contracts"; -const ESCAPE = 0x1b; -const BELL = 0x07; - -/** - * Strip complete ANSI CSI and OSC sequences in one forward pass. - * - * Incomplete sequences remain in the returned string so the caller's control - * character pass can neutralize their introducer without discarding ordinary - * text. Each scanner either advances beyond a complete sequence or stops at - * the first byte that cannot belong to it, keeping total work linear. - */ -function stripAnsiSequences(value: string): string { - const chunks: string[] = []; - let plainStart = 0; - let index = 0; - let noOscTerminatorRemaining = false; - - while (index < value.length) { - if (value.charCodeAt(index) !== ESCAPE || index + 1 >= value.length) { - index += 1; - continue; - } - - const introducer = value.charCodeAt(index + 1); - if (introducer === 0x5b) { - // CSI: ESC [ parameter-bytes intermediate-bytes final-byte - let cursor = index + 2; - while (cursor < value.length) { - const code = value.charCodeAt(cursor); - if (code < 0x30 || code > 0x3f) break; - cursor += 1; - } - while (cursor < value.length) { - const code = value.charCodeAt(cursor); - if (code < 0x20 || code > 0x2f) break; - cursor += 1; - } - - const finalByte = cursor < value.length ? value.charCodeAt(cursor) : -1; - if (finalByte >= 0x40 && finalByte <= 0x7e) { - chunks.push(value.slice(plainStart, index)); - index = cursor + 1; - plainStart = index; - continue; - } - - // The scanned parameter/intermediate prefix cannot contain another ESC, - // so resume at the first invalid byte without rescanning that prefix. - index = cursor; - continue; - } - - if (introducer === 0x5d) { - // OSC: ESC ] payload (BEL | ESC \). Preserve the previous sanitizer's - // greedy behavior: the first BEL wins, otherwise the last ST wins. - if (noOscTerminatorRemaining) { - index += 2; - continue; - } - let cursor = index + 2; - let sequenceEnd = -1; - let lastStringTerminatorEnd = -1; - while (cursor < value.length) { - const code = value.charCodeAt(cursor); - if (code === BELL) { - sequenceEnd = cursor + 1; - break; - } - if (code === ESCAPE && cursor + 1 < value.length && value.charCodeAt(cursor + 1) === 0x5c) { - lastStringTerminatorEnd = cursor + 2; - cursor += 2; - continue; - } - cursor += 1; - } - if (sequenceEnd === -1) sequenceEnd = lastStringTerminatorEnd; - - if (sequenceEnd !== -1) { - chunks.push(value.slice(plainStart, index)); - index = sequenceEnd; - plainStart = index; - continue; - } - - // No later OSC introducer can complete without also completing this one. - noOscTerminatorRemaining = true; - index += 2; - continue; - } - - index += 1; - } - - if (chunks.length === 0) return value; - chunks.push(value.slice(plainStart)); - return chunks.join(""); -} +// oxlint-disable-next-line no-control-regex -- strips complete ANSI CSI/OSC sequences. +const ANSI_SEQUENCE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g; export interface InteractiveTerminalPort { readonly interactive: boolean; @@ -125,7 +30,7 @@ function display(value: string | null, maximumCharacters = 4_096): string { if (value === null) return "not provided"; // Authority metadata is untrusted terminal text. Strip C0/C1, ANSI, // bidirectional/format, and Unicode line controls, then enforce the boundary. - const withoutAnsi = stripAnsiSequences(value); + const withoutAnsi = value.replace(ANSI_SEQUENCE, ""); return withoutAnsi.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, maximumCharacters); } diff --git a/apps/use-once-helper/tests/terminal-disclosure.test.ts b/apps/use-once-helper/tests/terminal-disclosure.test.ts index 32c27698..ac84d6a1 100644 --- a/apps/use-once-helper/tests/terminal-disclosure.test.ts +++ b/apps/use-once-helper/tests/terminal-disclosure.test.ts @@ -143,69 +143,4 @@ describe("interactive disclosure", () => { }); expect(await disclosure.confirm({ preview: preview(), bundledTerms: null })).toBeNull(); }); - - test("strips adversarial OSC input with repeated introducers in one bounded output", async () => { - const lines: string[] = []; - const disclosure = makeTerminalDisclosure({ - interactive: false, - write: (line) => lines.push(line), - readLine: async () => "", - }); - const basePreview = preview(); - const input: UseOncePreview = { - ...basePreview, - package: { - ...basePreview.package, - displayName: `visible\u001b]${"\u001b]payload".repeat(20_000)}\u0007safe`, - }, - }; - - await disclosure.show({ preview: input, bundledTerms: null }); - - expect(lines[0]).toBe("Skill: visiblesafe 1.0.0"); - expect(lines[0]).not.toContain("\u001b"); - }); - - test("preserves greedy OSC termination while scanning nested terminators linearly", async () => { - const lines: string[] = []; - const disclosure = makeTerminalDisclosure({ - interactive: false, - write: (line) => lines.push(line), - readLine: async () => "", - }); - const basePreview = preview(); - const input: UseOncePreview = { - ...basePreview, - package: { - ...basePreview.package, - displayName: "A\u001b]outer\u001b\\hidden\u0007Z|B\u001b]outer\u001b\\hidden\u001b\\Y", - }, - }; - - await disclosure.show({ preview: input, bundledTerms: null }); - - expect(lines[0]).toBe("Skill: AZ|BY 1.0.0"); - expect(lines[0]).not.toContain("hidden"); - }); - - test("continues stripping complete CSI after an unterminated OSC introducer", async () => { - const lines: string[] = []; - const disclosure = makeTerminalDisclosure({ - interactive: false, - write: (line) => lines.push(line), - readLine: async () => "", - }); - const basePreview = preview(); - const input: UseOncePreview = { - ...basePreview, - package: { - ...basePreview.package, - displayName: "A\u001b]unfinished\u001b[31mRED", - }, - }; - - await disclosure.show({ preview: input, bundledTerms: null }); - - expect(lines[0]).toBe("Skill: A ]unfinishedRED 1.0.0"); - }); }); diff --git a/bun.lock b/bun.lock index 0f99aa4e..54638402 100644 --- a/bun.lock +++ b/bun.lock @@ -42,7 +42,6 @@ "@effect/tsgo": "^0.24.1", "@effect/vitest": "4.0.0-beta.66", "@evilmartians/lefthook": "^1.13.6", - "@testing-library/react": "^16.3.2", "@types/bun": "^1.3.11", "@typescript/native": "npm:typescript@^7.0.2", "bun-types": "^1.3.11", @@ -83,9 +82,8 @@ }, "apps/desktop": { "name": "@selftune/desktop", - "version": "0.2.33", + "version": "0.3.3", "dependencies": { - "@selftune/api-contract": "workspace:*", "@selftune/config": "workspace:*", "@sentry/electron": "^7.8.0", "@zip.js/zip.js": "^2.8.17", @@ -168,6 +166,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.1", @@ -210,19 +209,6 @@ "typescript": "^5.9.3", }, }, - "packages/api-contract": { - "name": "@selftune/api-contract", - "version": "0.1.0", - "dependencies": { - "effect": "4.0.0-beta.66", - "spdx-expression-parse": "4.0.0", - }, - "devDependencies": { - "@types/spdx-expression-parse": "4.0.0", - "typescript": "^5.9.3", - "vitest": "^4.0.18", - }, - }, "packages/app-core": { "name": "@selftune/app-core", "version": "0.1.0", @@ -234,6 +220,7 @@ "tw-animate-css": "^1.3.6", }, "devDependencies": { + "@types/node": "^20", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "typescript": "^5.9.3", @@ -276,16 +263,14 @@ "name": "@selftune/dashboard-core", "version": "1.0.0", "dependencies": { + "@selftune/control-plane": "workspace:*", "@selftune/ui": "workspace:*", - "lucide-react": "^0.577.0", + "lucide-react": "^0.576.0", "sonner": "^2.0.7", }, "devDependencies": { - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "jsdom": "^29.0.2", }, "peerDependencies": { "react": "^19.0.0", @@ -455,7 +440,11 @@ }, "devDependencies": { "@types/bun": "^1.3.11", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", "bun-types": "^1.3.11", + "react": "^19.1.0", + "react-dom": "^19.1.0", "typescript": "^5.9.3", }, }, @@ -540,6 +529,8 @@ "version": "1.0.0", "dependencies": { "@base-ui/react": "^1.3.0", + "@pierre/diffs": "^1.1.19", + "@pierre/trees": "^1.0.0-beta.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", @@ -579,14 +570,6 @@ "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.10.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA=="], - "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], - - "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], - - "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], - - "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -651,8 +634,6 @@ "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], - "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], - "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="], @@ -691,18 +672,6 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], - - "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], - - "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.10", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw=="], - - "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], - - "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.7", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig=="], - - "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], - "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -837,8 +806,6 @@ "@evilmartians/lefthook": ["@evilmartians/lefthook@1.13.6", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "ia32", "arm64", ], "bin": { "lefthook": "bin/index.js" } }, "sha512-79vplrUBWL4Fkt59YkEBdSpqBVhNrY8t5+jEp+wX5QbsmbQLcSULqwS7FmbNRyECa2LWMrUWpe6ENIfNwB4jiw=="], - "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], - "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -1005,6 +972,14 @@ "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + "@pierre/diffs": ["@pierre/diffs@1.3.6", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-a3woaW2QHy78JDxPJK0OJzwZUN4xoQLLIS/pceO8X6+L8gA5D682mP7/w3YxxEVRPXOaoe/p/RJ5Oj/3nrEzew=="], + + "@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="], + + "@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="], + + "@pierre/trees": ["@pierre/trees@1.0.0-beta.6", "", { "dependencies": { "@pierre/theming": "1.0.0", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -1091,8 +1066,6 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - "@selftune/api-contract": ["@selftune/api-contract@workspace:packages/api-contract"], - "@selftune/app-core": ["@selftune/app-core@workspace:packages/app-core"], "@selftune/cli": ["@selftune/cli@workspace:apps/cli"], @@ -1171,6 +1144,24 @@ "@sentry/server-utils": ["@sentry/server-utils@10.62.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", "@apm-js-collab/tracing-hooks": "^0.10.0", "@sentry/conventions": "^0.12.0", "@sentry/core": "10.62.0", "magic-string": "~0.30.0" } }, "sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA=="], + "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="], + + "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], + + "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], + + "@shikijs/transformers": ["@shikijs/transformers@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/types": "4.4.3" } }, "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw=="], + + "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], @@ -1231,7 +1222,7 @@ "@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/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=="], + "@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=="], "@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=="], @@ -1303,8 +1294,6 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], - "@types/spdx-expression-parse": ["@types/spdx-expression-parse@4.0.0", "", {}, "sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA=="], - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -1395,7 +1384,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="], @@ -1437,8 +1426,6 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], - "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], @@ -1561,8 +1548,6 @@ "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-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], - "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=="], @@ -1589,16 +1574,12 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], - "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], - "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -1695,8 +1676,6 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], @@ -1847,6 +1826,8 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], @@ -1855,10 +1836,10 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], - "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], - "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -1927,8 +1908,6 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -1961,8 +1940,6 @@ "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -2031,9 +2008,11 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + + "lucide-react": ["lucide-react@0.576.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-koNxU14BXrxUfZQ9cUaP0ES1uyPZKYDjk31FQZB6dQ/x+tXk979sVAn9ppZ/pVeJJyOxVM8j1E+8QEuSc02Vug=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -2059,8 +2038,6 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -2197,6 +2174,10 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -2229,8 +2210,6 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -2277,6 +2256,10 @@ "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], + + "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -2297,14 +2280,12 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], - "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -2357,6 +2338,12 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], @@ -2407,8 +2394,6 @@ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], @@ -2439,6 +2424,8 @@ "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -2469,12 +2456,6 @@ "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], - "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], - - "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], - - "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -2509,8 +2490,6 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "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=="], @@ -2543,10 +2522,6 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tldts": ["tldts@7.4.9", "", { "dependencies": { "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA=="], - - "tldts-core": ["tldts-core@7.4.9", "", {}, "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg=="], - "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], @@ -2557,9 +2532,7 @@ "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], - "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], - - "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -2641,15 +2614,11 @@ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], - "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], - "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], - "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], - - "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "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=="], @@ -2663,12 +2632,8 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], - "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], @@ -2695,8 +2660,6 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2749,6 +2712,14 @@ "@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=="], + "@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], + + "@selftune/local-dashboard/lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + + "@selftune/ui/lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + "@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=="], @@ -2793,8 +2764,6 @@ "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2845,9 +2814,9 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], @@ -2871,6 +2840,8 @@ "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -2901,16 +2872,12 @@ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3063,10 +3030,6 @@ "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - - "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "ora/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/docs/design-docs/alpha-remote-data-contract.md b/docs/design-docs/alpha-remote-data-contract.md deleted file mode 100644 index a7b08de4..00000000 --- a/docs/design-docs/alpha-remote-data-contract.md +++ /dev/null @@ -1,350 +0,0 @@ - - -# Alpha Remote Data Contract — Cloud API V2 Push, Upload Queue, Auth Model - -**Status:** Active -**Created:** 2026-03-18 -**Updated:** 2026-04-01 -**Type:** Design document - ---- - -## 1. Overview - -### What the alpha remote pipeline does - -The alpha remote pipeline enables opted-in selftune users to upload consent-based telemetry data to the selftune cloud API. This data powers aggregate analysis across the alpha cohort: which skills trigger reliably, which evolution proposals improve outcomes, and where the selftune feedback loop breaks down across real-world usage patterns. - -The pipeline is batch-oriented and asynchronous. Local SQLite remains the source of truth. Uploads happen periodically during `sync` and `orchestrate` runs, or explicitly through `selftune alpha upload`, not in real time. - -### Why the cloud API - -Alpha uploads target the existing selftune cloud API's V2 push endpoint (`POST /api/v1/push`) rather than a standalone service. This approach was chosen over a dedicated Cloudflare Worker/D1 setup because: - -- **Shared infrastructure.** The cloud API already handles authentication, rate limiting, and data storage in Neon Postgres. No separate service to deploy and maintain. -- **Canonical schema.** The V2 push endpoint accepts canonical records (sessions, prompts, skill_invocations, execution_facts, evolution_evidence) that align with selftune's data model. No impedance mismatch between local and remote schemas. -- **Single auth model.** Users authenticate with `st_live_*` API keys via Bearer header — the same mechanism used for all cloud API interactions. -- **Low cost for alpha volume.** The existing cloud infrastructure handles the expected alpha cohort (tens of users, thousands of records per day) without additional cost. - -### Relationship to the existing contribution surfaces - -The current product has three distinct sharing surfaces: -- `selftune contribute` — manual community contribution bundle export -- `selftune contributions` — local creator-directed sharing preferences -- `selftune alpha upload` — user -> own cloud / alpha telemetry upload - -The `contribute/` system and the alpha upload pipeline serve different purposes but now share the same cloud API backend: - -| Dimension | `contribute/` | Alpha upload | -| -------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | -| **Purpose** | Community sharing of anonymized eval data | Automatic telemetry for alpha cohort analysis | -| **Trigger** | Manual (`selftune contribute`) | Automatic (`sync` / `orchestrate` when enrolled) + explicit (`selftune alpha upload`) | -| **Transport** | HTTPS to cloud API | HTTPS to cloud API (`POST /api/v1/push`) | -| **Storage** | Neon Postgres (canonical tables) | Neon Postgres (canonical tables) | -| **Consent model** | Per-invocation confirmation | Enrollment flag in config (`config.alpha.enrolled`) + API key | -| **Data granularity** | Skill-level bundles with eval entries | Session-level, invocation-level, evolution-level V2 canonical records | -| **Privacy level** | Conservative or aggressive sanitization | Explicit alpha consent for raw prompt/query text plus structured telemetry | - -Both systems target the same cloud API, but alpha upload is automatic (when enrolled and an API key is configured) while community contribution requires manual invocation and confirmation. - -`selftune contributions` is intentionally separate: it stores future creator-directed sharing preferences locally and does not yet change alpha-upload behavior by itself. -The current local creator-directed contribution groundwork also stays separate from alpha upload: - -- approved skills can now stage privacy-safe creator-directed relay signals into SQLite during `sync` -- those staged rows do **not** ride the alpha upload queue -- they now flush explicitly through `selftune contributions upload` to a dedicated relay endpoint -- cloud relay delivery will be a later pipeline layered on top of the existing remote architecture - ---- - -## 2. Endpoint Configuration - -### Target endpoint - -Alpha uploads are sent to the cloud API's V2 push endpoint: - -```text -POST https://api.selftune.dev/api/v1/push -``` - -### Environment override - -The endpoint can be overridden with the `SELFTUNE_ALPHA_ENDPOINT` environment variable: - -```bash -export SELFTUNE_ALPHA_ENDPOINT="https://staging-api.selftune.dev/api/v1/push" -``` - -Default: `https://api.selftune.dev/api/v1/push` - ---- - -## 3. Authentication - -### API key model - -Each alpha user authenticates with an `st_live_*` API key, provisioned automatically via the device-code flow: - -1. User runs `selftune init --alpha --alpha-email ` -2. CLI requests a device code and opens the browser for approval -3. On approval, the CLI receives and stores the API key, cloud_user_id, and org_id automatically - -### HTTP auth - -Every upload request includes the API key as a Bearer token: - -```text -Authorization: Bearer st_live_abc123... -``` - -The cloud API validates the key, identifies the user, and associates uploaded records with their account. - -### Key storage - -The API key is stored in `~/.selftune/config.json` under the `alpha` block: - -```json -{ - "alpha": { - "enrolled": true, - "user_id": "a1b2c3d4-...", - "cloud_user_id": "a1b2c3d4-...", - "api_key": "st_live_abc123...", - "email": "user@example.com" - } -} -``` - -`cloud_user_id` is stored alongside the local `user_id` in config. The V2 push envelope still uses `user_id` as the request identity field. - ---- - -## 4. V2 Canonical Payload Format - -### Schema version - -All upload payloads use `schema_version: "2.0"` and contain canonical records that map directly to the cloud API's Postgres tables. - -### Record types - -The V2 push payload contains typed canonical records: - -| Record type | Description | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `sessions` | Session summaries with platform, model, timing, and skill trigger metadata | -| `prompts` | User prompt/query records with raw text (alpha consent required) | -| `skill_invocations` | Skill trigger/miss records with confidence, mode, and query context | -| `execution_facts` | Tool usage, error counts, and execution metadata (deterministic `execution_fact_id` generated during staging for records that lack one) | -| `evolution_evidence` | Evolution proposal outcomes, pass rate changes, deploy/rollback status (deterministic `evidence_id` generated during staging) | -| `orchestrate_runs` | Orchestrate run reports with sync/evolve/watch phase summaries | - -### Payload envelope - -Each HTTP request sends an envelope containing metadata and a batch of canonical records: - -```json -{ - "schema_version": "2.0", - "user_id": "a1b2c3d4-...", - "agent_type": "claude_code", - "selftune_version": "0.2.7", - "records": [ - { "type": "sessions", "data": { ... } }, - { "type": "skill_invocations", "data": { ... } } - ] -} -``` - -The TypeScript interfaces are defined in `cli/selftune/alpha-upload-contract.ts` (queue infrastructure types and `PushUploadResult`). The V2 payload shape is validated by `PushPayloadV2Schema` (Zod) with `min(0)` arrays. - -### Canonical upload staging - -Before payloads are built, records are staged into a local `canonical_upload_staging` SQLite table by `cli/selftune/alpha-upload/stage-canonical.ts`. This module reads canonical records from SQLite by default (or a JSONL override only for explicit recovery/debugging), plus evolution evidence and orchestrate runs from SQLite, then writes them into the staging table with deterministic IDs: - -- **`execution_fact_id`** — generated deterministically during staging for records that lack one (hash of session_id + tool + timestamp) -- **`evidence_id`** — generated deterministically during staging for evolution evidence records (hash of proposal_id + target + skill + timestamp) - -The staging table uses a single monotonic cursor, so `build-payloads.ts` reads only unstaged records on each cycle. This avoids re-scanning the full SQLite-backed canonical history. If a malformed staged row is encountered, payload assembly stops before that row and holds the cursor at the last valid sequence so corrupted data is not silently skipped. - -### Cloud-side lossless ingest - -The cloud API stores every push request in a `raw_pushes` table before normalizing into canonical tables. This provides: - -- **Lossless ingest** — no data is lost even if normalization logic changes -- **Partial push acceptance** — unresolved references are stored in raw_pushes and resolved later -- **Retry safety** — natural-key UNIQUE constraints with `onConflictDoNothing` make duplicate pushes idempotent - ---- - -## 5. Response Handling - -The cloud API returns standard HTTP status codes: - -| Status | Meaning | Client behavior | -| ----------------------- | ------------------------------------ | --------------------------------------------- | -| `201 Created` | Records accepted and stored | Mark queue item as `sent` | -| `409 Conflict` | Duplicate records (already uploaded) | Treat as success, mark `sent` | -| `429 Too Many Requests` | Rate limited | Retryable — increment attempts, apply backoff | -| `401 Unauthorized` | Invalid or missing API key | Non-retryable — mark `failed`, log auth error | -| `403 Forbidden` | Key valid but user not authorized | Non-retryable — mark `failed`, log auth error | -| `5xx` | Server error | Retryable — increment attempts, apply backoff | - ---- - -## 6. Upload Timing - -**Recommendation: periodic batch upload, not immediate.** - -Uploads happen through three entry points: - -1. **On each `selftune orchestrate` run.** After sync completes and before evolution begins, the orchestrate loop checks for pending upload queue items and flushes them. This piggybacks on the existing orchestrate cadence (typically cron-scheduled every 1-4 hours). - -2. **On each `selftune sync` run when alpha is enrolled.** Sync replays native source data into SQLite, then runs an upload cycle so the cloud stays current between orchestrate runs. - -3. **Explicit `selftune alpha upload`.** This gives agents a way to force-upload or preview a dry run without running a full orchestrate cycle. - -**Rationale for batch over immediate:** - -- **Alpha volume is low.** Tens of users generating hundreds of records per day. Real-time streaming adds complexity without proportional value. -- **Reduces noise.** Batching naturally deduplicates records that might be written multiple times during a session (e.g., skill_usage records appended by hooks then reconciled by sync). -- **Aligns with orchestrate cadence.** The orchestrate loop already reads local SQLite, runs evolution, and writes results. Adding an upload step is a natural extension of this pipeline. -- **Failure isolation.** If the cloud API is unreachable, the upload fails silently and retries next cycle. No impact on local selftune operation. - -**What NOT to do:** - -- Do not upload from hooks (too latency-sensitive, runs in the critical path of user prompts). -- Do not upload from the dashboard server (it is a read-only query surface). -- Do not upload on every SQLite write (too frequent, creates thundering herd for multi-skill users). - ---- - -## 7. Queue/Retry Model - -### Local upload queue - -A local `upload_queue` table in the existing selftune SQLite database stages records for upload. This table is defined in `cli/selftune/localdb/schema.ts`. - -```sql -CREATE TABLE upload_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - payload_type TEXT NOT NULL, -- 'sessions' | 'invocations' | 'evolution' - payload_json TEXT NOT NULL, -- JSON-serialized array of payload items - created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'sent' | 'failed' - attempts INTEGER NOT NULL DEFAULT 0, - last_attempt_at TEXT, - last_error TEXT, - sent_at TEXT -); - -CREATE INDEX idx_upload_queue_status ON upload_queue(status); -CREATE INDEX idx_upload_queue_created ON upload_queue(created_at); -``` - -### Enqueue flow - -1. During `sync`, `orchestrate`, or `selftune alpha upload`, the upload module stages canonical SQLite rows into `canonical_upload_staging` and advances from that staging table's monotonic sequence/cursor. -2. Records are batched into envelopes of up to **100 records** per payload type. -3. Each batch is inserted into `upload_queue` as a single row with `status = 'pending'`. - -### Flush flow - -1. `flushQueue()` selects rows with `status = 'pending'` ordered by `created_at ASC`. -2. For each pending item, it POSTs the stored V2 push envelope to `https://api.selftune.dev/api/v1/push` with the Bearer API key. -3. Retryable failures (`429`, `5xx`) are retried with exponential backoff inside the same `flushQueue()` run. -4. Success (`201` or `409`) is terminal: set `status = 'sent'` and `sent_at`. -5. Exhausted retryable failures and non-retryable auth failures (`401`, `403`) are terminal: increment `attempts`, set `last_attempt_at` / `last_error`, and leave the row at `status = 'failed'`. - -### Retry with exponential backoff - -When retrying failed items within a single flush cycle: - -| Attempt | Delay before retry | -| ------- | ------------------ | -| 1 | 1 second | -| 2 | 2 seconds | -| 3 | 4 seconds | -| 4 | 8 seconds | -| 5 | 16 seconds | - -After 5 failed attempts, the queue item stays at `status = 'failed'` and is not retried automatically. A future `selftune alpha retry` command could reset failed items. - -### Batch size limits - -- Maximum **100 records** per envelope (per payload_type). -- If a local query returns more than 100 records for a payload type, they are split into multiple queue items. -- This keeps individual HTTP requests small (estimated <50KB per envelope at 100 invocation records). - ---- - -## 8. Consent Enforcement - -### Local enforcement - -Before any network call, the upload module performs this check: - -```python -config = readFreshConfig() // NOT cached, read from disk each time -if config.alpha?.enrolled !== true: - return // silently skip upload -if !config.alpha?.api_key: - return // no API key configured, skip upload -``` - -Reading config fresh from disk on every upload attempt means a user (or their agent) can unenroll at any time by setting `config.alpha.enrolled = false` or removing the `alpha` key. The next upload cycle respects the change immediately. - -### Server-side enforcement - -The cloud API validates every upload: - -1. Extract the API key from the `Authorization: Bearer` header. -2. Look up the associated user account. -3. If the key is invalid or the user has been deactivated, return 401/403. -4. On successful writes, update the user's `last_upload_at` timestamp. - -### Future: data deletion - -A future `selftune alpha delete-data` command will: - -- Call a cloud API endpoint that deletes all records for the user's account. -- Remove the `alpha` config block locally. -- Confirm deletion to the agent. - -This aligns with the principle that alpha enrollment is fully reversible. - ---- - -## 9. Privacy Model - -### Data minimization - -The alpha pipeline uploads only the fields needed for alpha analysis, but it does include raw query text for explicitly consented users: - -| Data category | What is uploaded | What is NOT uploaded | -| ----------------- | -------------------------------------------------- | ------------------------------------------------------------- | -| Queries | Raw query text (in `raw_source_ref.metadata`) | Full transcript bodies outside the captured prompt/query text | -| Workspace paths | Workspace path (in V2 canonical records) | N/A | -| File contents | Nothing | Nothing | -| Conversation text | Prompt/query text only | Full conversation transcripts | -| Code | Nothing | Nothing | -| File paths | Only if the user typed them into prompt/query text | Structured file-path fields | -| Session IDs | Session ID (opaque UUID) | N/A | - -### What is explicitly excluded - -- No file contents of any kind -- No transcript text beyond the captured prompt/query text -- No code snippets or diffs -- No environment variables or shell history -- No tool input/output content - ---- - -## Appendix: Design Decision — Cloud API over Standalone Worker - -The initial design direction evaluated a standalone Cloudflare Worker backed by D1 (SQLite at the edge). This was replaced with direct integration into the existing cloud API for these reasons: - -1. **Reduced operational surface.** One service to monitor, not two. -2. **Unified auth.** API keys work the same way for all cloud interactions. -3. **Schema convergence.** V2 canonical records are the shared language between local and cloud — no separate D1 schema to maintain. -4. **Future-proof.** As selftune moves toward a full cloud product, alpha data lives in the same Postgres tables that power the cloud dashboard. diff --git a/docs/design-docs/index.md b/docs/design-docs/index.md index 622a2e96..676dde92 100644 --- a/docs/design-docs/index.md +++ b/docs/design-docs/index.md @@ -20,6 +20,7 @@ Registry of all design documents with verification status. | alpha-remote-data-contract.md | Current | 2026-03-18 | Team | | agent-cli-contract.md | Current | 2026-03-28 | Team | | desktop-control-plane.md | Current | 2026-07-14 | Team | +| portable-skill-evaluation-contract.md | Current | 2026-08-25 | Team | ## Verification Schedule diff --git a/docs/design-docs/portable-skill-evaluation-contract.md b/docs/design-docs/portable-skill-evaluation-contract.md new file mode 100644 index 00000000..3142fdb6 --- /dev/null +++ b/docs/design-docs/portable-skill-evaluation-contract.md @@ -0,0 +1,70 @@ +# Portable skill evaluation contract + +**Status:** Current +**Verified:** 2026-08-25 + +**Authority:** [Agent Skills — Evaluating skill output quality](https://agentskills.io/skill-creation/evaluating-skills) + +## Decision + +Evaluation definitions belong to the skill package they specify: + +```text +skill-name/ +├── SKILL.md +└── evals/ + ├── evals.json + ├── routing.json + └── files/ +``` + +- `evals.json` follows the Agent Skills evaluation contract: `skill_name` plus + cases containing `id`, a realistic `prompt`, a human-readable + `expected_output`, optional `files`, and later objective `assertions`. +- `routing.json` is a SelfTune extension containing positive and negative + routing cases. It answers whether the harness should invoke the skill for a + prompt; it does not replace the standard output-quality contract. +- `files/` contains optional checked-in inputs referenced by eval cases. + +These files are portable product source. They travel with forks, releases, +exports, and local edits, and can be reviewed in the same change as `SKILL.md`. + +SQLite remains the local index for readiness queries and run history. The +legacy files under `~/.selftune/eval-sets` and `~/.selftune/unit-tests` remain +compatibility mirrors. A cached definition must not override a non-empty +package definition. + +Run results do not belong in the package. They remain in SQLite and in a sibling +`-workspace/iteration-N/` tree because they are machine-, model-, +and time-specific. Each case separates `with_skill` from `without_skill` (or +`old_skill`), including outputs, timing, and evidence-backed grading. Aggregated +statistics live in that iteration's `benchmark.json`. + +## Resolution rules + +1. When `--skill-path` is available, generation writes the package contract. +2. Readiness checks prefer the package contract over SQLite and legacy mirrors. +3. An explicit `--output` or `--tests` path produces an additional copy or + selects an explicit input; it does not silently change package ownership. +4. When no skill path can be resolved, the legacy cache remains a supported + fallback so existing installations continue to work. + +## Expected-result model + +Behavior cases begin with a prompt and expected output. Start with two or three +varied, realistic cases including an edge condition. Add objective, +human-readable assertions after inspecting the first outputs. SelfTune stores +typed checks in the optional `selftune_assertions` extension so its local runner +can execute `contains`, `not_contains`, `regex`, `json_path`, `tool_called`, and +`tool_not_called` checks without replacing the standard fields. Exact prose +snapshots are intentionally not the default because they are brittle across +models. + +## Consequences + +- Skills can ship with reproducible evidence instead of acquiring tests only + after installation. +- Changes to instructions and their evaluation contract can be code-reviewed + together. +- Readiness no longer depends on one machine's hidden home-directory state. +- Local run history remains private and does not create noisy package diffs. diff --git a/docs/exec-plans/active/agent-first-alpha-onboarding.md b/docs/exec-plans/active/agent-first-alpha-onboarding.md deleted file mode 100644 index aae0bb4c..00000000 --- a/docs/exec-plans/active/agent-first-alpha-onboarding.md +++ /dev/null @@ -1,218 +0,0 @@ -# Agent-First Alpha Onboarding - -**Status:** Proposed -**Date:** 2026-03-19 - -## Goal - -Make the real alpha user path happen through the user's coding agent and the -local CLI, not through the cloud frontend as the primary UX. - -The cloud app remains the control plane for: - -- sign-in -- alpha enrollment -- upload credential issuance - -But the user's experience should be: - -1. tell the agent to set up selftune -2. complete the minimum cloud auth handoff -3. return to the agent/CLI flow - -## Product Rule - -The cloud app is a dependency, not the main product surface. - -The main product surface remains: - -- `skill/SKILL.md` -- `skill/workflows/Initialize.md` -- `selftune init` - -## Ticket 1: Define the Agent-First Enrollment Flow - -**Goal:** specify the exact setup sequence the agent should follow. - -### Deliverable - -- a short flow spec covering: - - user says "set up selftune" - - agent checks local config - - if not linked, agent explains the cloud enrollment step - - user signs in / enrolls / issues credential - - agent stores credential locally - - agent finishes setup and verifies upload readiness - -### Acceptance - -- no ambiguity about where browser handoff happens -- no ambiguity about what the agent asks the user -- no ambiguity about when the flow returns to local CLI mode - -## Ticket 2: Replace Local Alpha Identity Assumptions - -**Goal:** stop treating alpha identity as a separate local-only user model. - -### Files - -- `cli/selftune/alpha-identity.ts` -- `cli/selftune/types.ts` -- `cli/selftune/init.ts` - -### Work - -- treat cloud-linked identity as authoritative -- keep local config as a cache of: - - cloud user id - - org id - - upload credential - - enrollment status metadata if needed -- remove assumptions that local email/user id are the real alpha identity source - -### Acceptance - -- local config reflects linked cloud identity, not a separate parallel identity model - -## Ticket 3: Add CLI Support for Cloud Linking State - -**Goal:** make `selftune init` and related commands aware of cloud link status. - -### Files - -- `cli/selftune/init.ts` -- `cli/selftune/status.ts` -- `cli/selftune/observability.ts` - -### Work - -- detect whether cloud identity + upload credential are present -- show clear agent-facing next steps when missing -- expose whether alpha upload is: - - not linked - - linked but not enrolled - - enrolled but missing credential - - ready - -### Acceptance - -- agent can reliably diagnose why alpha upload is not active - -## Ticket 4: Add Browser Handoff UX for the Agent - -**Goal:** make the unavoidable cloud step feel intentional and small. - -### Files - -- `skill/workflows/Initialize.md` -- `skill/SKILL.md` -- `skill/references/interactive-config.md` - -### Work - -- tell the agent exactly when to ask the user to sign in to the cloud app -- tell the agent exactly when to ask the user to issue an upload credential -- make the copy explicit: - - this is a one-time account/enrollment step - - afterwards the workflow returns to the local agent/CLI path - -### Acceptance - -- the agent does not present the cloud app as the main way to use selftune - -## Ticket 5: Add Credential Import / Storage Path - -**Goal:** let the agent finish setup after the user gets a cloud-issued credential. - -### Files - -- `cli/selftune/init.ts` -- `cli/selftune/alpha-upload/index.ts` -- local config read/write helpers - -### Work - -- accept product-issued `st_live_*` credential in setup flow -- store it locally in the expected config location -- validate presence/format before marking setup complete - -### Acceptance - -- after credential issuance, the agent can finish setup without manual file editing - -## Ticket 6: Add Upload Readiness Verification - -**Goal:** prove the local machine is actually ready after setup. - -### Files - -- `cli/selftune/init.ts` -- `cli/selftune/observability.ts` -- `skill/workflows/Initialize.md` - -### Work - -- run a small readiness check after setup: - - config present - - enrollment/credential fields present - - push endpoint configured - - upload queue can initialize -- return agent-facing confirmation or exact remediation - -### Acceptance - -- setup ends with a concrete readiness result, not “probably done” - -## Ticket 7: Update Agent Docs to Match the New Truth - -**Goal:** keep the agent-first product surface aligned with the new onboarding path. - -### Files - -- `skill/SKILL.md` -- `skill/workflows/Initialize.md` -- `skill/workflows/Doctor.md` -- `skill/workflows/Dashboard.md` if any cloud references exist - -### Work - -- make the setup workflow explicitly agent-first -- describe cloud auth as a required one-time control-plane handoff -- remove any implication that users should live in the cloud UI for normal use - -### Acceptance - -- docs match the intended product story - -## Ticket 8: Add End-to-End Setup Smoke Test - -**Goal:** verify the intended user path, not just the pieces. - -### Scope - -- temp local config -- simulated or staged cloud-issued credential -- `selftune init` -- readiness verification - -### Acceptance - -- one passing test proves the setup can go from fresh machine to upload-ready - -## Recommended Order - -1. Ticket 1 — flow spec -2. Ticket 2 — local identity cleanup -3. Ticket 3 — cloud-link state in CLI -4. Ticket 5 — credential import/storage -5. Ticket 6 — readiness verification -6. Ticket 4 and Ticket 7 — doc/agent workflow alignment -7. Ticket 8 — end-to-end smoke test - -## Success Criteria - -- the primary setup story is “tell your agent to set up selftune” -- the cloud UI is used only as a short auth/enrollment handoff -- the agent can explain exactly what the user must do and when -- local config reflects cloud-issued identity/credential state -- setup ends with upload-ready verification diff --git a/docs/exec-plans/active/alpha-rollout-data-loop-plan.md b/docs/exec-plans/active/alpha-rollout-data-loop-plan.md deleted file mode 100644 index e41907fc..00000000 --- a/docs/exec-plans/active/alpha-rollout-data-loop-plan.md +++ /dev/null @@ -1,365 +0,0 @@ -# Execution Plan: Alpha Rollout and Data Loop Activation - - - -**Status:** In Progress -**Created:** 2026-03-18 -**Goal:** Move selftune from “mechanics built” to “confidence building” by shipping a consent-based alpha rollout and a real multi-user data loop, while only fixing the dashboard/data-integrity issues that block trustworthy testing. - -## Status Update — 2026-03-18 - -This plan has partially executed. - -- **Phase A:** substantially complete - - runtime identity landed in `/api/health` and the dashboard footer - - hermetic path overrides now cover config/log/Claude/OpenClaw roots - - the dev probe is stable again and no longer mutates `bun.lock` - - rebuild preflight now blocks lossy rebuilds and reports SQLite-only row counts -- **Phase B:** complete for the current onboarding slice - - alpha config/identity flow shipped - - explicit consent/email flow is documented for the agent-facing init workflow - - raw prompt/query text consent wording is now aligned with the friendly alpha cohort - - plain `selftune init --force` preserves existing alpha enrollment -- **Phase C:** complete (cloud-realigned, hardened) - - the initial D1 schema/type/doc spike landed, then fully realigned to cloud API - - standalone Worker/D1 scaffold removed; pipeline targets `POST /api/v1/push` on the cloud API - - auth model: `st_live_*` API keys via Bearer header - - lossless canonical upload staging table (`canonical_upload_staging`) with single monotonic cursor - - `stage-canonical.ts` stages SQLite-backed canonical records + evolution evidence + orchestrate_runs - - deterministic `execution_fact_id` and `evidence_id` generation during staging - - `build-payloads.ts` reads from staging table, produces V2 canonical push payloads - - HTTP client with Bearer auth and fail-open behavior (never throws) - - flush engine: 409 (duplicate) treated as success, 401/403 as non-retryable auth errors - - orchestrate_runs now staged and included in V2 push payloads - - telemetry contract hardened with Zod schemas (`PushPayloadV2Schema` with `min(0)` arrays) - - cloud API stores lossless `raw_pushes` before normalizing into canonical Postgres tables - - `selftune alpha upload [--dry-run]` CLI command - - upload step wired into `selftune orchestrate` (step 5, fail-open) - - `selftune status` and `selftune doctor` show alpha queue health - - e2e integration tests for the full upload pipeline - -The next implementation target is **Phase D: Analysis Loop for Marginal Cases**. - ---- - -## Executive Summary - -The office-hours synthesis changes the priority order. - -The main problem is not “build more product surface.” The main problem is that selftune still lacks enough real-world data to know what good looks like across users, skills, and workflows. - -That means the next move should **not** be “start the entire dashboard-data-integrity-recovery plan end-to-end.” That plan is valid, but only part of it is a prerequisite for alpha. - -The right sequence is: - -1. Finish the **remaining trust-floor follow-ons** only where they still block alpha. -2. Treat the **consentful alpha onboarding flow** as landed for the current slice. -3. Build the **remote data pipeline** for opted-in alpha users. -4. Create a **tight operator loop** for Daniel to inspect marginal cases and learn from them. -5. Then return to the deeper dashboard/runtime cleanup that is not blocking alpha. - ---- - -## Recommendation on the Existing Recovery Plan - -**Do not start the full** [dashboard-data-integrity-recovery.md](dashboard-data-integrity-recovery.md) **first.** - -Start only the parts of it that are direct alpha prerequisites: - -- Phase 0: runtime identity and dev-server truth -- Phase 1: hermetic tests / proof harnesses -- Phase 2: lossy-rebuild guardrails and backup honesty - -Defer the rest until after alpha data collection is live: - -- WAL-based SSE freshness cleanup -- broader dashboard semantic cleanup -- deeper documentation realignment beyond what alpha needs - -Reason: Ray’s synthesis says the bottleneck is confidence from data, not more mechanics. But alpha data is only useful if the data path is trustworthy. - ---- - -## Planning Inputs - -- office-hours-2026-03-18-synthesis.md (external strategy document) -- [dashboard-data-integrity-recovery.md](dashboard-data-integrity-recovery.md) -- [cloud-auth-unification-for-alpha.md](cloud-auth-unification-for-alpha.md) - ---- - -## Target State - -- Daniel can onboard 3-5 alpha users with explicit consent in minutes. -- Each alpha user has a stable local identity stored in `~/.selftune/`. -- Opted-in alpha data uploads to a shared backend with enough fidelity to analyze false positives, false negatives, and marginal cases. -- Local dashboards and stores are trustworthy enough that Daniel can validate what happened during alpha sessions. -- Tests and proofs cannot pollute the real operator store. -- Rebuild/backfill cannot silently drop recent data. - ---- - -## Execution Order - -### Phase A: Alpha Trust Floor - -**Status:** Substantially complete - -**Priority:** Critical -**Effort:** Medium -**Risk:** Low - -This phase is the minimum cut of the dashboard recovery work required before recruiting testers. - -**Scope:** - -1. Expose runtime identity in `/api/health` and the dashboard UI. Completed. -2. Fix the `bun run dev` backend-health probe and startup race baseline. Probe fixed; startup wait is still optional follow-on work. -3. Make test/proof runs hermetic with environment-overridable storage roots. Substantially complete. -4. Add rebuild preflight/guardrails so recent SQLite-only rows cannot be silently discarded. Completed. - -**Why this phase exists:** - -- alpha data is useless if Daniel cannot tell which workspace/server he is looking at -- alpha data is dangerous if tests can leak into the real store -- alpha confidence collapses if rebuild can delete recent rows - -**Completion criteria:** - -- Daniel can identify workspace, DB path, log path, and watcher mode from the running dashboard -- `bun run dev` and `selftune dashboard` no longer create mystery backend mismatches -- proof/test runs leave `~/.selftune` and `~/.claude` untouched -- destructive rebuild aborts when it would be lossy - ---- - -### Phase B: Consentful Alpha Onboarding - -**Status:** Complete for current scope - -**Priority:** Critical -**Effort:** Medium -**Risk:** Medium - -**Primary outcome:** `selftune init` becomes the alpha enrollment point. - -**Files likely involved:** - -- `cli/selftune/init.ts` -- `cli/selftune/types.ts` -- `cli/selftune/constants.ts` -- `skill/workflows/Initialize.md` -- `skill/SKILL.md` -- config/helpers under `cli/selftune/` - -**Changes:** - -1. Add an explicit alpha-consent flow during init: - - explain that this is an alpha - - explain what data is shared - - explain that the purpose is improving selftune -2. Collect: - - email - - display name or optional label - - consent timestamp - - alpha participation flag -3. Persist a stable local user identity in `~/.selftune/`. -4. Keep the flow simple and skippable: - - opted-in alpha user - - local-only user -5. Update the agent-facing init docs to reflect the exact flow. - -**Non-goals:** - -- full public-launch anonymization -- enterprise-grade privacy workflows - -**Completion criteria:** - -- a new alpha user can complete init and enrollment in under 5 minutes -- identity and consent are stored locally and inspectable -- the skill docs tell the agent how to explain the alpha clearly - ---- - -### Phase C: Remote Alpha Data Pipeline - -**Status:** Complete - -**Priority:** Critical -**Effort:** Large -**Risk:** Medium - -**Primary outcome:** opted-in alpha data reaches a shared backend Daniel can analyze. - -**Current state:** fully implemented. Local queue, payload builders, HTTP transport, CLI surface, orchestrate integration, and operator diagnostics are all shipped with 80 passing tests. The standalone Cloudflare Worker/D1 scaffold was replaced with direct integration into the existing cloud API's V2 push endpoint (`POST /api/v1/push`), authenticated with `st_live_*` API keys. - -**Design direction (resolved):** - -- The initial Cloudflare/D1 direction from the synthesis was evaluated and scaffolded, but was replaced with the existing cloud API to reduce operational surface and unify authentication -- Upload from opted-in clients only, authenticated with `st_live_*` API keys via Bearer header -- Local SQLite as source-of-truth cache, cloud API (Neon Postgres) as analysis sink - -**Files likely involved:** - -- new remote sync/upload module under `cli/selftune/` -- `cli/selftune/orchestrate.ts` or a dedicated uploader command/scheduler -- `cli/selftune/contribute/` if reused -- `cli/selftune/types.ts` -- docs and init workflow - -**Changes:** - -1. Define the alpha upload contract: - - user ID - - agent/platform metadata - - skill invocation facts - - prompt/query references needed for false positive / false negative analysis - - evolution outcomes where relevant -2. Decide upload timing: - - immediate best-effort - - periodic batch - - explicit sync -3. Add local queueing / retry behavior for failed uploads. -4. Add a simple operator view or CLI for upload status. -5. Keep consent enforcement local and explicit. - -**Completed sub-split for this phase:** - -1. local upload queue + watermark tracking -2. SQLite-backed canonical upload staging (`stage-canonical.ts`) + payload builders -3. cloud API V2 push integration (replaced Worker/D1 direction) -4. upload-status visibility for operators - -**Completion criteria:** - -- Daniel can query remote data by user, time window, and skill -- failed uploads are visible and retryable -- an opted-out user sends nothing upstream - ---- - -### Phase D: Analysis Loop for Marginal Cases - -**Priority:** High -**Effort:** Medium -**Risk:** Medium - -**Primary outcome:** Daniel can turn alpha data into learning, not just storage. - -Detailed spike: [phase-d-marginal-case-review-spike.md](phase-d-marginal-case-review-spike.md) - -**Changes:** - -1. Build the four-quadrant analysis view around: - - true positive - - false positive - - false negative - - true negative -2. Prioritize operator views for: - - likely false negatives - - likely false positives - - ambiguous/marginal cases -3. Add a lightweight review mechanism for marginal cases: - - thumbs up/down - - accepted/rejected label - - optional note -4. Store those labels so future eval/evolution work can use them. - -**Important note:** - -This does **not** need to be a polished end-user product first. A Daniel-only operator surface is enough for the first cohort. - -**Completion criteria:** - -- Daniel can review and label marginal cases from alpha users -- labels are stored with enough context to feed later eval/evolution improvements - ---- - -### Phase E: Alpha Cohort Operations - -**Priority:** High -**Effort:** Small -**Risk:** Low - -**Operator assets:** maintain the invite script, onboarding checklist, and -cohort tracker outside the public OSS repo - -**Primary outcome:** the first 3-5 testers are actually live. - -**Changes:** - -1. Prepare a short alpha invite script and install script. -2. Create a tester checklist: - - install - - init - - consent - - verify upload - - run normal work -3. Add a simple internal tracker: - - who is active - - when they were onboarded - - whether uploads are flowing - - notable skill failures or wins -4. Respond to Ray and any other volunteers with the alpha setup flow. - -**Completion criteria:** - -- 3-5 alpha users are onboarded -- at least 2 are generating real data regularly -- Daniel can inspect their uploads without custom debugging - ---- - -### Phase F: Return to the Deferred Recovery Work - -**Priority:** Medium -**Effort:** Medium -**Risk:** Medium - -After alpha data is flowing, resume the deferred parts of the dashboard recovery plan: - -- WAL-driven SSE freshness -- broader dashboard semantic cleanup -- final documentation alignment - -This work still matters, but it should follow the data loop, not precede it. - ---- - -## Completed Agent Splits - -### Phase C (completed 2026-03-18) - -Wave 1 (parallel): - -1. **Agent 1:** Queue + watermark storage (20 tests) -2. **Agent 2:** Payload builder from SQLite (19 tests) -3. **Agent 3:** HTTP client + flush engine (15 tests) -4. **Agent 4:** Cloud API integration (replaced standalone Worker scaffold) (17 tests) - -Wave 2 (after Wave 1): 5. **Agent 5:** CLI + orchestrate integration (10 tests) 6. **Agent 6:** Upload status + doctor diagnostics (17 tests) - -### Next split suggestion - -Phase D is the next active target: - -1. **Agent 1:** Four-quadrant analysis view (TP/FP/FN/TN) -2. **Agent 2:** Labeling + review mechanism -3. **Agent 3:** Operator inspection flow (Daniel-only) - ---- - -## Acceptance Criteria for Starting Alpha - -Alpha is ready to begin when all of the following are true: - -- Daniel can trust which runtime/store he is looking at -- tests cannot contaminate real data -- rebuild cannot silently lose fresh rows -- init can enroll a user with explicit consent -- opted-in data can reach the shared backend -- Daniel can inspect marginal cases from at least one non-Daniel user - -Until then, the product is still in internal mechanics mode, not alpha-learning mode. diff --git a/docs/exec-plans/active/alpha-simplification-program.md b/docs/exec-plans/active/alpha-simplification-program.md deleted file mode 100644 index 75a080f8..00000000 --- a/docs/exec-plans/active/alpha-simplification-program.md +++ /dev/null @@ -1,328 +0,0 @@ - - -# Execution Plan: Alpha Simplification Program - -**Status:** In Progress -**Created:** 2026-03-19 -**Goal:** Reduce coordination tax during alpha by freezing optional breadth, deleting redundant architecture, and converging on one narrow happy path that is easier to ship, debug, and maintain. - -## Status Update — 2026-03-20 - -This plan has partially executed. - -**Landed already:** - -- Claude Code is now explicitly the primary platform for active support, while Codex/OpenCode/OpenClaw are labeled experimental across the CLI, README, SKILL surface, and AGENTS docs. -- Alpha remote ingest is concentrated on the main cloud API + Neon path; the earlier sidecar/Worker direction was replaced by the current `POST /api/v1/push` path, canonical upload staging, and `SELFTUNE_ALPHA_ENDPOINT`. -- Major SQLite-primary read paths are already in place: architecture docs describe SQLite as the primary operational store, and core dashboard/status/report surfaces query SQLite rather than reading JSONL directly. -- Dashboard runtime identity and freshness honesty improved materially: the runtime footer and Status page expose workspace/db/log/config paths and warn when the server is still using legacy JSONL watcher invalidation. - -**Partially landed / still mixed:** - -- SQLite-primary is not fully complete. Dashboard live invalidation still watches JSONL logs, and some modules still read JSONL directly on transitional or non-core paths. -- The “one honest dashboard story” is only partially complete. Runtime identity and watcher-mode warnings landed, but the live-freshness story is still mixed and some design docs no longer match the current implementation. -- Optional breadth is mostly frozen in labels and messaging, but not yet consistently enforced as a planning rule across all active work. - -**Still open:** - -- Cloud auth unification is not done. Local alpha identity still behaves as a first-class local model, and the repo still carries an active auth-unification plan to converge browser auth, upload auth, and local cached identity. -- Duplicate/obsolete paths and stale docs remain in the tree. -- JSONL-driven runtime invalidation remains open until the dashboard fully cuts over to SQLite WAL live updates. - -This plan should now be treated as an in-progress simplification program with some decisions already landed, some partially landed, and a smaller set of unresolved authority/auth/freshness issues still blocking the end-state. - -## Problem Statement - -selftune is moving slowly because too many changes cross too many unsettled boundaries. - -Today the project is simultaneously acting as: - -- a local agent skill -- a telemetry/normalization pipeline -- a dashboard/operator surface -- a cloud product with auth, uploads, and analysis - -That would be manageable if the boundaries were settled. They are not. - -The current drag comes from unresolved duplication and partial migrations: - -- JSONL vs SQLite vs cloud as “source of truth” -- browser auth vs API auth vs alpha auth -- local product vs cloud product -- agent-facing docs vs CLI behavior -- primary platform vs experimental platforms - -The result is that a small feature often becomes: - -- CLI work -- workflow doc work -- local schema work -- dashboard contract work -- cloud ingest work -- operator semantics work - -This is why progress feels slow. - -## Principle - -For alpha, selftune should optimize for: - -- one primary platform -- one local runtime path -- one cloud auth model -- one ingest path -- one explanation of what the system is doing - -Everything else should be frozen, deferred, or explicitly downgraded to experimental. - -## Target Alpha Shape - -### Keep - -- Claude Code as the primary alpha platform -- SQLite as the local runtime/query store -- cloud upload to the main cloud API + Neon -- Neon Auth as the canonical user/session model -- product-owned upload credentials tied to cloud users -- one dashboard path that reflects the actual current system -- the agent-first skill surface for the primary workflows - -### Freeze - -- Codex/OpenCode/OpenClaw architecture work -- new dashboard surfaces that do not help alpha learning -- new eval/evolution sophistication that is not required for current alpha decisions -- new auth variants -- new ingestion backends - -### Delete or Defer - -- runtime dependence on JSONL watchers -- duplicate auth stacks -- stale workflows/docs for unsupported paths -- ambiguous “source of truth” language -- optional architecture branches that are not serving the current alpha - -## Simplification Decisions - -### Decision 1: One Primary Platform - -**Current status:** Substantially landed in the current repo surface. - -**Decision:** Claude Code is the only first-class platform during alpha. - -Implications: - -- Claude Code paths get active maintenance -- Codex/OpenCode/OpenClaw remain explicitly experimental -- no new architectural work should be justified by experimental adapters during alpha - -Follow-through: - -- mark non-Claude adapters as frozen for alpha -- stop routing roadmap-critical decisions through multi-platform generality - -### Decision 2: SQLite-Primary Local Runtime - -**Current status:** Partially landed. - -**Decision:** SQLite is the only local runtime/query source of truth. - -Implications: - -- dashboard reads from SQLite -- local status/doctor/report queries read from SQLite -- JSONL remains backup/export/input material, not runtime truth - -Follow-through: - -- no runtime freshness logic should depend on JSONL watchers -- JSONL becomes archival/recovery/input material only -- docs must stop implying equal status between JSONL and SQLite - -### Decision 3: One Cloud Ingest Path - -**Current status:** Landed for the current alpha upload path. - -**Decision:** Alpha data goes to the main cloud API and Neon. No parallel worker/D1 path for alpha. - -Implications: - -- one remote store -- one auth boundary -- one operator query surface - -Follow-through: - -- remove or freeze sidecar remote-ingest experiments for alpha -- keep `telemetry-contract` authoritative, but keep ingestion concentrated in one backend - -### Decision 4: One Cloud Auth Story - -**Current status:** Not landed yet. - -**Decision:** Neon Auth owns user/session identity. Upload credentials are product-owned credentials tied to those cloud users. - -Implications: - -- alpha users are cloud users -- local alpha identity becomes cached state, not source of truth -- browser auth and upload auth resolve into one user/org graph - -Follow-through: - -- do not keep a parallel direct Better Auth product auth stack -- do not assume custom Better Auth plugin paths are the right long-term boundary just because Neon Auth uses Better Auth under the hood - -### Decision 5: One Honest Dashboard Story - -**Current status:** Partially landed. - -**Decision:** The dashboard must clearly say what it is showing and what freshness model it uses. - -Implications: - -- no mixed implicit semantics -- no “recent activity” labels when the data source is actually older audit-only state -- no mystery backend/process identity - -Follow-through: - -- preserve runtime identity and watcher-mode indicators -- prefer explicit labels over ambiguous aggregation - -## Concrete Cut List - -### Cut Now - -- New platform-generalization work for non-Claude adapters -- Additional D1/worker architecture for alpha telemetry -- Auth work that preserves both Neon Auth and a second product auth stack -- Dashboard features that depend on unresolved semantics - -### Cut Soon - -- JSONL-driven runtime invalidation -- stale workflow instructions for removed or legacy paths -- duplicate contract definitions where one package should be authoritative - -### Keep Investing In - -- upload reliability -- operator review tools -- marginal-case analysis -- auth unification -- data integrity -- agent-facing workflow accuracy - -## Execution Phases - -### Phase 0: Freeze Optional Breadth - -**Status:** Partially complete. - -**Priority:** Critical -**Effort:** Small -**Risk:** Low - -Actions: - -- mark non-Claude platform work as frozen for alpha -- mark sidecar remote-ingest experiments as out of scope for alpha -- stop accepting roadmap arguments that depend on multi-platform breadth - -Completion criteria: - -- active plans stop assuming equal investment across platforms -- open work is framed around the Claude Code alpha path - -### Phase 1: Remove Duplicate Authority - -**Status:** In progress. - -**Priority:** Critical -**Effort:** Medium -**Risk:** Medium - -Actions: - -- converge auth around the cloud-auth unification plan -- continue the SQLite-primary cleanup -- remove stale source-of-truth language in docs - -Completion criteria: - -- one answer for “where is local truth?” -- one answer for “who is the user?” -- one answer for “where does alpha data go?” - -### Phase 2: Delete Obsolete Paths - -**Status:** Not complete. - -**Priority:** High -**Effort:** Medium -**Risk:** Medium - -Actions: - -- remove dead or misleading commands/docs -- remove runtime dependencies on transitional code paths once replacements are proven -- archive or explicitly label experimental modules instead of pretending they are near-equal peers - -Completion criteria: - -- fewer paths to do the same thing -- fewer stale docs -- fewer “temporary” branches still in the critical path - -### Phase 3: Tighten the Alpha Kernel - -**Status:** Partially complete. - -**Priority:** Critical -**Effort:** Medium -**Risk:** Low - -Define the alpha kernel as the only thing that must feel great: - -- init/enroll -- observe -- upload -- inspect -- label marginal cases -- improve core skill behavior - -Everything else is secondary until the kernel is fast and trustworthy. - -Completion criteria: - -- a new alpha user can be onboarded quickly -- uploads are trustworthy -- Daniel can inspect real data quickly -- the core improvement loop is understandable - -## Success Metrics - -- A typical alpha-facing change touches fewer subsystems than it does today. -- The team can explain local truth, cloud truth, and auth truth in one sentence each. -- The number of “experimental but still on the critical path” modules goes down. -- The time from bug discovery to confident fix gets shorter. -- The number of plan/doc/code mismatches drops materially. - -## Anti-Goals - -Do not use this plan as justification for: - -- another broad rewrite -- new generic abstractions -- new cross-platform frameworks -- more architecture before deleting old architecture - -The point is subtraction, not sophistication. - -## Related Plans - -- [alpha-rollout-data-loop-plan.md](/Users/danielpetro/conductor/workspaces/selftune/miami/docs/exec-plans/active/alpha-rollout-data-loop-plan.md) -- [cloud-auth-unification-for-alpha.md](/Users/danielpetro/conductor/workspaces/selftune/miami/docs/exec-plans/active/cloud-auth-unification-for-alpha.md) -- [dashboard-data-integrity-recovery.md](/Users/danielpetro/conductor/workspaces/selftune/miami/docs/exec-plans/active/dashboard-data-integrity-recovery.md) diff --git a/docs/exec-plans/active/consumer-creator-progressive-disclosure-plan.md b/docs/exec-plans/active/consumer-creator-progressive-disclosure-plan.md deleted file mode 100644 index 6a0314ba..00000000 --- a/docs/exec-plans/active/consumer-creator-progressive-disclosure-plan.md +++ /dev/null @@ -1,424 +0,0 @@ -# Consumer / Creator Progressive Disclosure Plan - -**Status:** Active -**Date:** 2026-04-01 -**Owner:** Daniel Petro -**Builds on:** external strategy docs that are not checked into this repo - -## Purpose - -Translate the 2026-04-01 strategy update into an implementation plan that fits the current selftune codebase, dashboard contract, and cloud rollout constraints. - -The strategy is directionally correct: -- one product, not two products -- consumer value should be mostly invisible -- creator value should be comparison-first -- detail should be drill-down, not the landing page -- the long-term business is the creator ← user eval data pipeline - -This plan adds the engineering constraints the strategy document does not capture explicitly. - -It now also incorporates the new companion spec from the external strategy workspace: -- `contribute-pipeline-spec.md` - -## Implementation Progress - -Completed groundwork on 2026-04-01: -- creator-side `selftune.contribute.json` management -- end-user creator-directed contribution preferences -- per-skill preview of privacy-safe relay payloads -- shared creator-directed signal builder -- local SQLite staging for approved creator-directed signals during `sync` -- explicit `selftune contributions upload` relay flush for staged rows -- eval cold-start onboarding for installed skills (`--list-skills` readiness + `--auto-synthetic` fallback) -- bulk creator config scaffolding for installed skill suites (`selftune creator-contributions enable --all --prefix `) -- cold-start architecture suspicion in `selftune eval family-overlap`, including installed-surface overlap and synthetic sibling-confusion probes -- replay-backed routing validation scaffolding with persisted validation provenance and skill-report provenance badges - -Still pending: -- creator aggregate analytics surface -- proposal generation from contributor aggregates - -## Next Up - -Immediate next steps, in order: - -1. **State Change creator eval onboarding** - - Follow-on polish only: cold-start onboarding is already in place, and `family-overlap` now flags early architecture suspicion before trusted telemetry converges. - - Next refinement is tuning static-signal thresholds from real creator sessions. - -1. **Cloud relay ingest** - - Completed in the cloud repo: the creator-directed relay ingest path now matches `selftune contributions upload` and stays separate from alpha upload canonical push. - -1. **Creator community analytics** - - Continue validating the new aggregate/community layer against real relay data. - - Do not mix creator-community signals into local trust metrics. - -1. **Aggregate proposal generation** - - Validate proposal quality and thresholding now that relay ingest and aggregate reads are live. - -## Strategy Review - -### What is correct - -1. **Progressive disclosure is the right product model.** - The current repo already has the right primitive split: - - CLI surfaces for low-friction status - - overview page for multi-skill supervision - - per-skill report for drill-down - -2. **The overview should become comparison-first.** - The present [Overview.tsx](../../../apps/local-dashboard/src/pages/Overview.tsx) is still supervision-heavy. That is useful, but it is not yet the creator landing page Ray described. - -3. **Consumers should not need the dashboard.** - The current [status.ts](../../../cli/selftune/status.ts) is the right place to deliver “seen and not heard” value. - -4. **The creator-to-user eval pipeline is the moat.** - This aligns with: - - [alpha-rollout-data-loop-plan.md](./alpha-rollout-data-loop-plan.md) - - [deterministic-routing-validation.md](./deterministic-routing-validation.md) - -### Newly completed on this branch - -1. **Cold-start architecture suspicion now exists in OSS.** - The repo no longer waits exclusively on trusted live overlap counts before flagging sibling-family packaging problems. - -2. **Replay provenance is now a first-class product concept.** - Routing validation can persist and surface whether a proposal was replay-validated, judge-validated, or only passed structural guards. - -### What needs adjustment - -1. **“Confidence” cannot be treated as a universal trust number yet.** - In the current DB, confidence coverage is incomplete and varies by ingest path. The creator grid can show confidence, but only when coverage is high enough and the label is explicit: - - use `routing confidence` - - suppress or soften the metric when confidence coverage is weak - -2. **The overview and skill report must share the same trust model.** - This is now partly fixed in code, but it needs to be treated as a formal invariant going forward. - -3. **“Watched skills” is not yet a first-class concept.** - Ray’s “these are the ten I care about” mental model is right, but the repo does not currently have persistent watchlist/pinning state. Phase 1 should fake this with a derived default. Phase 2 can add explicit watchlist persistence. - -4. **The cloud eval pipeline is not ready for product promises yet.** - Alpha upload is still blocked by the cloud V2 API mismatch tracked in: - - [tech-debt-tracker.md](../tech-debt-tracker.md) - -5. **The new contribution pipeline collides with the current `contribute` product surface.** - In the current repo: - - `selftune push` means user -> own cloud dashboard - - `selftune contribute` means user -> community pool / anonymized export - The new spec adds a third meaning: - - end-user -> relay -> specific creator - - This is strategically coherent, but the command and documentation model will be confusing unless we explicitly split: - - consumer contribution preferences - - creator-side contribution enablement - - existing community contribution/export - -## Product Principles - -1. **One trust model, three surfaces** - - consumer CLI summary - - creator comparison grid - - creator drill-down report - -2. **Numbers first, explanation second** - - comparison surfaces should lead with metrics - - detail pages can carry narrative and evidence - -3. **Real usage first** - - internal selftune eval traffic never masquerades as ordinary usage - - repaired history must be labeled when surfaced - -4. **No product bifurcation** - - no separate consumer app - - no separate creator app - - only different defaults and density levels - -5. **One data plane, multiple contribution modes** - - SQLite-first local runtime remains the foundation - - alpha upload / cloud push remains the generic remote transport - - creator-directed contribution is a product layer on top of the cloud relay, not a separate telemetry stack - -## Workstreams - -### Workstream A: Shared Trust Model Foundation - -**Goal:** keep CLI, overview, skill report, and later cloud analytics on one vocabulary. - -**Tasks** -- Extract the current trust computation rules from [skill-report.ts](../../../cli/selftune/routes/skill-report.ts) and [queries.ts](../../../cli/selftune/localdb/queries.ts) into shared helpers. -- Make `overview`, `status`, and per-skill report consume the same: - - operational observation filtering - - legacy exclusion rules - - repaired-history handling - - dedupe rules for “previously missed” cases -- Add focused tests that lock this invariant for a sample skill like `Art`. - -**Files** -- [cli/selftune/localdb/queries.ts](../../../cli/selftune/localdb/queries.ts) -- [cli/selftune/routes/overview.ts](../../../cli/selftune/routes/overview.ts) -- [cli/selftune/routes/skill-report.ts](../../../cli/selftune/routes/skill-report.ts) -- [cli/selftune/status.ts](../../../cli/selftune/status.ts) -- [cli/selftune/dashboard-contract.ts](../../../cli/selftune/dashboard-contract.ts) - -**Exit criteria** -- A skill cannot appear “at risk” in overview and “healthy” in detail unless the UI explicitly says the two views answer different questions. -- `status`, overview, and skill report agree on core counts for the same trusted working set. - -### Workstream B: Consumer Surface - -**Goal:** make the default selftune experience useful without opening the dashboard. - -**Scope** -- rewrite `selftune status` to a short, agent-friendly summary line first -- keep detailed output available below or behind a verbose mode -- add a weekly summary mechanism driven by existing scheduling/orchestration, not a separate notification system - -**Tasks** -- Redesign [status.ts](../../../cli/selftune/status.ts) output so the first line answers: - - how many skills are being watched - - how many improved recently - - whether anything needs attention -- Add a consumer-first summary string that agents can quote directly. -- Add optional weekly summary output from the orchestrate/schedule path rather than OS-native notification complexity in v1. -- Document the new consumer flow in: - - [skill/workflows/Initialize.md](../../../skill/workflows/Initialize.md) - - [skill/workflows/Orchestrate.md](../../../skill/workflows/Orchestrate.md) - - [skill/workflows/Dashboard.md](../../../skill/workflows/Dashboard.md) - -**Non-goals** -- native desktop notifications -- a consumer-only dashboard mode - -**Exit criteria** -- a first-time user can understand value from `init -> sync -> status` without touching the dashboard -- the status output is short enough for an agent to relay naturally - -### Workstream C: Creator Landing Page - -**Goal:** make overview a comparison-first creator surface. - -**Scope** -- replace “supervision dashboard first” with “skill comparison grid first” -- keep supervision as a secondary module, not the hero - -**Required columns** -- skill name -- trigger rate -- routing confidence -- sessions watched -- last evolution -- status - -**Important metric rules** -- `trigger rate` must use trusted operational observations -- `routing confidence` must be hidden or visibly caveated when confidence coverage is weak -- `status` must use the shared trust model from Workstream A - -**Tasks** -- Add a comparison-grid section to [Overview.tsx](../../../apps/local-dashboard/src/pages/Overview.tsx) and corresponding API support in [overview.ts](../../../cli/selftune/routes/overview.ts). -- Decide an initial `Watching` subset: - - Phase 1: derive from at-risk/improving/uncertain plus recent creator interest - - Phase 2: persist a user-managed watchlist -- Demote the current attention/supervision feed below the comparison surface. -- Keep drill-down clicks routing to `/skills/:name`. - -**Files** -- [apps/local-dashboard/src/pages/Overview.tsx](../../../apps/local-dashboard/src/pages/Overview.tsx) -- [cli/selftune/routes/overview.ts](../../../cli/selftune/routes/overview.ts) -- [cli/selftune/dashboard-contract.ts](../../../cli/selftune/dashboard-contract.ts) - -**Exit criteria** -- overview first screen answers “which skills need my attention and which are doing well?” -- the page is scannable without opening a single detail page - -### Workstream D: Skill Detail Compression - -**Goal:** keep the existing trust-first detail page, but make it feel like Layer 3 of the creator experience. - -**Tasks** -- Keep current sections, but tune the page for drill-down: - - big metrics at top - - prompt evidence - - evolution evidence - - FAQ/help -- Make education/support content progressively disclosed: - - keep the `How this works` entry point - - auto-collapse or reduce onboarding density after first exposure -- Ensure the detail page always answers: - - what happened - - what changed - - should I act - -**Files** -- [apps/local-dashboard/src/pages/SkillReport.tsx](../../../apps/local-dashboard/src/pages/SkillReport.tsx) -- [packages/ui/src/components/SkillReportPanels.tsx](../../../../packages/ui/src/components/SkillReportPanels.tsx) -- [packages/ui/src/components/EvidenceViewer.tsx](../../../packages/ui/src/components/EvidenceViewer.tsx) - -**Exit criteria** -- detail page feels like a drill-down from the comparison grid, not a separate product -- `observed` never reads as `healthy` - -### Workstream E: Creator ← User Eval Pipeline - -**Goal:** prepare the business-critical data loop without overpromising it in the near term. - -**Dependencies** -- cloud alpha upload V2 API compatibility -- stable canonical push payload acceptance -- clear provenance for replay-validated vs judge-validated evidence - -**New constraints from the contribution spec** -- no raw prompt content in the creator-facing contribution schema -- static creator config, not executable creator code -- privacy model enforced at the relay, not by creator tooling -- end-user opt-in must be post-value and per-skill -- creator analytics must distinguish: - - creator's own local evidence - - contributor aggregate evidence - - replay-validated / judge-validated proposal confidence - -**Tasks** -- Fix the cloud-side alpha-upload payload mismatch tracked in [tech-debt-tracker.md](../tech-debt-tracker.md). -- Continue the replay-based validation plan from [deterministic-routing-validation.md](./deterministic-routing-validation.md). -- Define the creator-facing cloud analytics contract: - - creator-owned skills - - user aggregate trigger/miss signals - - privacy-safe provenance -- Define the relay contribution contract so it composes with existing push infrastructure rather than bypassing it. -- Add a product naming decision before implementation begins: - - preserve `selftune contribute` for community contribution and introduce a new creator-directed command surface, or - - explicitly migrate/rename the current community contribution feature -- Define the local data model additions for contribution consent/preferences and creator-contribution config discovery. - -**Non-goal for current sprint** -- shipping the full marketplace/team eval pipeline - -**Required design decisions before coding** -1. Command surface split - - recommended: keep current `selftune contribute` semantics for community export compatibility - - introduce separate end-user consent and creator config commands for the creator-directed relay pipeline -2. Config discovery model - - how `selftune.contribute.json` is discovered from installed skills - - how multiple installed skills request contribution independently -3. Cloud boundary - - whether creator-directed contribution signals ride the current canonical push path or a distinct relay ingest contract -4. Privacy/audit model - - what is stored locally - - what is queued for upload - - what is retained in relay storage - -### Workstream F: Contribution Pipeline Productization - -**Goal:** turn the new contribution spec into an implementable product slice without collapsing existing `push` and `contribute` semantics. - -**Scope** -- creator-side configuration and packaging -- end-user opt-in/preferences -- local signal preparation -- relay-facing contract definition -- creator dashboard integration planning - -**Tasks** -- Define the v1 command map. - Recommended shape: - - keep `selftune push` for user -> own dashboard - - keep current `selftune contribute` for community/export until migrated deliberately - - introduce a separate surface for creator-directed sharing, e.g. `selftune contributions` for end-user preferences and `selftune creator-contributions` or equivalent for creator-side setup -- Add workflow/doc propagation requirements for the new commands and config: - - `skill/SKILL.md` - - `skill/workflows/Contribute.md` or split workflow docs - - `README.md` - - `docs/design-docs/alpha-remote-data-contract.md` -- Define `selftune.contribute.json` ownership and validation rules in the OSS repo. -- Specify how local classifier outputs map onto a relay-safe schema and how that differs from existing `ContributionBundle`. -- Design the creator dashboard "Community" layer as a separate evidence source, not mixed into local trust metrics. - -**Exit criteria** -- there is no ambiguity about the difference between: - - personal cloud push - - community contribution - - creator-directed contribution -- the new creator-directed pipeline can be built without breaking existing alpha upload or community contribution behavior - -### Workstream G: Alpha Operations - -**Goal:** turn the strategy’s human tasks into explicit product feedback loops. - -**Tasks** -- Pair with Ray on SC skills using the creator comparison view, not the old detail-heavy dashboard. -- Capture the exact ten-skill “watching” list mental model during the session. -- Onboard Robert as a second creator alpha after the comparison view and status line are stable. -- Convert the findings into: - - dashboard tweaks - - CLI wording tweaks - - trust-model adjustments - -## Sequence - -### Phase 1: Trust + Consumer Surface - -- Workstream A -- Workstream B - -### Phase 2: Creator Landing Page - -- Workstream C -- Workstream D - -### Phase 3: Cloud Data Loop Foundation - -- Workstream E -- Workstream F - -### Phase 4: Alpha Validation - -- Workstream G - -## Immediate Implementation Priorities - -1. **Unify command semantics before adding new pipeline UI** - - decide the naming split for creator-directed contribution vs existing `contribute` - - update repo docs accordingly before implementation spreads - -1. **Keep alpha upload as the transport foundation** - - creator-directed contribution should build on the cloud relay / upload architecture already being established - - do not create a second unrelated remote ingest stack - -1. **Keep creator contribution data separate from local trust** - - local skill health remains grounded in the creator/user's own trusted operational observations - - contributor aggregate data should augment creator decision-making, not silently alter local trust scores - -1. **Land the comparison-first creator overview before community analytics** - - the creator landing page still needs to be comparison-first even before the full contribution pipeline ships - -## Acceptance Criteria - -1. **Consumer success** - - a user can install selftune, ignore the dashboard, and still see value in `status` - -1. **Creator success** - - a creator can compare skills in one screen before drilling down - -1. **Trust consistency** - - overview, skill detail, and CLI status do not disagree about core health semantics - -1. **Business readiness** - - the repo is structurally ready for the creator ← user eval pipeline, even if the cloud portion is not fully launched yet - -1. **Contribution clarity** -- the product has a clear, non-conflicting story for: - - push - - community contribution - - creator-directed contribution - -## Immediate Next Actions - -1. Land the shared trust-model helper extraction. -2. Redesign `selftune status` around a single summary line plus compact secondary detail. -3. Replace overview’s current hero-first layout with a comparison-first creator grid. -4. Use the Ray pairing session to validate: - - column choice - - watchlist concept - - acceptable confidence labeling diff --git a/docs/exec-plans/active/incremental-report-aggregation-2026-07-18.md b/docs/exec-plans/active/incremental-report-aggregation-2026-07-18.md index e70109b7..7c9fb66f 100644 --- a/docs/exec-plans/active/incremental-report-aggregation-2026-07-18.md +++ b/docs/exec-plans/active/incremental-report-aggregation-2026-07-18.md @@ -3,7 +3,7 @@ # Execution Plan: Incremental Report Aggregation **Status:** Phase 1 complete; ingest-time rollups deferred -**Created:** 2026-07-18 +**Created:** 2026-07-18 **Goal:** Replace full-history JavaScript folds in the skill-intelligence and portfolio reports with ingest-time SQL aggregates, so report cost stops growing linearly with session history. ## Executive Summary diff --git a/docs/exec-plans/completed/output-quality-loop-prereqs.md b/docs/exec-plans/completed/output-quality-loop-prereqs.md deleted file mode 100644 index 1f9e3c47..00000000 --- a/docs/exec-plans/completed/output-quality-loop-prereqs.md +++ /dev/null @@ -1,178 +0,0 @@ -# Output Quality Loop Prerequisites - -**Status:** Proposed -**Date:** 2026-03-19 -**Related:** [prd-output-quality-loop.md](/Users/danielpetro/Documents/Projects/FOSS/selftune/strategy/prd-output-quality-loop.md) - -## Purpose - -Do **not** build `selftune evolve output` yet. - -Do the minimum now so the alpha program collects the right data and does not -close off the option to build the output-quality loop well later. - -This is a prerequisite plan, not a feature plan. - -## Why Now - -The output-quality PRD is strategically right but tactically early. - -Current priority remains: - -- trusted alpha onboarding -- reliable local-to-cloud upload -- operator visibility -- real-session review loops - -But if alpha telemetry omits the evidence needed for output-quality learning, -we waste the highest-value learning window. - -## Goal - -Capture enough output-side evidence during alpha that a later -`selftune evolve output` loop can be built from real data rather than guesses. - -## Non-Goals - -- no `selftune evolve output` command yet -- no automated output mutation loop yet -- no output-quality dashboard panel yet -- no output grader rollout yet - -## Required Data Prerequisites - -### 1. Final Output Capture - -For sessions where a skill fires, preserve the best available representation of -what the agent actually produced. - -Prefer, in order: - -- final assistant message text -- generated file references and changed file paths -- structured artifact metadata when available -- attachment or screenshot references when available - -Minimum requirement: - -- enough data to let an operator answer “the skill fired, but what did it produce?” - -### 2. Output Context Linkage - -Every captured output signal should be linkable back to: - -- `session_id` -- `prompt_id` if available -- `skill_invocation_id` -- `skill_name` -- platform / agent type / model -- timestamp - -This is what makes later grading and mutation evidence usable. - -### 3. Artifact References, Not Just Text - -For output-quality work, text alone is often insufficient. - -Capture references to: - -- changed files -- generated markdown/docs/code outputs -- image or screenshot paths when local artifacts exist -- any durable local artifact ID that can be replayed or inspected later - -Do not try to upload huge binaries blindly in the first pass. -Store references and metadata first. - -### 4. Manual Review Hook - -Add a lightweight operator review path for “triggered correctly, output looked bad.” - -Minimum viable form: - -- mark a session or invocation as output-bad -- attach a short note -- preserve the linked output evidence - -This gives real labels before full automation exists. - -### 5. Cloud Queryability - -The cloud side should be able to answer: - -- which skills trigger often but receive poor output feedback -- which invocations are linked to output-bad labels -- what artifacts or outputs were produced for those invocations - -This can start as operator-facing inspection, not polished UI. - -## Suggested Implementation Slices - -### Slice A: Local Evidence Capture - -In `miami`, ensure the local telemetry pipeline preserves: - -- final response text when safely available -- changed file paths -- artifact metadata or attachment references - -Do not block alpha on perfect normalization. -Prefer capture over elegance. - -### Slice B: Canonical Upload Contract Extension - -Extend the alpha upload contract only where needed to preserve: - -- output evidence references -- linked file paths or artifact metadata -- future operator labels for output quality - -Avoid a giant schema expansion. -Add only fields that are clearly useful for later grading or review. - -### Slice C: Cloud Operator Inspection - -In the cloud app, ensure operator surfaces can inspect: - -- invocation -- output evidence -- linked artifacts -- any manual output-quality label - -Start with raw/operator views, not polished product UI. - -### Slice D: Manual Label Seed - -Add a minimal label model for: - -- `output_bad` -- `output_good` -- optional note - -This is enough to seed the later quality loop. - -## Acceptance Criteria - -- For a triggered skill invocation, an operator can inspect what was produced. -- Output evidence is linked to invocation/session identity. -- At least one manual label path exists for “triggered correctly, output was poor.” -- The cloud model preserves enough evidence to support later output-quality grading. -- No major alpha rollout work is blocked on this prerequisite slice. - -## Sequencing - -1. Finish current alpha/auth/upload stabilization. -2. Add output-evidence capture and linkage as a narrow telemetry enhancement. -3. Add minimal operator review/label support. -4. Reassess after the first alpha cohort produces real sessions. -5. Only then decide whether to start full `selftune evolve output`. - -## Decision - -Use the output-quality PRD to influence **what data we keep now**. - -Do **not** treat it as the next implementation milestone until: - -- alpha users are active -- the current trigger/data loop is trusted -- operator review of real outputs is happening diff --git a/docs/exec-plans/completed/telemetry-normalization.md b/docs/exec-plans/completed/telemetry-normalization.md deleted file mode 100644 index 4d3d3f4c..00000000 --- a/docs/exec-plans/completed/telemetry-normalization.md +++ /dev/null @@ -1,446 +0,0 @@ -# Execution Plan: Telemetry Normalization and Canonical Event Model - - - -**Status:** Active -**Created:** 2026-03-10 -**Goal:** Introduce a canonical multi-agent telemetry model that supports local append-only logs, repaired overlays, and a cloud Neon projection without duplicating platform-specific logic across Claude Code, Codex, OpenClaw, and OpenCode. - -## Implementation Status (2026-03-10) - -Implemented in this repo: - -- raw legacy logs remain separate from the canonical event stream -- canonical events now write to `~/.claude/canonical_telemetry_log.jsonl` -- live Claude hooks now emit canonical `prompt`, `skill_invocation`, and `execution_fact` records with deterministic prompt IDs -- replay and batch ingestors now write canonical records to the dedicated canonical log instead of polluting legacy logs -- the canonical schema has been extracted into the internal package `packages/telemetry-contract/` -- `selftune export-canonical` can export canonical records for downstream cloud ingestion - -Still pending: - -- move downstream local analytics fully onto canonical readers/projections -- make the cloud repo consume the extracted contract instead of re-declaring canonical types -- expand stronger source-native IDs and richer lifecycle coverage where the platform supports it - ---- - -## Problem Statement - -The current telemetry model is operational, but it is too adapter-shaped and too thin for long-term maintainability. - -Today: - -1. **Source information is present but not normalized.** - `QueryLogRecord`, `SkillUsageRecord`, and `SessionTelemetryRecord` only carry an optional `source?: string`, while `SelftuneConfig` separately stores `agent_type`. -2. **The same logical session is split across three append-only JSONL logs.** - Queries, skill usage, and session telemetry must be rejoined downstream with heuristics. -3. **Platform adapters emit different semantics into the same fields.** - Examples: `claude_code`, `claude_code_replay`, `codex_rollout`, `codex`, `opencode_json`, `openclaw`. -4. **We lack stable event identity and lineage.** - There is no first-class message ID, turn ID, invocation ID, normalization version, repair provenance, or canonical session origin. -5. **The cloud use case is under-modeled.** - A Neon-backed offering should consume canonical events, not adapter-specific JSONL assumptions. - -If we do not fix this, every new agent adapter and every new observability surface will keep re-solving the same normalization problem. - ---- - -## Verified Current State - -### What We Collect Today - -From the current codebase: - -- `SelftuneConfig.agent_type` captures the local installation's primary agent family. -- `QueryLogRecord.source` is populated by writers such as: - - `claude_code` - - `claude_code_replay` - - `codex` - - `codex_rollout` - - `opencode` - - `opencode_json` - - `openclaw` -- `SessionTelemetryRecord` includes useful execution data: - - `tool_calls` - - `total_tool_calls` - - `bash_commands` - - `skills_triggered` - - `skills_invoked?` - - `assistant_turns` - - `errors_encountered` - - `input_tokens?` - - `output_tokens?` - - `rollout_path?` -- `SkillUsageRecord` captures: - - `skill_name` - - `skill_path` - - `query` - - `triggered` - - `source?` - -### What Is Missing - -- No first-class `platform`, `agent_family`, `capture_mode`, or `ingest_mode` -- No stable `event_id`, `message_id`, `prompt_id`, or `skill_invocation_id` -- No normalized `prompt_kind` or `is_actionable` flag at write time across all adapters -- No per-record `schema_version` -- No repair provenance except the new `source: "claude_code_repair"` overlay rows -- No canonical way to represent explicit vs inferred skill invocation across all platforms -- No shared local+cloud projection layer - ---- - -## Answering the Product Questions - -### Why is this not easy to maintain right now? - -Because the repository treats adapter output as if it were already canonical. It is not. - -The current shape is: - -`adapter output -> raw JSONL -> downstream heuristics` - -It needs to become: - -`adapter output -> canonical normalization -> projections (local UI, local CLI, cloud DB)` - -Right now, maintenance cost shows up as: - -- platform-specific `source` strings leaking into analytics -- repeated filter logic in `status`, `dashboard`, `evals`, `watch`, and repair paths -- reconstruction work happening after ingestion instead of during normalization -- local JSONL assumptions bleeding into cloud concerns - -### Are we collecting data on the source agent as well? - -Partially, but not well enough. - -We currently collect: - -- local install `agent_type` in config -- per-record `source` strings in logs -- some adapter-specific metadata like `rollout_path` - -We do **not** currently collect source-agent identity as a first-class normalized dimension on every event. - -We should explicitly model: - -- `platform`: `claude_code | codex | opencode | openclaw` -- `capture_mode`: `hook | replay | wrapper | batch_ingest | repair` -- `agent_cli`: exact CLI family used to generate the event -- `adapter_version`: normalization/writer version -- `source_session_kind`: `interactive | replayed | synthetic | repaired` - -### What other telemetry do we need? - -At minimum: - -1. **Canonical source metadata** - - `platform` - - `capture_mode` - - `adapter_version` - - `schema_version` - -2. **Session identity** - - `session_id` - - `external_session_id` where different - - `thread_id` / `conversation_id` where available - - `workspace_path` - - repo remote / repo root / branch / commit when available - -3. **Prompt identity** - - `prompt_id` - - `prompt_text` - - `prompt_hash` - - `prompt_kind` (`user`, `meta`, `continuation`, `task_notification`, `teammate_message`, etc.) - - `is_actionable` - -4. **Skill invocation identity** - - `skill_invocation_id` - - `skill_name` - - `skill_path` - - `skill_version_hash` if available - - `invocation_mode` (`explicit`, `implicit`, `inferred`, `repaired`) - - `confidence` - - `matched_prompt_id` - -5. **Execution telemetry** - - tool counts - - tool sequence summary - - error count - - error classes where available - - assistant turn count - - completion / interruption status - - duration - -6. **Model telemetry** - - provider - - model name - - input tokens - - output tokens - - cost estimate when available - -7. **Normalization provenance** - - `normalized_at` - - `normalizer_version` - - `raw_source_ref` - - `repair_applied` - -### Platform Docs Already Worth Encoding - -Official docs verify several source-native fields we should design around up front: - -- **Claude Code** exposes `session_id`, `transcript_path`, `model` on `SessionStart`, `prompt` on `UserPromptSubmit`, `agent_id` / `agent_type` / `agent_transcript_path` on `SubagentStop`, `reason` on `SessionEnd`, plus worktree and pre-compaction events. -- **Codex** documents `codex exec --json` JSONL events including `thread.started`, `turn.*`, `item.*`, `usage`, resumable `SESSION_ID`s, explicit sandbox mode, and per-run model override. -- **OpenCode** documents first-class `Session`, `Message`, and `Part` types in the SDK, session list/export commands, stats/model breakdowns, and programmatic prompt/command APIs against a running server. -- **OpenClaw** documents transport-to-session-key mapping, hook/event automation, command logging, session cleanup, and pruning semantics without rewriting JSONL history. - -That means the normalization layer should be grounded in verified platform contracts, not inferred solely from our current ingestors. - -### Track 0 Verification Snapshot (2026-03-10) - -The implementation contract derived from this snapshot lives in -[`telemetry-field-map.md`](../reference/telemetry-field-map.md). - -**Official source references** - -- Claude Code hooks: -- Codex CLI: -- Codex non-interactive JSONL: -- OpenCode CLI: -- OpenCode SDK: -- OpenClaw hooks: -- OpenClaw agent loop: -- OpenClaw channels/messages: , - -**Observed capture availability on this machine** - -- Claude Code: verified against real local transcript JSONL plus repo hook fixtures -- Codex: verified against real local rollout JSONL plus repo unit tests -- OpenCode: verified against real local legacy session metadata JSON plus repo unit tests -- OpenClaw: verified against repo fixture sessions only; no live `~/.openclaw/agents` tree on this machine - -#### Claude Code - -- **Docs say:** hook payloads expose lifecycle fields we are not preserving today: `session_id`, `prompt`, `model`, `reason`, `agent_id`, `agent_type`, `agent_transcript_path`, and worktree metadata. -- **Observed locally:** transcript lines use camelCase and nested message records such as `sessionId`, `parentUuid`, `permissionMode`, `gitBranch`, `isSidechain`, `uuid`, and nested `message` objects. Real transcript lines are not the same thing as hook payloads. -- **Current code assumes:** `PromptSubmitPayload`, `PostToolUsePayload`, and `StopPayload` only need `session_id`, `transcript_path`, `cwd`, and the last actionable user message. -- **Implication:** the canonical model needs distinct support for hook events vs transcript events, plus explicit subagent lineage, permission mode, worktree state, and stop reason. We should also normalize camelCase transcript fields into canonical IDs instead of treating transcripts as if they already matched hook payload contracts. - -#### Codex - -- **Docs say:** `codex exec --json` emits machine-readable session/turn/item events and exposes session IDs, usage, sandbox settings, approval policy, and model selection. -- **Observed locally:** rollout files on this machine are not using the event names our parser expects. The live files start with `session_meta`, `response_item`, `event_msg`, and `turn_context` records. Nested payloads expose fields like `id`, `cwd`, `git`, `model_provider`, `originator`, `source`, `approval_policy`, `sandbox_policy`, `model`, `user_message`, `token_count`, `agent_reasoning`, and `function_call`. -- **Current code assumes:** both `codex-wrapper.ts` and `codex-rollout.ts` look for `thread.started`, `turn.started`, `turn.completed`, `item.completed`, and `error`. -- **Implication:** the current Codex ingestors are contract-fragile and likely incomplete against real local rollouts. Codex needs to move to a versioned adapter that can parse both documented event streams and the actual rollout schema we observed, with canonical fields for `approval_policy`, `sandbox_policy`, `model`, `git`, and session origin. - -#### OpenCode - -- **Docs say:** OpenCode has first-class session/message/part entities, programmatic prompt/command APIs, export/share flows, and model usage stats. -- **Observed locally:** this machine has legacy session JSON files under `~/.local/share/opencode/storage/session/...`, but the sampled files are metadata-only with keys like `directory`, `id`, `projectID`, `slug`, `time`, `title`, and `version`. No live `opencode.db` was present, and the sampled JSON files did not embed `messages`. -- **Current code assumes:** `opencode-ingest.ts` can read either a SQLite `session` + `message` schema or legacy JSON files with embedded `messages`. -- **Implication:** OpenCode needs a stricter capture-mode split. Metadata-only local session files should not be treated like full transcripts. We likely need to ingest message bodies from the current export/server API path, and we should model export/share lineage separately from local storage snapshots. - -#### OpenClaw - -- **Docs say:** OpenClaw has transport-aware session routing (`sessionKey` vs `sessionId`), hook automation, command logging, cleanup/pruning, and channel/account/session/message dedupe semantics. -- **Observed in repo fixtures:** session files begin with a `session` header and then message lines using roles such as `user`, `assistant`, and `toolResult`. Content blocks include `text`, `thinking`, `toolCall`, and `toolUse`, with `toolCallId` and `isError` on result lines. -- **Current code assumes:** `openclaw-ingest.ts` parses the fixture file format and derives prompt/tool/error data from those message blocks. -- **Implication:** the current OpenClaw ingestor is only covering the transcript layer. The canonical schema should also reserve transport and routing dimensions from the official docs, such as `session_key`, `channel`, `account_id`, and dedupe identifiers, even when those are absent from fixture-only captures. - -### Track 0 Decisions From Verification - -- `platform`, `capture_mode`, `source_session_kind`, `session_id`, and `raw_source_ref` should be required on every canonical event. -- `prompt_id`, `prompt_kind`, `is_actionable`, and `matched_prompt_id` should be canonicalized at normalization time, not inferred separately in dashboard/status/evals code. -- `model`, `provider`, `approval_policy`, `sandbox_policy`, `cwd`, `branch`, `git`, `agent_id`, `agent_type`, `session_key`, and `channel` should be optional canonical fields with source-specific population rules. -- `skill_invocation_id`, `invocation_mode`, and `confidence` should be explicit canonical fields so repaired/inferred/explicit invocations stop collapsing into the same raw log shape. -- The refactor priority order should be: Codex adapter rewrite first, OpenCode capture-mode split second, Claude hook expansion third, OpenClaw transport metadata fourth. - ---- - -## Target Architecture - -### Layer 1: Raw Capture - -Preserve append-only raw logs exactly as they arrive from each adapter. - -- Claude Code hooks / replay -- Codex wrapper / rollout ingest -- OpenCode ingest -- OpenClaw ingest - -### Layer 2: Canonical Normalization - -Create a single internal event model that every adapter maps into. - -Candidate entities: - -- `CanonicalSession` -- `CanonicalPrompt` -- `CanonicalSkillInvocation` -- `CanonicalExecutionTelemetry` -- `CanonicalEvolutionEvent` - -### Layer 3: Projections - -Project canonical events into: - -- local JSONL materializations for CLI/dashboard -- repaired overlays -- cloud Neon tables -- contribution bundles - -The cloud product should consume the same canonical model as local, not a second interpretation. - ---- - -## Proposed Canonical Tables / Records - -### `sessions` - -- `session_id` -- `platform` -- `capture_mode` -- `source_session_kind` -- `started_at` -- `ended_at` -- `workspace_path` -- `repo_root` -- `repo_remote` -- `branch` -- `commit_sha` -- `agent_cli` -- `model` -- `input_tokens` -- `output_tokens` -- `duration_ms` - -### `prompts` - -- `prompt_id` -- `session_id` -- `timestamp` -- `prompt_text` -- `prompt_hash` -- `prompt_kind` -- `is_actionable` -- `source_ref` - -### `skill_invocations` - -- `skill_invocation_id` -- `session_id` -- `prompt_id` -- `timestamp` -- `skill_name` -- `skill_path` -- `skill_version_hash` -- `invocation_mode` -- `triggered` -- `confidence` -- `source_ref` - -### `execution_facts` - -- `session_id` -- `tool_calls_json` -- `total_tool_calls` -- `assistant_turns` -- `errors_encountered` -- `bash_commands_redacted` -- `interrupted` - -### `normalization_runs` - -- `normalizer_version` -- `run_at` -- `source_platform` -- `capture_mode` -- `raw_records_seen` -- `canonical_records_written` -- `repair_applied` - ---- - -## Implementation Tracks - -### Track 0: Platform Contract Verification - -| Step | Description | Depends On | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| 0A | Inventory official docs for Claude Code, Codex, OpenCode, and OpenClaw fields relevant to sessions, prompts, invocations, hooks, stats, and exports | — | -| 0B | Capture fresh local sessions where available, and fall back to checked-in fixtures when a platform is not installed on this machine | 0A | -| 0C | Record drift cases where docs and observed payloads differ, and decide whether canonical fields are `required`, `optional`, or `derived` | 0B | -| 0D | Freeze a source-to-canonical field mapping table before adapter refactors begin | 0C | - -### Track A: Schema and Domain Model - -| Step | Description | Depends On | -| ---- | ----------------------------------------------------------------------------------- | ---------- | -| A1 | Define canonical event entities in `types.ts` without breaking existing log readers | — | -| A2 | Add explicit enums for `platform`, `capture_mode`, `prompt_kind`, `invocation_mode` | A1 | -| A3 | Add `schema_version` + `normalizer_version` to canonical records | A1 | -| A4 | Write design doc for canonical event model and local/cloud projection rules | A1 | - -### Track B: Local Normalizer - -| Step | Description | Depends On | -| ---- | --------------------------------------------------------------------------------- | ---------- | -| B1 | Add a normalization module that converts raw adapter data into canonical events | A2 | -| B2 | Move actionable/meta classification into normalization, not downstream dashboards | B1 | -| B3 | Emit normalized local projections from canonical events | B1 | -| B4 | Fold repaired overlays into the same normalization pipeline | B1 | - -### Track C: Adapter Upgrades - -| Step | Description | Depends On | -| ---- | ------------------------------------------------------------------------------- | -------------- | -| C1 | Upgrade Claude Code hook/replay writers to populate canonical source dimensions | B1 | -| C2 | Upgrade Codex wrapper/rollout ingest to canonical source dimensions | B1 | -| C3 | Upgrade OpenCode ingest to canonical source dimensions | B1 | -| C4 | Upgrade OpenClaw ingest to canonical source dimensions | B1 | -| C5 | Add adapter fixture coverage for normalization output parity | C1, C2, C3, C4 | - -### Track D: Cloud Projection - -| Step | Description | Depends On | -| ---- | ----------------------------------------------------------------------- | ---------- | -| D1 | Define Neon schema for sessions, prompts, invocations, telemetry facts | A4 | -| D2 | Add canonical-to-Neon projection layer | D1, B3 | -| D3 | Ensure local and cloud dashboards consume the same canonical view model | D2 | -| D4 | Add replay-safe idempotency keys for cloud upserts | D2 | - -### Track E: Analytics and Evidence - -| Step | Description | Depends On | -| ---- | --------------------------------------------------------------------------------- | ---------- | -| E1 | Replace free-form `source` heuristics in analytics with canonical fields | B3 | -| E2 | Add quality dashboards for `meta_rate`, `repair_rate`, `explicit_invocation_rate` | B3 | -| E3 | Track invocation confidence and prompt/invocation joins in reports | E1 | -| E4 | Add cloud-facing evidence exports from canonical records | D2, E3 | - ---- - -## Success Criteria - -- [ ] A canonical event model exists and is documented -- [ ] Every adapter maps to the same source dimensions -- [ ] Prompt/actionability classification happens once in normalization -- [ ] Local CLI/dashboard read canonical projections, not raw adapter assumptions -- [ ] Repair overlays are first-class normalization inputs -- [ ] Neon schema mirrors canonical entities instead of raw JSONL shapes -- [ ] Session, prompt, and skill invocation identity are stable and joinable -- [ ] We can answer “which agent, from which platform, in which mode, invoked which skill for which prompt?” without heuristics -- [ ] We can measure data quality itself: meta contamination, repair dependence, explicit invocation coverage - ---- - -## Recommended Immediate Next Steps - -1. Finish Track 0 and write down the source-to-canonical field mapping table -2. Introduce canonical enums and types in `cli/selftune/types.ts` -3. Add a normalization module that all adapters call before writing projections -4. Add first-class `platform` and `capture_mode` to every canonical event -5. Move the current query filters and repair overlay logic under that normalizer -6. Design the Neon tables from canonical entities, not from the current JSONL log files diff --git a/docs/exec-plans/deferred/cloud-auth-unification-for-alpha.md b/docs/exec-plans/deferred/cloud-auth-unification-for-alpha.md deleted file mode 100644 index 769a3674..00000000 --- a/docs/exec-plans/deferred/cloud-auth-unification-for-alpha.md +++ /dev/null @@ -1,360 +0,0 @@ -# Execution Plan: Cloud Auth Unification for Alpha - -**Status:** Proposed -**Created:** 2026-03-19 -**Goal:** Unify the cloud app and alpha-upload auth model so alpha users are first-class cloud users, browser and API routes share one identity boundary, and CLI uploads use cloud-issued credentials instead of a parallel local-only identity system. - -## Why This Exists - -Today the auth story is split: - -- the Next.js cloud app uses Neon Auth wrappers -- the API package configures Better Auth directly -- local selftune stores a separate alpha identity block in `~/.selftune/config.json` -- alpha upload credentials are conceptually API keys, but that path is not yet the same clear product boundary as browser auth - -That split creates unnecessary product and operational complexity: - -- alpha users are not clearly the same thing as cloud users -- browser auth and upload auth are hard to reason about together -- operator support is harder because identity is duplicated across local and cloud layers -- rollout confidence is lower because the public authenticated upload path is not the same path we are exercising through the browser app - -The desired model is simpler: - -- **cloud account** is the source of truth for identity -- **org membership** is the source of truth for tenancy -- **alpha enrollment** is cloud-side state on that user or org membership -- **CLI upload credential** is minted from the signed-in cloud user and stored locally as a cache - -Not every cloud user must be an alpha user. -But every alpha user should be a cloud user. - -## Architectural Recommendation - -Use **Neon Auth as the canonical product auth boundary** for user and session identity. - -That does not mean “keep two independent auth implementations because Neon Auth uses Better Auth internally.” It means: - -- the product should treat Neon Auth as the user/session authority -- the API should trust the same identity model as the web app -- CLI upload credentials should be issued by the cloud app/API for authenticated cloud users -- local selftune should stop inventing its own long-lived alpha identity as the source of truth - -### Docs Basis - -Neon’s auth docs make the intended boundary explicit: - -- Neon Auth is a **managed authentication service** for users, sessions, and auth configuration -- auth state lives in the **`neon_auth` schema** -- Neon positions it as the right choice for **production authentication**, preview environments, and branch-aware auth flows -- Neon also explicitly says **self-hosting Better Auth makes sense when you need custom plugins, hooks, and options not yet supported by Neon Auth** - -That matters here because the current API package is trying to use a separate direct Better Auth setup plus the `apiKey()` plugin as if it were the same thing as Neon Auth. The docs do not support that assumption. - -### What This Means for Upload Credentials - -Neon Auth should remain the canonical **user/session** layer. - -But alpha upload credentials should be treated as **product-owned credentials tied to Neon-authenticated users**, not as an implicit “Neon Auth supports the Better Auth API-key plugin” assumption. - -Recommended long-term shape: - -- browser sign-in and cloud identity: **Neon Auth** -- upload credential issuance and revocation: **product-owned tables and endpoints in the cloud app** -- upload credential verification: **API middleware that resolves the credential back to the same Neon-authenticated user/org graph** - -### Auth Boundary After Unification - -- **Browser app** - - Neon Auth session/cookie - - user signs in once - - org membership resolved cloud-side - -- **Cloud API** - - browser requests authenticated via the same cloud user/session boundary - - CLI upload requests authenticated via cloud-issued upload keys or tokens stored in product-owned tables - - both paths resolve to the same `user_id` and `org_id` - -- **Local selftune** - - stores cached cloud identity references and upload credentials - - does not treat local email/user_id as canonical enrollment truth - -## Product Rules - -1. Alpha enrollment is a cloud feature, not a local-only feature. -2. The source of truth for alpha status lives in the cloud backend. -3. The CLI may cache enrollment and credential state locally for convenience, but the cloud backend remains authoritative. -4. Upload credentials must be revocable and attributable to a real cloud user and org. -5. Auth for browser and auth for upload may use different credential forms, but they must resolve to the same user/org graph. - -## Target State - -- A user signs into the cloud app and belongs to an org. -- That user opts into alpha inside the product or through an authenticated CLI/browser handoff. -- The cloud app mints an upload credential scoped to that user/org. -- `selftune init --alpha` stores the credential locally and records the linked `cloud_user_id` and `org_id`. -- `selftune alpha upload` authenticates with that cloud-issued credential. -- Operator tools query by the same user/org identifiers the browser app uses. - -## Scope - -### In Scope - -- choose one canonical auth boundary for app + API -- make alpha users first-class cloud users -- mint upload credentials from authenticated cloud users -- change local alpha identity semantics from source of truth to cache -- align CLI onboarding with cloud sign-in -- align docs and product language around one auth story - -### Out of Scope - -- enterprise SSO -- billing/plan enforcement beyond org membership hooks -- public self-serve signup polish -- non-alpha community contribution auth - -## Repo Boundaries - -### `/Users/danielpetro/conductor/workspaces/selftune-cloud-app/gwangju-v1` - -Owns: - -- canonical user/session/org auth model -- alpha enrollment state -- upload credential issuance and revocation -- protected operator surfaces -- upload auth verification - -### `/Users/danielpetro/conductor/workspaces/selftune/miami` - -Owns: - -- local sign-in/enrollment handoff UX -- cached identity and credential storage -- upload client usage of issued credentials -- agent-facing workflow docs - -## Execution Phases - -### Phase 0: Decide the Canonical Auth Surface - -**Priority:** Critical -**Risk:** Low - -Make an explicit architectural choice: - -- treat Neon Auth as the product-level user/session authority -- stop treating the direct Better Auth setup in `packages/api` as an independent product auth stack -- stop assuming Neon Auth should also directly host the Better Auth `apiKey()` plugin path - -Deliverables: - -- short architecture note in the cloud repo -- one stated auth source of truth -- clear ownership of browser sessions vs CLI upload credentials - -Completion criteria: - -- the team can answer “how does a user authenticate?” in one sentence -- the team can answer “how does a CLI upload authenticate?” in one sentence - -### Phase 1: Cloud Enrollment Model - -**Priority:** Critical -**Risk:** Medium - -Add or normalize cloud-side enrollment state. - -Recommended shape: - -- `alpha_enrollments` - - `user_id` - - `org_id` - - `status` - - `consented_at` - - `cohort` - - `notes` - - `created_at` - - `updated_at` - -Alternative: - -- add alpha fields directly to a user/org membership table if that is materially simpler - -Requirements: - -- enrollment is queryable by user and org -- enrollment can be revoked without deleting the user -- operator tools can filter to alpha-enrolled users only - -### Phase 2: Upload Credential Issuance - -**Priority:** Critical -**Risk:** Medium - -Build the cloud-side flow that issues a CLI upload credential from an authenticated cloud user. - -Recommended model: - -- authenticated browser/session request -- server creates scoped upload credential -- credential tied to `user_id` + `org_id` -- credential revocable and auditable - -The credential should be product-owned, not a side effect of a parallel Better Auth plugin stack. - -Recommended model: - -- `upload_credentials` or equivalent product-owned table -- credential issued only after a Neon-authenticated user session is resolved -- credential tied to `user_id` + `org_id` -- credential revocable without touching the underlying user account -- credential usage auditable (`created_by`, `last_used_at`, `revoked_at`) - -The concrete credential can be either: - -- a product-owned API key, or -- a signed upload token with rotation metadata - -But it should not depend on a second hidden auth world, and it should not assume Neon Auth directly exposes the custom Better Auth plugin surface you would get from self-hosting. - -Requirements: - -- issue -- list -- revoke -- last used timestamp -- scope metadata (`push`, optional `read`) - -### Phase 3: API Auth Unification - -**Priority:** Critical -**Risk:** High - -Update the API so: - -- browser-authenticated requests resolve user/org via the canonical cloud auth path -- upload-authenticated requests resolve to the same user/org model using the issued credential -- push/operator routes do not rely on a parallel auth implementation with drifting tables or models - -This phase should remove the current conceptual split between: - -- browser auth in the app -- direct Better Auth auth in `packages/api` -- product-owned upload credentials vs user/session identity - -Completion criteria: - -- the auth middleware resolves both browser and CLI callers into the same `user_id` / `org_id` context -- one integration test proves browser session auth -- one integration test proves upload credential auth - -### Phase 4: Local CLI Onboarding Realignment - -**Priority:** Critical -**Risk:** Medium - -Change `miami` so local alpha identity is no longer the primary source of truth. - -New flow: - -1. agent asks user whether they want to enroll in alpha -2. if yes, CLI opens or instructs a cloud login flow -3. cloud confirms identity and enrollment -4. cloud issues upload credential -5. local config stores: - - `cloud_user_id` - - `org_id` - - cached email/display name if useful - - upload credential metadata - -Local config should stop behaving like the canonical alpha registry. - -### Phase 5: Migration and Compatibility - -**Priority:** High -**Risk:** Medium - -Provide a temporary migration path for existing local alpha users. - -Recommended behavior: - -- detect legacy local-only alpha blocks -- prompt to link or migrate to a cloud account -- do not silently discard local enrollment state -- support a transitional fallback period if needed - -Completion criteria: - -- existing testers can migrate without losing upload ability -- new testers only see the unified flow - -## Testing Strategy - -### Cloud Repo - -- session-auth route tests -- upload-credential issuance tests -- upload-credential verification tests -- revoke/expired credential tests -- user/org resolution tests - -### Local Repo - -- init/enrollment tests -- migration from legacy local alpha block tests -- upload with cloud-issued credential tests -- opted-out user sends nothing tests - -### End-to-End - -1. sign in as a cloud user -2. enroll in alpha -3. mint upload credential -4. store locally -5. perform upload -6. verify rows land under the correct org/user -7. revoke credential -8. verify further uploads fail cleanly - -## Rollout Order - -1. Cloud enrollment model -2. Credential issuance + revocation -3. API auth unification -4. Local CLI onboarding realignment -5. Legacy migration -6. Remove obsolete local-only identity assumptions - -## Acceptance Criteria - -- Every alpha user is a cloud user. -- Browser auth and upload auth resolve into the same user/org graph. -- Local `~/.selftune/config.json` is a cache of cloud-linked identity state, not the source of truth. -- Upload credentials are cloud-issued, revocable, attributable, and stored in product-owned credential tables. -- One real end-to-end authenticated upload works without `DEV_AUTH=1`. -- The product can explain alpha enrollment in one consistent sentence across app, API, and CLI. - -## Neon Docs Notes - -Reviewed against Neon Auth overview, last updated **March 5, 2026**: - -- Neon Auth overview: https://neon.com/docs/auth/overview - -Key constraints taken from the docs: - -- Neon Auth is managed and stores auth state in `neon_auth` -- Neon Auth is the recommended product auth layer for app users and sessions -- self-hosting Better Auth remains the path for unsupported custom plugins/hooks/options - -That is why this plan converges on: - -- **Neon Auth for user/session identity** -- **product-owned upload credentials for CLI ingestion** - -## Related Plans - -- [alpha-rollout-data-loop-plan.md](/Users/danielpetro/conductor/workspaces/selftune/miami/docs/exec-plans/active/alpha-rollout-data-loop-plan.md) -- [dashboard-data-integrity-recovery.md](/Users/danielpetro/conductor/workspaces/selftune/miami/docs/exec-plans/active/dashboard-data-integrity-recovery.md) diff --git a/docs/exec-plans/reference/state-change-skills-call-2026-04-09-insights.md b/docs/exec-plans/reference/state-change-skills-call-2026-04-09-insights.md deleted file mode 100644 index 29626874..00000000 --- a/docs/exec-plans/reference/state-change-skills-call-2026-04-09-insights.md +++ /dev/null @@ -1,183 +0,0 @@ - - -# State Change Skills Call: SelfTune Insights - -**Status:** Reference -**Source:** External transcript at `/Users/danielpetro/Documents/Projects/FOSS/selftune/transcripts/state-change-skills-call-selftune-04-09-2026.md` -**Goal:** Capture the transcript-grounded product and workflow insights from the 2026-04-09 State Change skills call, and separate them from maintainer synthesis, so they become versioned repo knowledge instead of call-only lore. - -## Reading Guide - -- "Transcript-grounded" means the point was discussed directly in the call. -- "Maintainer synthesis" means the point is an interpretation or follow-on - recommendation, not a direct claim from the transcript. - -## Executive Summary - -The transcript supports four clear takeaways: - -- routing and skill-level observability, not generic LLM tracing -- a router plus workflows plus deterministic tooling, not one giant prompt blob -- a system that helps creators iterate on real user language, not imagined trigger phrases -- a creator workflow that separates judgment from deterministic code and tools - -The strongest friction points in the call were: - -- make creator best practices easier to learn from repo artifacts -- make skill evaluation and runtime trust easier to reason about -- clarify what belongs in the skill versus workflows, references, scripts, and tools -- fix the basic install/discovery path so people can actually try selftune - -Maintainer synthesis from those points: - -1. finish stabilization and convergence already in flight -2. package the creator playbook and examples -3. produce a routing-first proof slice -4. do a smaller packaging/discoverability cleanup -5. keep any broader authoring-model design narrow and grounded in shipped behavior - -## What Landed Well In The Call - -### 1. Router-first skill structure - -The clearest moment of comprehension came when selftune was shown as a small -top-level skill that routes into workflow files rather than trying to keep the -entire operating manual in one prompt surface. - -Implication: - -- selftune should keep reinforcing the "skill is a folder" model -- router plus workflows should remain a first-class teaching pattern - -### 2. Deterministic scaffolding around AI judgment - -The call landed well when selftune was described as putting "if-then", -classification, checks, and command steering into deterministic surfaces while -keeping judgment with the model. - -Implication: - -- deterministic, repeated execution belongs in CLI/scripts/tools -- judgment, interpretation, and proposal generation stay with the model -- this matches `docs/golden-principles.md` and should be made more explicit for creators - -### 3. Skill-specific observability is still a real gap - -The conversation repeatedly circled back to the same pain: people can build -skills, but they do not know whether those skills actually triggered, helped, -or degraded in real usage. - -Implication: - -- selftune's positioning around skill observability remains strong -- the product should continue to distinguish itself from generic trace tools -- evaluation and watch correctness are not infrastructure polish; they are the product - -### 4. Routing-first thinking creates momentum - -The practical "aha" from the call was that creators should solve selection -before deep body logic. In the financial-model example, the useful first step -was deciding which model to build, not immediately optimizing the cell-level -mechanics. - -Implication: - -- selftune should package a routing-first benchmark/demo -- body/routing evolution should be taught as separate layers of maturity - -## Problems And Friction Surfaced By The Call - -### 1. Creator guidance is still too implicit - -People asked directly about: - -- when to split into new files -- whether to build skills within skills -- whether logic belongs in markdown, references, scripts, or tools -- how much context should live in the skill versus be loaded on demand - -The repo contains many of the answers, but they are still too distributed -across implementation artifacts and lived practice. - -### 2. Evaluation and runtime trust are still underspecified - -The call spent more time on "how do I know this skill is actually working?" than -on any one missing feature. The recurring issues were: - -- skills are hard to test against real user phrasing -- current eval tools still feel like black boxes -- creators need clearer evidence about whether they are improving or guessing - -### 3. Discoverability and install need a basic fix - -One participant explicitly said they searched for selftune and could not find -it. That is real signal, but it is a narrower point than the routing/trust -discussion above. - -### 4. The "what belongs where?" boundary is still a product question - -The call surfaced a recurring creator question: - -- what lives in the skill -- what lives in a workflow or reference file -- what should become deterministic code or a CLI tool - -This boundary should eventually become a first-class authoring model, not just -oral tradition. - -## Maintainer Synthesis - -### 1. Keep selftune centered on the runtime loop - -selftune resonated most when described as: - -- observe -- detect -- evaluate -- evolve -- watch - -It was less compelling when it was inferred to be "mostly discoverability." The -runtime loop should remain the center of the story. - -### 2. Treat creator education as product work - -For selftune, examples, workflow docs, and authoring patterns are not secondary -docs. They are part of the product surface because the product is agent-first -and creator-facing. - -### 3. Teach routing-first before generalized skill-body evolution - -Routing is the lowest-friction proof of value and the easiest concept for skill -creators to understand quickly. The product should exploit that. - -### 4. Keep future authoring-model work narrow until the current system is easier to teach and trust - -The call supports a sharper explanation of skill/workflow/reference/tool -boundaries. It does not by itself justify a broad creator-platform rewrite. - -## What Is Already In Flight - -The following active work already addresses the most urgent runtime issues and -should not be duplicated by a new effort: - -- `docs/exec-plans/active/eval-system-gap-closure.md` -- `docs/exec-plans/active/repo-convergence-refactor-program.md` - -The following deferred work already covers part of the creator-guidance side -and should be treated as an input, not ignored: - -- `docs/exec-plans/deferred/advanced-skill-patterns-adoption.md` - -## Recommended Follow-On - -Maintainer recommendation after the current stabilization wave: - -1. creator playbook and examples -2. routing-first benchmark/demo -3. basic packaging/discoverability repair -4. a smaller authoring-boundary note if it is still needed after the first three - -That follow-on is captured in: - -- `docs/exec-plans/deferred/post-stabilization-creator-adoption-plan.md` diff --git a/package.json b/package.json index 8fb18a77..ca7a1a20 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "lint:arch": "bun run lint-architecture.ts", "effect:tsgo": "node ./node_modules/@typescript/native/bin/tsc", "effect:diagnostics": "bun run effect:tsgo --noEmit -p tsconfig.effect.json", - "test": "turbo run test --concurrency=1 --filter=@selftune/config --filter=@selftune/library --filter=@selftune/local-store --filter=@selftune/observability --filter=@selftune/skill-intelligence --filter=@selftune/source-management --filter=@selftune/runtime --filter=@selftune/orchestration --filter=@selftune/local --filter=@selftune/api-contract --filter=@selftune/app-core --filter=@selftune/control-plane --filter=@selftune/dashboard-core --filter=@selftune/ui --filter=@selftune/use-once-helper --filter='./packages/harnesses/*'", + "test": "turbo run test --filter=@selftune/config --filter=@selftune/library --filter=@selftune/local-store --filter=@selftune/observability --filter=@selftune/skill-intelligence --filter=@selftune/source-management --filter=@selftune/runtime --filter=@selftune/orchestration --filter=@selftune/local --filter=@selftune/control-plane --filter=@selftune/use-once-helper --filter='./packages/harnesses/*'", "test:legacy": "bun test tests/ packages/telemetry-contract/ --path-ignore-patterns='packages/control-plane/**'", "test:fast": "bun test $(find tests -name '*.test.ts' ! -name 'evolve.test.ts' ! -name 'integration.test.ts' ! -name 'dashboard-server.test.ts' ! -path '*/blog-proof/*')", "test:slow": "bun test tests/evolution/evolve.test.ts tests/evolution/integration.test.ts tests/monitoring/integration.test.ts tests/dashboard/dashboard-server.test.ts", @@ -100,12 +100,12 @@ "sync-version": "bun run scripts/sync-skill-version.ts", "validate:subagents": "bun run scripts/validate-subagent-docs.ts", "prepublishOnly": "bun run sync-version && bun run build:dashboard", - "typecheck": "turbo run typecheck --filter=@selftune/config --filter=@selftune/library --filter=@selftune/local-store --filter=@selftune/observability --filter=@selftune/skill-intelligence --filter=@selftune/source-management --filter=@selftune/runtime --filter=@selftune/orchestration --filter=@selftune/local --filter=@selftune/cli --filter=@selftune/api-contract --filter=@selftune/app-core --filter=@selftune/control-plane --filter=@selftune/use-once-helper --filter='./packages/harnesses/*'", + "typecheck": "turbo run typecheck --filter=@selftune/config --filter=@selftune/library --filter=@selftune/local-store --filter=@selftune/observability --filter=@selftune/skill-intelligence --filter=@selftune/source-management --filter=@selftune/runtime --filter=@selftune/orchestration --filter=@selftune/local --filter=@selftune/cli --filter=@selftune/control-plane --filter=@selftune/use-once-helper --filter='./packages/harnesses/*'", "build:use-once-helper": "bun run --cwd apps/use-once-helper build", "typecheck:runtime": "bun run --cwd packages/runtime typecheck", "typecheck:dashboard": "cd apps/local-dashboard && bunx tsc --noEmit", "check": "bun run lint && bun run format:check && bun run lint:arch && bun run typecheck && bun run typecheck:dashboard && bun run test", - "prepare": "sh ./scripts/prepare-effect.sh && effect-tsgo patch --typescript-package @typescript/native && (bunx lefthook install || true)", + "prepare": "./scripts/prepare-effect.sh && effect-tsgo patch --typescript-package @typescript/native && (bunx lefthook install || true)", "start": "bun run apps/cli/src/main.ts --help" }, "dependencies": { @@ -146,7 +146,6 @@ "@effect/tsgo": "^0.24.1", "@effect/vitest": "4.0.0-beta.66", "@evilmartians/lefthook": "^1.13.6", - "@testing-library/react": "^16.3.2", "@types/bun": "^1.3.11", "@typescript/native": "npm:typescript@^7.0.2", "bun-types": "^1.3.11", diff --git a/packages/api-contract/README.md b/packages/api-contract/README.md deleted file mode 100644 index ebcb14db..00000000 --- a/packages/api-contract/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# `@selftune/api-contract` - -Effect 4 HTTP contracts for the shared selftune application. - -This package owns wire data, Effect Schema validation, tagged HTTP failures, the -canonical `HttpApi`, and its generated client. It does not import Hono, React, -authentication SDKs, persistence, or product screens. React consumes the small -Promise facade; legacy runtimes consume plain decode results so Effect runtime -values do not cross their boundary. - -## Initial surface - -- `CloudBootstrapSchema` exactly matches the existing Cloud v2 bootstrap service - result. -- `SelfTuneCloudApi` defines the canonical greenfield endpoint graph. -- `createCloudApiClient` wraps the generated `HttpApiClient` and accepts injected - authentication, organization selection, and fetch functions. - -The Cloudflare Worker implements the canonical API and validates the temporary -Hono upstream on every successful response. Hono uses this package's plain -decode adapter rather than importing Effect 4 runtime values into its Effect 3 -graph. The frozen Cloud v2 donor still carries legacy schemas; remove those as -their endpoint families move to the greenfield Cloud Host. diff --git a/packages/api-contract/fixtures/distribution-binding-golden.json b/packages/api-contract/fixtures/distribution-binding-golden.json deleted file mode 100644 index ef07162f..00000000 --- a/packages/api-contract/fixtures/distribution-binding-golden.json +++ /dev/null @@ -1,528 +0,0 @@ -[ - { - "name": "standalone", - "authorization": { - "id": "00000000-0000-4000-8000-000000000002", - "distributionId": "00000000-0000-4000-8000-000000000001", - "authorizationRequestId": "93000000-0000-4000-8000-000000000039", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-standalone", - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "sourceObjectSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "bindingSha256": "2bf490c62116b4a92b8b007da48efb6f3e748cc45f29c4c3280bfd5c9818eac3", - "channel": "registry_public", - "intent": { - "_tag": "registry_public" - }, - "rightsClaimId": "41000000-0000-4000-8000-000000000004", - "decision": { - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-standalone", - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "channel": "registry_public", - "status": "ready", - "licenseEvidence": { - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - }, - "rightsClaim": { - "id": "41000000-0000-4000-8000-000000000004", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-standalone", - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "evidence": { - "_tag": "standalone_license", - "licenseEvidence": { - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - } - }, - "rightsHolder": { - "_tag": "organization", - "organizationId": "10000000-0000-4000-8000-000000000001" - }, - "provenanceKind": "selftune_authored", - "sourceRepository": null, - "sourceRef": null, - "sourceTreeHash": null, - "scopes": { - "redistribute": true, - "modify": false, - "enableContributorSignals": false - }, - "attestedChannels": ["registry_public"], - "attestedBy": "30000000-0000-4000-8000-000000000003", - "attestedAt": "2026-07-21T07:00:00.000Z", - "attestationTermsVersion": "1", - "verificationState": "source_verified", - "verifiedBy": null, - "verifiedAt": null, - "reviewEvidence": null, - "reviewPolicyVersion": null, - "supersedesClaimId": null, - "createdAt": "2026-07-21T07:30:00.000Z" - }, - "telemetryEntitlement": { - "_tag": "unconfigured" - }, - "blockers": [], - "warnings": [], - "policyVersion": "distribution-readiness-v1", - "assessedAt": "2026-07-21T08:00:00.000Z" - }, - "transform": { - "name": "selftune-portable-package", - "version": "1", - "includesFeedbackArtifacts": false - }, - "packagedSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "authorizedBy": "30000000-0000-4000-8000-000000000003", - "authorizedAt": "2026-07-21T08:00:00.000Z", - "expiresAt": "2026-07-21T08:10:00.000Z" - }, - "components": [], - "binding": { - "schemaVersion": 1, - "organizationId": "10000000-0000-4000-8000-000000000001", - "actorId": "30000000-0000-4000-8000-000000000003", - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-standalone", - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "sourceObjectSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "channel": "registry_public", - "intent": { - "tag": "registry_public" - }, - "decision": { - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-standalone", - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "channel": "registry_public", - "status": "ready", - "licenseEvidence": { - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - }, - "rightsClaim": { - "id": "41000000-0000-4000-8000-000000000004", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-standalone", - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "evidence": { - "tag": "standalone_license", - "licenseEvidence": { - "sourceRevisionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - } - }, - "rightsHolder": { - "_tag": "organization", - "organizationId": "10000000-0000-4000-8000-000000000001" - }, - "provenanceKind": "selftune_authored", - "sourceRepository": null, - "sourceRef": null, - "sourceTreeHash": null, - "scopes": { - "redistribute": true, - "modify": false, - "enableContributorSignals": false - }, - "attestedChannels": ["registry_public"], - "attestedBy": "30000000-0000-4000-8000-000000000003", - "attestedAt": "2026-07-21T07:00:00.000Z", - "attestationTermsVersion": "1", - "verificationState": "source_verified", - "verifiedBy": null, - "verifiedAt": null, - "reviewEvidence": null, - "reviewPolicyVersion": null, - "supersedesClaimId": null, - "createdAt": "2026-07-21T07:30:00.000Z" - }, - "telemetry": { - "tag": "unconfigured" - }, - "blockers": [], - "warnings": [], - "policyVersion": "distribution-readiness-v1" - }, - "transform": { - "name": "selftune-portable-package", - "version": "1", - "includesFeedbackArtifacts": false - }, - "packagedSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "components": [] - } - }, - { - "name": "skill_set", - "authorization": { - "id": "00000000-0000-4000-8000-000000000004", - "distributionId": "00000000-0000-4000-8000-000000000003", - "authorizationRequestId": "94000000-0000-4000-8000-000000000049", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_set", - "skillSetId": "golden-skill-set", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "sourceObjectSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "bindingSha256": "5cd96e1380b9e320e10f1eb7d499911e8a50cdac3b91cdf856de1222053f73ba", - "channel": "portable_skill_set_export", - "intent": { - "_tag": "portable_skill_set_export" - }, - "rightsClaimId": "42000000-0000-4000-8000-000000000004", - "decision": { - "subject": { - "_tag": "skill_set", - "skillSetId": "golden-skill-set", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "channel": "portable_skill_set_export", - "status": "ready", - "licenseEvidence": null, - "rightsClaim": { - "id": "42000000-0000-4000-8000-000000000004", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_set", - "skillSetId": "golden-skill-set", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "evidence": { - "_tag": "skill_set_compilation", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "sourceManifestSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "sourceBomSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "orderedComponentInspectionEvidenceSha256s": [ - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ], - "inspectionEvidenceSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "rightsHolder": { - "_tag": "organization", - "organizationId": "10000000-0000-4000-8000-000000000001" - }, - "provenanceKind": "selftune_authored", - "sourceRepository": null, - "sourceRef": null, - "sourceTreeHash": null, - "scopes": { - "redistribute": true, - "modify": false, - "enableContributorSignals": false - }, - "attestedChannels": ["portable_skill_set_export"], - "attestedBy": "30000000-0000-4000-8000-000000000003", - "attestedAt": "2026-07-21T07:00:00.000Z", - "attestationTermsVersion": "1", - "verificationState": "source_verified", - "verifiedBy": null, - "verifiedAt": null, - "reviewEvidence": null, - "reviewPolicyVersion": null, - "supersedesClaimId": null, - "createdAt": "2026-07-21T07:30:00.000Z" - }, - "telemetryEntitlement": { - "_tag": "unconfigured" - }, - "blockers": [], - "warnings": [], - "policyVersion": "distribution-readiness-v1", - "assessedAt": "2026-07-21T08:00:00.000Z" - }, - "transform": { - "name": "selftune-portable-skill-set", - "version": "1", - "includesFeedbackArtifacts": false - }, - "packagedSha256": "a39829c522e25c3c3fbfae96b12997c12eece03c89f6cf7b595e9a12f135311d", - "authorizedBy": "30000000-0000-4000-8000-000000000003", - "authorizedAt": "2026-07-21T08:00:00.000Z", - "expiresAt": "2026-07-21T08:10:00.000Z" - }, - "components": [ - { - "ordinal": 0, - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-component", - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - }, - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "sourceObjectSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "packagedSha256": "b9749ed55b450b6c1432b5bb55247c3fdec7e1540940b92f077271f38155dafb", - "rightsClaimId": "43000000-0000-4000-8000-000000000004", - "licenseEvidence": { - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - }, - "telemetryEntitlement": { - "_tag": "enabled", - "recipientOrganizationId": "10000000-0000-4000-8000-000000000001", - "capability": { - "version": 1, - "allowedSignals": ["miss_category", "trigger", "grade"], - "wireFields": { - "trigger": ["triggered", "invocation_type", "miss_detected"], - "grade": ["execution_grade"], - "miss_category": ["query_bucket"] - } - }, - "configuredBy": "30000000-0000-4000-8000-000000000003", - "configuredAt": "2026-07-21T07:45:00.000Z" - }, - "transform": { - "name": "selftune-portable-package", - "version": "1", - "includesFeedbackArtifacts": true - } - } - ], - "binding": { - "schemaVersion": 1, - "organizationId": "10000000-0000-4000-8000-000000000001", - "actorId": "30000000-0000-4000-8000-000000000003", - "subject": { - "_tag": "skill_set", - "skillSetId": "golden-skill-set", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "sourceObjectSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "channel": "portable_skill_set_export", - "intent": { - "tag": "portable_skill_set_export" - }, - "decision": { - "subject": { - "_tag": "skill_set", - "skillSetId": "golden-skill-set", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "channel": "portable_skill_set_export", - "status": "ready", - "licenseEvidence": null, - "rightsClaim": { - "id": "42000000-0000-4000-8000-000000000004", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_set", - "skillSetId": "golden-skill-set", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "evidence": { - "tag": "skill_set_compilation", - "sourceRevisionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "sourceManifestSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "sourceBomSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "orderedComponentInspectionEvidenceSha256s": [ - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ], - "inspectionEvidenceSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "rightsHolder": { - "_tag": "organization", - "organizationId": "10000000-0000-4000-8000-000000000001" - }, - "provenanceKind": "selftune_authored", - "sourceRepository": null, - "sourceRef": null, - "sourceTreeHash": null, - "scopes": { - "redistribute": true, - "modify": false, - "enableContributorSignals": false - }, - "attestedChannels": ["portable_skill_set_export"], - "attestedBy": "30000000-0000-4000-8000-000000000003", - "attestedAt": "2026-07-21T07:00:00.000Z", - "attestationTermsVersion": "1", - "verificationState": "source_verified", - "verifiedBy": null, - "verifiedAt": null, - "reviewEvidence": null, - "reviewPolicyVersion": null, - "supersedesClaimId": null, - "createdAt": "2026-07-21T07:30:00.000Z" - }, - "telemetry": { - "tag": "unconfigured" - }, - "blockers": [], - "warnings": [], - "policyVersion": "distribution-readiness-v1" - }, - "transform": { - "name": "selftune-portable-skill-set", - "version": "1", - "includesFeedbackArtifacts": false - }, - "packagedSha256": "a39829c522e25c3c3fbfae96b12997c12eece03c89f6cf7b595e9a12f135311d", - "components": [ - { - "ordinal": 0, - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-component", - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - }, - "sourceObjectSha256": "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", - "packagedSha256": "b9749ed55b450b6c1432b5bb55247c3fdec7e1540940b92f077271f38155dafb", - "decision": { - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-component", - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - }, - "channel": "portable_skill_set_export", - "status": "ready", - "licenseEvidence": { - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - }, - "rightsClaim": { - "id": "43000000-0000-4000-8000-000000000004", - "organizationId": "10000000-0000-4000-8000-000000000001", - "subject": { - "_tag": "skill_revision", - "skillRevisionId": "golden-component", - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - }, - "evidence": { - "tag": "standalone_license", - "licenseEvidence": { - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - } - }, - "rightsHolder": { - "_tag": "organization", - "organizationId": "10000000-0000-4000-8000-000000000001" - }, - "provenanceKind": "selftune_authored", - "sourceRepository": null, - "sourceRef": null, - "sourceTreeHash": null, - "scopes": { - "redistribute": true, - "modify": true, - "enableContributorSignals": true - }, - "attestedChannels": ["portable_skill_set_export"], - "attestedBy": "30000000-0000-4000-8000-000000000003", - "attestedAt": "2026-07-21T07:00:00.000Z", - "attestationTermsVersion": "1", - "verificationState": "source_verified", - "verifiedBy": null, - "verifiedAt": null, - "reviewEvidence": null, - "reviewPolicyVersion": null, - "supersedesClaimId": null, - "createdAt": "2026-07-21T07:30:00.000Z" - }, - "telemetry": { - "tag": "enabled", - "recipientOrganizationId": "10000000-0000-4000-8000-000000000001", - "configuredBy": "30000000-0000-4000-8000-000000000003", - "configuredAt": "2026-07-21T07:45:00.000Z", - "version": 1, - "allowedSignals": ["grade", "miss_category", "trigger"], - "wireFields": { - "trigger": ["triggered", "invocation_type", "miss_detected"], - "grade": ["execution_grade"], - "miss_category": ["query_bucket"] - } - }, - "blockers": [], - "warnings": [], - "policyVersion": "distribution-readiness-v1" - }, - "rightsClaimId": "43000000-0000-4000-8000-000000000004", - "licenseEvidence": { - "sourceRevisionHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "expression": "MIT", - "kind": "spdx", - "policyDisposition": "automated_approved", - "filePath": null, - "fileSha256": null, - "noticePaths": [] - }, - "telemetry": { - "tag": "enabled", - "recipientOrganizationId": "10000000-0000-4000-8000-000000000001", - "configuredBy": "30000000-0000-4000-8000-000000000003", - "configuredAt": "2026-07-21T07:45:00.000Z", - "version": 1, - "allowedSignals": ["grade", "miss_category", "trigger"], - "wireFields": { - "trigger": ["triggered", "invocation_type", "miss_detected"], - "grade": ["execution_grade"], - "miss_category": ["query_bucket"] - } - }, - "transform": { - "name": "selftune-portable-package", - "version": "1", - "includesFeedbackArtifacts": true - } - } - ] - } - } -] diff --git a/packages/api-contract/index.ts b/packages/api-contract/index.ts deleted file mode 100644 index 74e4718c..00000000 --- a/packages/api-contract/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from "./src/client"; -export * from "./src/billing"; -export * from "./src/cloud-improve"; -export * from "./src/github"; -export * from "./src/contracts"; -export * from "./src/distribution"; -export * from "./src/distribution-read-models"; -export * from "./src/distribution-source-staging"; -export * from "./src/recipient-actions"; -export * from "./src/install-credentials"; -export * from "./src/share-invitations"; -export * from "./src/share-grants"; -export * from "./src/signal-routing-capability"; -export * from "./src/team"; diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json deleted file mode 100644 index 64bd973f..00000000 --- a/packages/api-contract/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@selftune/api-contract", - "version": "0.1.0", - "private": true, - "description": "Effect 4 HTTP contracts and typed client seam for selftune hosts", - "license": "MIT", - "author": "Daniel Petro", - "type": "module", - "exports": { - ".": "./index.ts", - "./client": "./src/client.ts", - "./billing": "./src/billing.ts", - "./cloud-improve": "./src/cloud-improve.ts", - "./contracts": "./src/contracts.ts", - "./distribution": "./src/distribution.ts", - "./distribution-source-staging": "./src/distribution-source-staging.ts", - "./recipient-actions": "./src/recipient-actions.ts", - "./install-credentials": "./src/install-credentials.ts", - "./github": "./src/github.ts", - "./share-invitations": "./src/share-invitations.ts", - "./share-grants": "./src/share-grants.ts", - "./signal-routing-capability": "./src/signal-routing-capability.ts", - "./team": "./src/team.ts" - }, - "scripts": { - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "effect": "4.0.0-beta.66", - "spdx-expression-parse": "4.0.0" - }, - "devDependencies": { - "@types/spdx-expression-parse": "4.0.0", - "typescript": "^5.9.3", - "vitest": "^4.0.18" - } -} diff --git a/packages/api-contract/src/billing.ts b/packages/api-contract/src/billing.ts deleted file mode 100644 index 39a72114..00000000 --- a/packages/api-contract/src/billing.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Schema } from "effect"; - -export const BillingPlanIdSchema = Schema.Literals(["free", "pro", "team", "enterprise"]); -export type BillingPlanId = Schema.Schema.Type; - -export const StripeSubscriptionStatusSchema = Schema.Literals([ - "none", - "active", - "canceled", - "incomplete", - "incomplete_expired", - "past_due", - "paused", - "trialing", - "unpaid", -]); -export type StripeSubscriptionStatus = Schema.Schema.Type; - -const PositiveIntSchema = Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))); - -export class BillingPlanSeats extends Schema.Class("BillingPlanSeats")({ - minimum: PositiveIntSchema, - label: Schema.NullOr(Schema.String), -}) {} - -export class BillingPlan extends Schema.Class("BillingPlan")({ - id: BillingPlanIdSchema, - 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(BillingPlanSeats)), -}) {} - -export class BillingStatus extends Schema.Class("BillingStatus")({ - plan: BillingPlanIdSchema, - subscriptionStatus: StripeSubscriptionStatusSchema, - currentPeriodEnd: Schema.NullOr(Schema.String), - trialEnd: Schema.NullOr(Schema.String), - seatCount: PositiveIntSchema, - hasStripeCustomer: Schema.Boolean, - canManageBilling: Schema.Boolean, - availablePlans: Schema.Array(BillingPlan), -}) {} - -export class BillingCheckoutInput extends Schema.Class( - "BillingCheckoutInput", -)({ - plan: Schema.Literals(["pro", "team"]), - seats: Schema.optional(PositiveIntSchema), -}) {} - -export class BillingSession extends Schema.Class("BillingSession")({ - url: Schema.NonEmptyString, -}) {} - -export class BillingCheckoutFinalizeInput extends Schema.Class( - "BillingCheckoutFinalizeInput", -)({ - sessionId: Schema.NonEmptyString, -}) {} - -export class BillingCheckoutFinalizeResult extends Schema.Class( - "BillingCheckoutFinalizeResult", -)({ - finalized: Schema.Boolean, - billing: Schema.NullOr(BillingStatus), - sessionStatus: Schema.NullOr(Schema.String), - paymentStatus: Schema.NullOr(Schema.String), -}) {} - -export const BillingApiPaths = { - status: "/api/v1/cloud/billing/status", - checkout: "/api/v1/cloud/billing/checkout", - portal: "/api/v1/cloud/billing/portal", - finalizeCheckout: "/api/v1/cloud/billing/checkout/finalize", -} as const; diff --git a/packages/api-contract/src/client.ts b/packages/api-contract/src/client.ts deleted file mode 100644 index d305b31c..00000000 --- a/packages/api-contract/src/client.ts +++ /dev/null @@ -1,590 +0,0 @@ -import { Cause, Effect, Schema } from "effect"; -import { - FetchHttpClient, - HttpClient, - HttpClientError, - HttpClientRequest, -} from "effect/unstable/http"; -import { HttpApiClient } from "effect/unstable/httpapi"; - -import { - RecipientActionApi, - SelfTuneCloudApi, - type CloudApiError, - type CloudBootstrap, - type CloudDeviceCodeDecision, - type CloudDeviceCodeDecisionInput, - type CloudLibraryInventory, - type CloudSkillDetail, - type CloudSkillSet, - type CloudSkillSetExport, - type CloudSkillSetInput, - type CloudSkillSetInventory, - type CloudSkillSetUpdate, -} from "./contracts"; -import type { BillingCheckoutFinalizeResult, BillingSession, BillingStatus } from "./billing"; -import type { - CloudGithubConnectionSync, - CloudGithubInstallSession, - CloudGithubStatus, -} from "./github"; -import { BillingCheckoutFinalizeInput, BillingCheckoutInput } from "./billing"; -import { TeamInviteInput, TeamRoleChangeInput } from "./team"; -import type { TeamStatus } from "./team"; -import { - CloudProposalApplyInput, - CloudProposalQuery, - CloudProposalReviewInput, -} from "./cloud-improve"; -import type { - CloudImproveRunDetail, - CloudImproveRunList, - CloudProposal, - CloudProposalApplyResult, - CloudProposalList, -} from "./cloud-improve"; -import type { - CreatorSignalsInventory, - DistributionReadinessInventory, -} from "./distribution-read-models"; -import { - ShareInvitationConflict, - ShareInvitationPreviewRequest, - ShareInvitationUnavailable, -} from "./share-invitations"; -import type { - ShareInvitationAcceptance, - ShareInvitationAcceptanceResult, - ShareInvitationAcceptImport, - ShareInvitationClaim, - ShareInvitationClaimToken, - ShareInvitationClaimResult, - ShareInvitationId, - ShareInvitationImportResult, - ShareInvitationPreview, - ShareInvitationRecipientView, -} from "./share-invitations"; -import { - RecipientAccountRequired, - RecipientActionConflict, - RecipientActionExpired, - RecipientActionForbidden, - RecipientActionInvalid, - RecipientActionReplay, -} from "./recipient-actions"; -import type { - RecipientDesktopInstallBootstrapRequest, - RecipientDesktopInstallBootstrapResponse, - RecipientDesktopInstallPreviewRequest, - RecipientDesktopInstallPreviewResponse, - RecipientPortableDownloadRequest, - RecipientPortableDownloadResponse, - RecipientUseOnceConsumeRequest, - RecipientUseOnceConsumeResponse, - RecipientUseOnceIssueRequest, - RecipientUseOnceIssueResponse, - RecipientUseOncePreviewRequest, - RecipientUseOncePreviewResponse, -} from "./recipient-actions"; - -export type ApiClientErrorKind = "http" | "invalid_response" | "network"; - -interface ApiClientErrorOptions { - readonly kind: ApiClientErrorKind; - readonly message: string; - readonly status?: number; - readonly code?: string; - readonly details?: unknown; - readonly cause?: unknown; -} - -/** Stable Promise-boundary error for React and other non-Effect consumers. */ -export class ApiClientError extends Error { - readonly kind: ApiClientErrorKind; - readonly status: number | null; - readonly code: string | null; - readonly details: unknown; - - constructor(options: ApiClientErrorOptions) { - super(options.message, { cause: options.cause }); - this.name = "ApiClientError"; - this.kind = options.kind; - this.status = options.status ?? null; - this.code = options.code ?? null; - this.details = options.details; - } -} - -export interface ApiRequestOptions { - readonly signal?: AbortSignal; -} - -export interface CloudApiClientOptions { - /** Origin only, such as `https://cloud.selftune.dev`. Defaults to same-origin. */ - readonly baseUrl?: string; - readonly fetch?: typeof fetch; - readonly headers?: HeadersInit; - readonly credentials?: RequestCredentials; - readonly getAccessToken?: () => null | string | Promise; - readonly getActiveOrganizationId?: () => null | string; -} - -export interface CloudApiClient { - readonly bootstrap: (options?: ApiRequestOptions) => Promise; - readonly distributionReadiness: ( - options?: ApiRequestOptions, - ) => Promise; - readonly creatorSignals: (options?: ApiRequestOptions) => Promise; - readonly library: (options?: ApiRequestOptions) => Promise; - readonly skillDetail: (skillId: string, options?: ApiRequestOptions) => Promise; - readonly skillSets: (options?: ApiRequestOptions) => Promise; - readonly createSkillSet: ( - input: CloudSkillSetInput, - options?: ApiRequestOptions, - ) => Promise; - readonly updateSkillSet: ( - input: CloudSkillSetUpdate, - options?: ApiRequestOptions, - ) => Promise; - readonly deleteSkillSet: (id: string, options?: ApiRequestOptions) => Promise; - readonly exportSkillSet: ( - id: string, - options?: ApiRequestOptions, - ) => Promise; - readonly materializeSkillSetExport: ( - id: string, - options?: ApiRequestOptions, - ) => Promise; - readonly decideDeviceCode: ( - input: CloudDeviceCodeDecisionInput, - options?: ApiRequestOptions, - ) => Promise; - readonly billingStatus: (options?: ApiRequestOptions) => Promise; - readonly createBillingCheckout: ( - input: BillingCheckoutInput, - options?: ApiRequestOptions, - ) => Promise; - readonly createBillingPortal: (options?: ApiRequestOptions) => Promise; - readonly finalizeBillingCheckout: ( - input: BillingCheckoutFinalizeInput, - options?: ApiRequestOptions, - ) => Promise; - readonly teamStatus: (options?: ApiRequestOptions) => Promise; - readonly inviteTeamMember: ( - input: TeamInviteInput, - options?: ApiRequestOptions, - ) => Promise; - readonly changeTeamMemberRole: ( - userId: string, - input: TeamRoleChangeInput, - options?: ApiRequestOptions, - ) => Promise; - readonly removeTeamMember: (userId: string, options?: ApiRequestOptions) => Promise; - readonly cancelTeamInvitation: ( - invitationId: string, - options?: ApiRequestOptions, - ) => Promise; - readonly improveRuns: (options?: ApiRequestOptions) => Promise; - readonly improveRun: ( - runId: string, - options?: ApiRequestOptions, - ) => Promise; - readonly proposals: ( - query?: CloudProposalQuery, - options?: ApiRequestOptions, - ) => Promise; - readonly proposal: (proposalId: string, options?: ApiRequestOptions) => Promise; - readonly reviewProposal: ( - proposalId: string, - input: CloudProposalReviewInput, - options?: ApiRequestOptions, - ) => Promise; - readonly applyProposal: ( - proposalId: string, - input?: CloudProposalApplyInput, - options?: ApiRequestOptions, - ) => Promise; - readonly githubStatus: (options?: ApiRequestOptions) => Promise; - readonly startGithubInstall: (options?: ApiRequestOptions) => Promise; - readonly syncGithubConnection: ( - connectionId: string, - options?: ApiRequestOptions, - ) => Promise; - readonly previewShareInvitation: ( - claimToken: ShareInvitationClaimToken, - options?: ApiRequestOptions, - ) => Promise; - readonly claimShareInvitation: ( - input: ShareInvitationClaim, - options?: ApiRequestOptions, - ) => Promise; - readonly getShareInvitation: ( - invitationId: ShareInvitationId, - options?: ApiRequestOptions, - ) => Promise; - readonly acceptShareInvitationLicense: ( - invitationId: ShareInvitationId, - input: ShareInvitationAcceptance, - options?: ApiRequestOptions, - ) => Promise; - readonly acceptAndImportShareInvitation: ( - invitationId: ShareInvitationId, - input: ShareInvitationAcceptImport, - options?: ApiRequestOptions, - ) => Promise; - readonly requestRecipientPortableDownload: ( - input: RecipientPortableDownloadRequest, - options?: ApiRequestOptions, - ) => Promise; - readonly issueRecipientUseOnce: ( - input: RecipientUseOnceIssueRequest, - options?: ApiRequestOptions, - ) => Promise; - readonly previewRecipientUseOnce: ( - input: RecipientUseOncePreviewRequest, - options?: ApiRequestOptions, - ) => Promise; - readonly consumeRecipientUseOnce: ( - input: RecipientUseOnceConsumeRequest, - options?: ApiRequestOptions, - ) => Promise; - readonly bootstrapRecipientDesktopInstall: ( - input: RecipientDesktopInstallBootstrapRequest, - options?: ApiRequestOptions, - ) => Promise; - readonly previewRecipientDesktopInstall: ( - input: RecipientDesktopInstallPreviewRequest, - options?: ApiRequestOptions, - ) => Promise; -} - -function isTaggedCloudApiError(error: unknown): error is CloudApiError { - if (typeof error !== "object" || error === null || !("_tag" in error)) return false; - const tag = error._tag; - return ( - tag === "CloudBadRequest" || - tag === "CloudUnauthorized" || - tag === "CloudForbidden" || - tag === "CloudPaymentRequired" || - tag === "CloudNotFound" || - tag === "CloudConflict" || - tag === "CloudServiceUnavailable" || - tag === "CloudUpstreamFailure" - ); -} - -function isTaggedShareInvitationError( - error: unknown, -): error is ShareInvitationUnavailable | ShareInvitationConflict { - return error instanceof ShareInvitationUnavailable || error instanceof ShareInvitationConflict; -} - -type RecipientClientFailure = - | RecipientActionInvalid - | RecipientAccountRequired - | RecipientActionForbidden - | RecipientActionExpired - | RecipientActionReplay - | RecipientActionConflict; - -function isTaggedRecipientActionError(error: unknown): error is RecipientClientFailure { - return ( - error instanceof RecipientActionInvalid || - error instanceof RecipientAccountRequired || - error instanceof RecipientActionForbidden || - error instanceof RecipientActionExpired || - error instanceof RecipientActionReplay || - error instanceof RecipientActionConflict - ); -} - -function statusForRecipientActionError(error: RecipientClientFailure): number { - switch (error._tag) { - case "RecipientActionInvalid": - return 400; - case "RecipientAccountRequired": - return 401; - case "RecipientActionForbidden": - return 403; - case "RecipientActionExpired": - return 410; - case "RecipientActionReplay": - case "RecipientActionConflict": - return 409; - } -} - -function statusForCloudApiError(error: CloudApiError): number { - switch (error._tag) { - case "CloudBadRequest": - return 400; - case "CloudUnauthorized": - return 401; - case "CloudForbidden": - return 403; - case "CloudPaymentRequired": - return 402; - case "CloudNotFound": - return 404; - case "CloudConflict": - return 409; - case "CloudServiceUnavailable": - return 503; - case "CloudUpstreamFailure": - return 502; - } -} - -function normalizeClientError(error: unknown): ApiClientError { - if (error instanceof ApiClientError) return error; - if (isTaggedCloudApiError(error)) { - return new ApiClientError({ - kind: "http", - message: error.message, - status: statusForCloudApiError(error), - code: error.code, - details: error.details, - cause: error, - }); - } - if (isTaggedShareInvitationError(error)) { - return new ApiClientError({ - kind: "http", - message: error.message, - status: error._tag === "ShareInvitationUnavailable" ? 404 : 409, - code: error._tag, - cause: error, - }); - } - if (isTaggedRecipientActionError(error)) { - return new ApiClientError({ - kind: "http", - message: error.message, - status: statusForRecipientActionError(error), - code: error._tag, - cause: error, - }); - } - if (Schema.isSchemaError(error)) { - return new ApiClientError({ - kind: "invalid_response", - message: "selftune API returned an invalid response payload.", - code: "invalid_response_payload", - details: String(error), - cause: error, - }); - } - if (HttpClientError.isHttpClientError(error)) { - const status = error.response?.status; - return new ApiClientError({ - kind: status === undefined ? "network" : "http", - message: - status === undefined - ? "Unable to reach the selftune API." - : `selftune API request failed with ${status}.`, - status, - cause: error, - }); - } - return new ApiClientError({ - kind: "network", - message: "Unable to reach the selftune API.", - cause: error, - }); -} - -export function createCloudApiClient(options: CloudApiClientOptions = {}): CloudApiClient { - const baseUrl = - options.baseUrl ?? - (typeof globalThis.location === "undefined" ? "http://localhost" : globalThis.location.origin); - const configuredHeaders = new Headers(options.headers); - const configuredHeaderEntries: Array = []; - configuredHeaders.forEach((value, key) => configuredHeaderEntries.push([key, value])); - const transformClient = HttpClient.mapRequestEffect((request) => - Effect.gen(function* () { - let next = HttpClientRequest.setHeaders(request, configuredHeaderEntries); - const token = options.getAccessToken - ? yield* Effect.promise(() => Promise.resolve(options.getAccessToken?.() ?? null)) - : null; - const organizationId = options.getActiveOrganizationId?.(); - if (token && !configuredHeaders.has("authorization")) { - next = HttpClientRequest.setHeader(next, "authorization", `Bearer ${token}`); - } - if (organizationId && !configuredHeaders.has("x-selftune-active-org-id")) { - next = HttpClientRequest.setHeader(next, "x-selftune-active-org-id", organizationId); - } - return next; - }), - ); - - const generated = Effect.runSync( - HttpApiClient.make(SelfTuneCloudApi, { - baseUrl, - transformClient, - }).pipe(Effect.provide(FetchHttpClient.layer)), - ); - const recipientActionsGenerated = Effect.runSync( - HttpApiClient.make(RecipientActionApi, { - baseUrl, - transformClient, - }).pipe(Effect.provide(FetchHttpClient.layer)), - ); - - function run(effect: Effect.Effect, requestOptions: ApiRequestOptions = {}) { - let runnable = effect.pipe( - Effect.provideService(FetchHttpClient.RequestInit, { - credentials: options.credentials ?? "include", - }), - Effect.catchCause((cause) => Effect.fail(normalizeClientError(Cause.squash(cause)))), - ); - if (options.fetch) { - runnable = runnable.pipe(Effect.provideService(FetchHttpClient.Fetch, options.fetch)); - } - return Effect.runPromise(runnable, { signal: requestOptions.signal }); - } - - return { - bootstrap: (requestOptions) => run(generated.cloud.bootstrap(), requestOptions), - distributionReadiness: (requestOptions) => - run(generated.cloud.distributionReadiness(), requestOptions), - creatorSignals: (requestOptions) => run(generated.cloud.creatorSignals(), requestOptions), - library: (requestOptions) => run(generated.cloud.library(), requestOptions), - skillDetail: (skillId, requestOptions) => - run(generated.cloud.skillDetail({ params: { skillId } }), requestOptions), - skillSets: (requestOptions) => run(generated.cloud.skillSets(), requestOptions), - createSkillSet: (input, requestOptions) => - run(generated.cloud.createSkillSet({ payload: input }), requestOptions), - updateSkillSet: ({ id, ...payload }, requestOptions) => - run(generated.cloud.updateSkillSet({ params: { id }, payload }), requestOptions), - deleteSkillSet: async (id, requestOptions) => { - await run(generated.cloud.deleteSkillSet({ params: { id } }), requestOptions); - }, - exportSkillSet: (id, requestOptions) => - run(generated.cloud.exportSkillSet({ params: { id } }), requestOptions), - materializeSkillSetExport: (id, requestOptions) => - run(generated.cloud.materializeSkillSetExport({ params: { id } }), requestOptions), - decideDeviceCode: (input, requestOptions) => - run(generated.cloud.decideDeviceCode({ payload: input }), requestOptions), - billingStatus: (requestOptions) => run(generated.billing.billingStatus(), requestOptions), - createBillingCheckout: (input, requestOptions) => - run( - generated.billing.createBillingCheckout({ payload: new BillingCheckoutInput(input) }), - requestOptions, - ), - createBillingPortal: (requestOptions) => - run(generated.billing.createBillingPortal(), requestOptions), - finalizeBillingCheckout: (input, requestOptions) => - run( - generated.billing.finalizeBillingCheckout({ - payload: new BillingCheckoutFinalizeInput(input), - }), - requestOptions, - ), - teamStatus: (requestOptions) => run(generated.team.teamStatus(), requestOptions), - inviteTeamMember: (input, requestOptions) => - run(generated.team.inviteTeamMember({ payload: new TeamInviteInput(input) }), requestOptions), - changeTeamMemberRole: (userId, input, requestOptions) => - run( - generated.team.changeTeamMemberRole({ - params: { userId }, - payload: new TeamRoleChangeInput(input), - }), - requestOptions, - ), - removeTeamMember: (userId, requestOptions) => - run(generated.team.removeTeamMember({ params: { userId } }), requestOptions), - cancelTeamInvitation: (invitationId, requestOptions) => - run(generated.team.cancelTeamInvitation({ params: { invitationId } }), requestOptions), - improveRuns: (requestOptions) => run(generated.cloudImprove.improveRuns(), requestOptions), - improveRun: (runId, requestOptions) => - run(generated.cloudImprove.improveRun({ params: { runId } }), requestOptions), - proposals: (query = {}, requestOptions) => - run( - generated.cloudImprove.proposals({ query: new CloudProposalQuery(query) }), - requestOptions, - ), - proposal: (proposalId, requestOptions) => - run(generated.cloudImprove.proposal({ params: { proposalId } }), requestOptions), - reviewProposal: (proposalId, input, requestOptions) => - run( - generated.cloudImprove.reviewProposal({ - params: { proposalId }, - payload: new CloudProposalReviewInput(input), - }), - requestOptions, - ), - applyProposal: (proposalId, input = {}, requestOptions) => - run( - generated.cloudImprove.applyProposal({ - params: { proposalId }, - payload: new CloudProposalApplyInput(input), - }), - requestOptions, - ), - githubStatus: (requestOptions) => run(generated.cloudGithub.githubStatus(), requestOptions), - startGithubInstall: (requestOptions) => - run(generated.cloudGithub.startGithubInstall(), requestOptions), - syncGithubConnection: (connectionId, requestOptions) => - run(generated.cloudGithub.syncGithubConnection({ params: { connectionId } }), requestOptions), - previewShareInvitation: (claimToken, requestOptions) => - run( - generated.shareInvitations.preview({ - payload: new ShareInvitationPreviewRequest({ claimToken }), - }), - requestOptions, - ), - claimShareInvitation: (input, requestOptions) => - run(generated.shareInvitations.claim({ payload: input }), requestOptions), - getShareInvitation: (invitationId, requestOptions) => - run( - generated.shareInvitations.recipientView({ params: { id: invitationId } }), - requestOptions, - ), - acceptShareInvitationLicense: (invitationId, input, requestOptions) => - run( - generated.shareInvitations.acceptLicense({ - params: { id: invitationId }, - payload: input, - }), - requestOptions, - ), - acceptAndImportShareInvitation: (invitationId, input, requestOptions) => - run( - generated.shareInvitations.acceptImport({ - params: { id: invitationId }, - payload: input, - }), - requestOptions, - ), - requestRecipientPortableDownload: (input, requestOptions) => - run( - recipientActionsGenerated.recipientActions.portableDownload({ payload: input }), - requestOptions, - ), - issueRecipientUseOnce: (input, requestOptions) => - run( - recipientActionsGenerated.recipientActions.useOnceIssue({ payload: input }), - requestOptions, - ), - previewRecipientUseOnce: (input, requestOptions) => - run( - recipientActionsGenerated.recipientActions.useOncePreview({ payload: input }), - requestOptions, - ), - consumeRecipientUseOnce: (input, requestOptions) => - run( - recipientActionsGenerated.recipientActions.useOnceConsume({ payload: input }), - requestOptions, - ), - bootstrapRecipientDesktopInstall: (input, requestOptions) => - run( - recipientActionsGenerated.recipientActions.desktopBootstrap({ payload: input }), - requestOptions, - ), - previewRecipientDesktopInstall: (input, requestOptions) => - run( - recipientActionsGenerated.recipientActions.desktopPreview({ payload: input }), - requestOptions, - ), - }; -} diff --git a/packages/api-contract/src/cloud-improve.ts b/packages/api-contract/src/cloud-improve.ts deleted file mode 100644 index 17e3c1d8..00000000 --- a/packages/api-contract/src/cloud-improve.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Schema } from "effect"; - -const NullableString = Schema.NullOr(Schema.String); -const NullableNumber = Schema.NullOr(Schema.Number); -const CloudEvaluationId = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(128), - // oxlint-disable-next-line no-control-regex -- portable identifiers reject ASCII control bytes. - Schema.isPattern(/^[^\u0000-\u001f\u007f]+$/), -); -const CloudEvaluationName = Schema.String.check( - Schema.isMinLength(1), - Schema.isMaxLength(200), - // oxlint-disable-next-line no-control-regex -- portable names reject ASCII control bytes. - Schema.isPattern(/^[^\u0000-\u001f\u007f]+$/), -); -const CloudPackageRevision = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); -const CloudManifestDigest = Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)); -const CloudRepetitionCount = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 })); - -export class CloudImproveRun extends Schema.Class("CloudImproveRun")({ - id: Schema.String, - sourceId: Schema.String, - snapshotId: Schema.String, - evalSuiteId: NullableString, - status: Schema.String, - phase: NullableString, - applyTarget: Schema.String, - providerModel: NullableString, - startedAt: NullableString, - completedAt: NullableString, - createdAt: Schema.String, - updatedAt: Schema.String, -}) {} - -/** Read-only, case-free handoff from a local trace candidate to Cloud evaluation. */ -export class CloudEvaluationTarget extends Schema.Class( - "CloudEvaluationTarget", -)({ - source_id: CloudEvaluationId, - snapshot_id: CloudEvaluationId, - skill_id: CloudEvaluationId, - skill_name: CloudEvaluationName, - skill_revision: CloudPackageRevision, - suite_id: CloudEvaluationId, - suite_name: CloudEvaluationName, - lane: Schema.Literals(["structural_validation", "trigger_routing", "outcome_task"]), - manifest_digest: CloudManifestDigest, - verifier_kind: CloudEvaluationId, - min_repetitions: CloudRepetitionCount, - max_repetitions: CloudRepetitionCount, - verification_only: Schema.Boolean, -}) {} - -export class CloudEvaluationTargetBlocker extends Schema.Class( - "CloudEvaluationTargetBlocker", -)({ - code: CloudEvaluationId, - message: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(1_000)), - suite_id: Schema.optional(CloudEvaluationId), -}) {} - -export class CloudEvaluationTargetDiscovery extends Schema.Class( - "CloudEvaluationTargetDiscovery", -)({ - targets: Schema.Array(CloudEvaluationTarget).check(Schema.isMaxLength(50)), - blockers: Schema.Array(CloudEvaluationTargetBlocker).check(Schema.isMaxLength(100)), -}) {} - -export class CloudEvaluationTargetQuery extends Schema.Class( - "CloudEvaluationTargetQuery", -)({ - skill_name: CloudEvaluationName, - // This is the canonical package-manifest SHA-256, not the archive hash. - skill_revision: CloudPackageRevision, -}) {} - -export class CloudImproveCandidate extends Schema.Class( - "CloudImproveCandidate", -)({ - id: Schema.String, - status: Schema.String, - candidateIndex: Schema.Int, - diffText: NullableString, - currentSkillScore: NullableNumber, - candidateSkillScore: NullableNumber, - improvementPct: NullableNumber, -}) {} - -export class CloudImproveRunDetail extends Schema.Class( - "CloudImproveRunDetail", -)({ - ...CloudImproveRun.fields, - candidates: Schema.Array(CloudImproveCandidate), - outcome: NullableString, -}) {} - -export class CloudImproveRunList extends Schema.Class("CloudImproveRunList")({ - runs: Schema.Array(CloudImproveRun), - total: Schema.Int, -}) {} - -export const CloudProposalStatus = Schema.Literals(["pending", "approved", "rejected", "applied"]); -export type CloudProposalStatus = Schema.Schema.Type; - -export class CloudProposal extends Schema.Class("CloudProposal")({ - id: Schema.String, - skillId: Schema.String, - skillName: Schema.String, - proposalType: Schema.String, - currentValue: Schema.String, - proposedValue: Schema.String, - rationale: NullableString, - passRateBefore: NullableNumber, - projectedPassRate: NullableNumber, - status: CloudProposalStatus, - createdAt: Schema.String, - reviewedAt: NullableString, - appliedAt: NullableString, - runId: NullableString, - candidateId: NullableString, - applyTarget: NullableString, - diffText: NullableString, -}) {} - -export class CloudProposalList extends Schema.Class("CloudProposalList")({ - proposals: Schema.Array(CloudProposal), - total: Schema.Int, -}) {} - -export class CloudProposalQuery extends Schema.Class("CloudProposalQuery")({ - runId: Schema.optional(Schema.String), -}) {} - -export class CloudProposalReviewInput extends Schema.Class( - "CloudProposalReviewInput", -)({ - status: Schema.Literals(["approved", "rejected"]), -}) {} - -export class CloudProposalApplyInput extends Schema.Class( - "CloudProposalApplyInput", -)({ - applyTarget: Schema.optional(Schema.Literals(["draft", "github_pr"])), -}) {} - -export class CloudProposalApplyResult extends Schema.Class( - "CloudProposalApplyResult", -)({ - proposalId: Schema.String, - status: CloudProposalStatus, - applyTarget: Schema.String, - message: Schema.String, -}) {} - -export const CloudImproveApiPaths = { - evaluationTargets: "/api/v1/cloud/evaluation-targets", - runs: "/api/v1/cloud/improve-runs", - run: "/api/v1/cloud/improve-runs/:runId", - proposals: "/api/v1/cloud/proposals", - proposal: "/api/v1/cloud/proposals/:proposalId", - proposalApply: "/api/v1/cloud/proposals/:proposalId/apply", -} as const; diff --git a/packages/api-contract/src/contracts.ts b/packages/api-contract/src/contracts.ts deleted file mode 100644 index 8f4e25a3..00000000 --- a/packages/api-contract/src/contracts.ts +++ /dev/null @@ -1,795 +0,0 @@ -import { Exit, Schema } from "effect"; -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; - -import { - ShareInvitationAcceptance, - ShareInvitationAcceptanceResult, - ShareInvitationAcceptImport, - ShareInvitationClaim, - ShareInvitationClaimResult, - ShareInvitationConflict, - ShareInvitationIdSchema, - ShareInvitationImportResult, - ShareInvitationPreview, - ShareInvitationPreviewRequest, - ShareInvitationRecipientView, - ShareInvitationUnavailable, -} from "./share-invitations"; -import { - RecipientAccountRequired, - RecipientActionConflict, - RecipientActionExpired, - RecipientActionForbidden, - RecipientActionInvalid, - RecipientActionReplay, - RecipientDesktopInstallBootstrapRequestSchema, - RecipientDesktopInstallBootstrapResponseSchema, - RecipientDesktopInstallPreviewRequestSchema, - RecipientDesktopInstallPreviewResponseSchema, - RecipientPortableDownloadRequestSchema, - RecipientPortableDownloadResponseSchema, - RecipientUseOnceConsumeRequestSchema, - RecipientUseOnceConsumeResponseSchema, - RecipientUseOnceIssueRequestSchema, - RecipientUseOnceIssueResponseSchema, - RecipientUseOncePreviewRequestSchema, - RecipientUseOncePreviewResponseSchema, -} from "./recipient-actions"; -import { - CreatorSignalsInventory, - DistributionReadinessInventory, -} from "./distribution-read-models"; -import { - BillingApiPaths, - BillingCheckoutFinalizeInput, - BillingCheckoutFinalizeResult, - BillingCheckoutInput, - BillingPlanIdSchema, - BillingSession, - BillingStatus, -} from "./billing"; -import { - CloudImproveApiPaths, - CloudImproveRunDetail, - CloudImproveRunList, - CloudProposal, - CloudProposalApplyInput, - CloudProposalApplyResult, - CloudProposalList, - CloudProposalQuery, - CloudProposalReviewInput, -} from "./cloud-improve"; -import { - CloudGithubApiPaths, - CloudGithubConnectionSync, - CloudGithubInstallSession, - CloudGithubStatus, -} from "./github"; -import { TeamApiPaths, TeamInviteInput, TeamRoleChangeInput, TeamStatus } from "./team"; - -export const ApiRoleSchema = Schema.Literals(["viewer", "member", "admin", "owner"]); -export type ApiRole = Schema.Schema.Type; - -export const ApiPlanSchema = BillingPlanIdSchema; -export type ApiPlan = Schema.Schema.Type; - -export const OnboardingStateSchema = Schema.Literals([ - "workspace_empty", - "import_review", - "inventory_seeded", - "first_run_queued", - "first_result_ready", -]); -export type OnboardingState = Schema.Schema.Type; - -export const CloudBootstrapSchema = Schema.Struct({ - viewer: Schema.Struct({ - id: Schema.String, - email: Schema.String, - name: Schema.NullOr(Schema.String), - avatarUrl: Schema.NullOr(Schema.String), - }), - memberships: Schema.mutable( - Schema.Array( - Schema.Struct({ - orgId: Schema.String, - orgName: Schema.String, - orgSlug: Schema.String, - role: ApiRoleSchema, - plan: ApiPlanSchema, - }), - ), - ), - activeWorkspace: Schema.Struct({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, - }), - plan: ApiPlanSchema, - onboardingState: OnboardingStateSchema, -}); -export type CloudBootstrap = Schema.Schema.Type; - -export const CloudLibrarySkillSchema = Schema.Struct({ - id: Schema.String, - name: Schema.String, - lifecycle: Schema.Literals(["active", "library", "draft", "archived"]), - status: Schema.String, - updateStatus: Schema.Literals(["available", "current", "unknown", "untracked"]), - sources: Schema.mutable( - Schema.Array( - Schema.Struct({ - kind: Schema.Literals(["github", "upload", "draft", "local", "other"]), - label: Schema.String, - href: Schema.optional(Schema.NullOr(Schema.String)), - path: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - locations: Schema.mutable(Schema.Array(Schema.Never)), - revisionHashes: Schema.mutable(Schema.Array(Schema.String)), - modifiedAt: Schema.optional(Schema.NullOr(Schema.String)), - lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)), - detailHref: Schema.optional(Schema.NullOr(Schema.String)), -}); - -export const CloudLibraryInventorySchema = Schema.Struct({ - skills: Schema.mutable(Schema.Array(CloudLibrarySkillSchema)), - categoryOptions: Schema.mutable( - Schema.Array(Schema.Struct({ id: Schema.String, label: Schema.String })), - ), - summary: Schema.optional( - Schema.NullOr( - Schema.Struct({ - ready: Schema.Int, - snapshots: Schema.Int, - pendingActions: Schema.Int, - }), - ), - ), - note: Schema.optional( - Schema.NullOr( - Schema.Struct({ - title: Schema.String, - description: Schema.String, - link: Schema.optional( - Schema.NullOr(Schema.Struct({ label: Schema.String, href: Schema.String })), - ), - }), - ), - ), -}); -export type CloudLibraryInventory = Schema.Schema.Type; - -/** A compact operational view of one workspace skill. - * - * This deliberately keeps trace-sized activity collections out of the initial - * detail response. The detail screen needs identity, source health, and enough - * activity context to direct the next action; runs and proposals own their - * respective deeper screens. - */ -export const CloudSkillDetailSourceSchema = Schema.Struct({ - id: Schema.String, - label: Schema.String, - kind: Schema.Literals(["github", "upload", "draft", "local", "other"]), - status: Schema.String, - capabilityStatus: Schema.NullOr(Schema.String), - repoFullName: Schema.NullOr(Schema.String), - skillPath: Schema.NullOr(Schema.String), - updatedAt: Schema.String, -}); - -export const CloudSkillDetailSchema = Schema.Struct({ - id: Schema.String, - name: Schema.String, - platform: Schema.String, - description: Schema.NullOr(Schema.String), - sources: Schema.mutable(Schema.Array(CloudSkillDetailSourceSchema)), - activity: Schema.Struct({ - evalSuites: Schema.Int, - improvementRuns: Schema.Int, - pendingProposals: Schema.Int, - }), -}); -export type CloudSkillDetail = Schema.Schema.Type; - -export const CloudSkillDetailParams = Schema.Struct({ - skillId: Schema.NonEmptyString, -}); - -export const ProjectConnectionSchema = Schema.Literals([ - "codex", - "claude_code", - "opencode", - "openclaw", - "pi", -]); - -export const CloudInstalledSkillSetInputSkillSchema = Schema.Struct({ - name: Schema.String, - packagePath: Schema.String, - provenance: Schema.optional(Schema.Literal("installed")), -}); - -export const CloudSkillSetInputSkillSchema = Schema.Union([ - CloudInstalledSkillSetInputSkillSchema, - Schema.Struct({ - name: Schema.String, - provenance: Schema.Literal("catalog"), - catalogId: Schema.String, - source: Schema.String, - installSpec: Schema.String, - downloadUrl: Schema.optional(Schema.NullOr(Schema.String)), - }), -]); - -export const CloudSkillSetInputSchema = Schema.Struct({ - name: Schema.String, - description: Schema.String, - connections: Schema.mutable(Schema.Array(ProjectConnectionSchema)), - skills: Schema.mutable(Schema.Array(CloudSkillSetInputSkillSchema)), -}); -export type CloudSkillSetInput = Schema.Schema.Type; - -export const CloudSkillSetUpdatePayloadSchema = Schema.Struct({ - parentRevisionHash: Schema.String, - name: Schema.String, - description: Schema.String, - connections: Schema.mutable(Schema.Array(ProjectConnectionSchema)), - skills: Schema.mutable(Schema.Array(CloudInstalledSkillSetInputSkillSchema)), -}); -export type CloudSkillSetUpdatePayload = Schema.Schema.Type< - typeof CloudSkillSetUpdatePayloadSchema ->; - -export const CloudSkillSetUpdateSchema = Schema.Struct({ - id: Schema.String, - ...CloudSkillSetUpdatePayloadSchema.fields, -}); -export type CloudSkillSetUpdate = Schema.Schema.Type; - -export const CloudSkillSetSchema = Schema.Struct({ - id: Schema.String, - name: Schema.String, - description: Schema.String, - connections: Schema.mutable(Schema.Array(ProjectConnectionSchema)), - skills: Schema.mutable( - Schema.Array( - Schema.Struct({ - name: Schema.String, - packagePath: Schema.String, - contentHash: Schema.String, - }), - ), - ), - revision: Schema.Int.check(Schema.isGreaterThan(0)), - revisionHash: Schema.String, - updatedAt: Schema.String, - ownerScope: Schema.optional(Schema.Literals(["personal", "workspace"])), - ownerName: Schema.optional(Schema.NullOr(Schema.String)), - workspacePolicy: Schema.optional( - Schema.NullOr( - Schema.Struct({ - action: Schema.Literals(["allow", "require_approval", "block", "require"]), - reason: Schema.NullOr(Schema.String), - }), - ), - ), -}); - -export const CloudSkillSetInventorySchema = Schema.Struct({ - skillSets: Schema.mutable(Schema.Array(CloudSkillSetSchema)), - availableSkills: Schema.mutable( - Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - packagePath: Schema.String, - contentHash: Schema.String, - lifecycle: Schema.String, - }), - ), - ), - receipts: Schema.mutable(Schema.Array(Schema.Never)), -}); -export type CloudSkillSetInventory = Schema.Schema.Type; -export type CloudSkillSet = Schema.Schema.Type; - -export const CloudSkillSetExportSchema = Schema.Struct({ - filename: Schema.String, - contentType: Schema.String, - content: Schema.String, -}); -export type CloudSkillSetExport = Schema.Schema.Type; - -export const DeletedSchema = Schema.Struct({ deleted: Schema.Literal(true) }); -export type Deleted = Schema.Schema.Type; - -const ErrorFields = { - code: Schema.String, - message: Schema.String, - details: Schema.optional(Schema.Unknown), -}; - -export class CloudBadRequest extends Schema.TaggedErrorClass()( - "CloudBadRequest", - ErrorFields, - { httpApiStatus: 400 }, -) {} - -export class CloudUnauthorized extends Schema.TaggedErrorClass()( - "CloudUnauthorized", - ErrorFields, - { httpApiStatus: 401 }, -) {} - -export class CloudForbidden extends Schema.TaggedErrorClass()( - "CloudForbidden", - ErrorFields, - { httpApiStatus: 403 }, -) {} - -export class CloudPaymentRequired extends Schema.TaggedErrorClass()( - "CloudPaymentRequired", - ErrorFields, - { httpApiStatus: 402 }, -) {} - -export class CloudNotFound extends Schema.TaggedErrorClass()( - "CloudNotFound", - ErrorFields, - { httpApiStatus: 404 }, -) {} - -export class CloudConflict extends Schema.TaggedErrorClass()( - "CloudConflict", - ErrorFields, - { httpApiStatus: 409 }, -) {} - -export class CloudServiceUnavailable extends Schema.TaggedErrorClass()( - "CloudServiceUnavailable", - ErrorFields, - { httpApiStatus: 503 }, -) {} - -export class CloudUpstreamFailure extends Schema.TaggedErrorClass()( - "CloudUpstreamFailure", - ErrorFields, - { httpApiStatus: 502 }, -) {} - -export const CloudApiErrorSchema = Schema.Union([ - CloudBadRequest, - CloudUnauthorized, - CloudForbidden, - CloudPaymentRequired, - CloudNotFound, - CloudConflict, - CloudServiceUnavailable, - CloudUpstreamFailure, -]); -export type CloudApiError = Schema.Schema.Type; - -export const CloudDeviceCodeDecisionInputSchema = Schema.Struct({ - userCode: Schema.String, - action: Schema.Literals(["approve", "deny"]), -}); -export type CloudDeviceCodeDecisionInput = Schema.Schema.Type< - typeof CloudDeviceCodeDecisionInputSchema ->; - -export const CloudDeviceCodeDecisionSchema = Schema.Struct({ - status: Schema.Literals(["approved", "denied"]), -}); -export type CloudDeviceCodeDecision = Schema.Schema.Type; - -export const LegacyApiErrorEnvelopeSchema = Schema.Struct({ - error: Schema.Struct({ - code: Schema.optional(Schema.String), - message: Schema.String, - status: Schema.optional(Schema.Int), - details: Schema.optional(Schema.Unknown), - }), -}); -export type LegacyApiErrorEnvelope = Schema.Schema.Type; - -const CloudEndpointErrors = [ - CloudBadRequest, - CloudUnauthorized, - CloudForbidden, - CloudPaymentRequired, - CloudNotFound, - CloudConflict, - CloudServiceUnavailable, - CloudUpstreamFailure, -] as const; - -export const CloudApiPaths = { - bootstrap: "/api/v1/cloud/bootstrap", - library: "/api/v1/cloud/library", - skillDetail: "/api/v1/cloud/skills/:skillId", - skillSets: "/api/v1/cloud/skill-sets", - skillSet: "/api/v1/cloud/skill-sets/:id", - skillSetExport: "/api/v1/cloud/skill-sets/:id/export", - skillSetExportMaterialize: "/api/v1/cloud/skill-sets/:id/export/materialize", - deviceCode: "/api/v1/cloud/device-code", - distributionReadiness: "/api/v1/cloud/distribution-readiness", - creatorSignals: "/api/v1/cloud/creator-signals", -} as const; - -export class CloudApiGroup extends HttpApiGroup.make("cloud") - .add( - HttpApiEndpoint.get("distributionReadiness", CloudApiPaths.distributionReadiness, { - success: DistributionReadinessInventory, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("creatorSignals", CloudApiPaths.creatorSignals, { - success: CreatorSignalsInventory, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("bootstrap", CloudApiPaths.bootstrap, { - success: CloudBootstrapSchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("library", CloudApiPaths.library, { - success: CloudLibraryInventorySchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("skillDetail", CloudApiPaths.skillDetail, { - params: CloudSkillDetailParams, - success: CloudSkillDetailSchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("skillSets", CloudApiPaths.skillSets, { - success: CloudSkillSetInventorySchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("createSkillSet", CloudApiPaths.skillSets, { - payload: CloudSkillSetInputSchema, - success: CloudSkillSetSchema.pipe(HttpApiSchema.status("Created")), - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.patch("updateSkillSet", CloudApiPaths.skillSet, { - params: { id: Schema.String }, - payload: CloudSkillSetUpdatePayloadSchema, - success: CloudSkillSetSchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.delete("deleteSkillSet", CloudApiPaths.skillSet, { - params: { id: Schema.String }, - success: DeletedSchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("exportSkillSet", CloudApiPaths.skillSetExport, { - params: { id: Schema.String }, - success: CloudSkillSetExportSchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("materializeSkillSetExport", CloudApiPaths.skillSetExportMaterialize, { - params: { id: Schema.String }, - success: CloudSkillSetExportSchema, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("decideDeviceCode", CloudApiPaths.deviceCode, { - payload: CloudDeviceCodeDecisionInputSchema, - success: CloudDeviceCodeDecisionSchema, - error: CloudEndpointErrors, - }), - ) {} - -export class BillingApiGroup extends HttpApiGroup.make("billing") - .add( - HttpApiEndpoint.get("billingStatus", BillingApiPaths.status, { - success: BillingStatus, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("createBillingCheckout", BillingApiPaths.checkout, { - payload: BillingCheckoutInput, - success: BillingSession, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("createBillingPortal", BillingApiPaths.portal, { - success: BillingSession, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("finalizeBillingCheckout", BillingApiPaths.finalizeCheckout, { - payload: BillingCheckoutFinalizeInput, - success: BillingCheckoutFinalizeResult, - error: CloudEndpointErrors, - }), - ) {} - -export class TeamApiGroup extends HttpApiGroup.make("team") - .add( - HttpApiEndpoint.get("teamStatus", TeamApiPaths.status, { - success: TeamStatus, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("inviteTeamMember", TeamApiPaths.invite, { - payload: TeamInviteInput, - success: TeamStatus, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.patch("changeTeamMemberRole", TeamApiPaths.member, { - params: { userId: Schema.String }, - payload: TeamRoleChangeInput, - success: TeamStatus, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.delete("removeTeamMember", TeamApiPaths.member, { - params: { userId: Schema.String }, - success: TeamStatus, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.delete("cancelTeamInvitation", TeamApiPaths.invitation, { - params: { invitationId: Schema.String }, - success: TeamStatus, - error: CloudEndpointErrors, - }), - ) {} - -export class CloudImproveApiGroup extends HttpApiGroup.make("cloudImprove") - .add( - HttpApiEndpoint.get("improveRuns", CloudImproveApiPaths.runs, { - success: CloudImproveRunList, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("improveRun", CloudImproveApiPaths.run, { - params: { runId: Schema.String }, - success: CloudImproveRunDetail, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("proposals", CloudImproveApiPaths.proposals, { - query: CloudProposalQuery, - success: CloudProposalList, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("proposal", CloudImproveApiPaths.proposal, { - params: { proposalId: Schema.String }, - success: CloudProposal, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.patch("reviewProposal", CloudImproveApiPaths.proposal, { - params: { proposalId: Schema.String }, - payload: CloudProposalReviewInput, - success: CloudProposal, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("applyProposal", CloudImproveApiPaths.proposalApply, { - params: { proposalId: Schema.String }, - payload: CloudProposalApplyInput, - success: CloudProposalApplyResult, - error: CloudEndpointErrors, - }), - ) {} - -/** Cloud-hosted GitHub App connection management. The app callback binds the installation here. */ -export class CloudGithubApiGroup extends HttpApiGroup.make("cloudGithub") - .add( - HttpApiEndpoint.get("githubStatus", CloudGithubApiPaths.status, { - success: CloudGithubStatus, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("startGithubInstall", CloudGithubApiPaths.startInstall, { - success: CloudGithubInstallSession, - error: CloudEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("syncGithubConnection", CloudGithubApiPaths.syncConnection, { - params: { connectionId: Schema.String }, - success: CloudGithubConnectionSync, - error: CloudEndpointErrors, - }), - ) {} - -export const ShareInvitationApiPaths = { - preview: "/api/v1/public/share-invitations/preview", - claim: "/api/v1/share-invitations/claim", - recipientView: "/api/v1/share-invitations/:id", - acceptance: "/api/v1/share-invitations/:id/license-acceptance", - acceptImport: "/api/v1/share-invitations/:id/import", -} as const; - -const ShareInvitationEndpointErrors = [ - ShareInvitationUnavailable, - ShareInvitationConflict, - CloudBadRequest, - CloudUnauthorized, - CloudUpstreamFailure, -] as const; - -export class ShareInvitationApiGroup extends HttpApiGroup.make("shareInvitations") - .add( - HttpApiEndpoint.post("preview", ShareInvitationApiPaths.preview, { - payload: ShareInvitationPreviewRequest, - success: ShareInvitationPreview, - error: ShareInvitationEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("claim", ShareInvitationApiPaths.claim, { - payload: ShareInvitationClaim, - success: ShareInvitationClaimResult, - error: ShareInvitationEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.get("recipientView", ShareInvitationApiPaths.recipientView, { - params: { id: ShareInvitationIdSchema }, - success: ShareInvitationRecipientView, - error: ShareInvitationEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("acceptLicense", ShareInvitationApiPaths.acceptance, { - params: { id: ShareInvitationIdSchema }, - payload: ShareInvitationAcceptance, - success: ShareInvitationAcceptanceResult, - error: ShareInvitationEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("acceptImport", ShareInvitationApiPaths.acceptImport, { - params: { id: ShareInvitationIdSchema }, - payload: ShareInvitationAcceptImport, - success: ShareInvitationImportResult, - error: ShareInvitationEndpointErrors, - }), - ) {} - -export const RecipientActionApiPaths = { - portableDownload: "/api/v1/recipient-actions/download", - useOnceIssue: "/api/v1/recipient-actions/use-once/issue", - useOncePreview: "/api/v1/recipient-actions/use-once/preview", - useOnceConsume: "/api/v1/recipient-actions/use-once/consume", - desktopBootstrap: "/api/v1/recipient-actions/desktop/bootstrap", - desktopPreview: "/api/v1/recipient-actions/desktop/preview", -} as const; - -const RecipientActionEndpointErrors = [ - RecipientActionInvalid, - RecipientAccountRequired, - RecipientActionForbidden, - RecipientActionExpired, - RecipientActionReplay, - RecipientActionConflict, -] as const; - -export class RecipientActionApiGroup extends HttpApiGroup.make("recipientActions") - .add( - HttpApiEndpoint.post("portableDownload", RecipientActionApiPaths.portableDownload, { - payload: RecipientPortableDownloadRequestSchema, - success: RecipientPortableDownloadResponseSchema, - error: RecipientActionEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("useOnceIssue", RecipientActionApiPaths.useOnceIssue, { - payload: RecipientUseOnceIssueRequestSchema, - success: RecipientUseOnceIssueResponseSchema, - error: RecipientActionEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("useOncePreview", RecipientActionApiPaths.useOncePreview, { - payload: RecipientUseOncePreviewRequestSchema, - success: RecipientUseOncePreviewResponseSchema, - error: RecipientActionEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("useOnceConsume", RecipientActionApiPaths.useOnceConsume, { - payload: RecipientUseOnceConsumeRequestSchema, - success: RecipientUseOnceConsumeResponseSchema, - error: RecipientActionEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("desktopBootstrap", RecipientActionApiPaths.desktopBootstrap, { - payload: RecipientDesktopInstallBootstrapRequestSchema, - success: RecipientDesktopInstallBootstrapResponseSchema, - error: RecipientActionEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("desktopPreview", RecipientActionApiPaths.desktopPreview, { - payload: RecipientDesktopInstallPreviewRequestSchema, - success: RecipientDesktopInstallPreviewResponseSchema, - error: RecipientActionEndpointErrors, - }), - ) {} - -/** Separate contract surface so Phase 4A adds no host route implementation requirement. */ -export const RecipientActionApi = HttpApi.make("selftune-recipient-actions").add( - RecipientActionApiGroup, -); - -export const SelfTuneCloudApi = HttpApi.make("selftune-cloud") - .add(CloudApiGroup) - .add(BillingApiGroup) - .add(TeamApiGroup) - .add(CloudImproveApiGroup) - .add(CloudGithubApiGroup) - .add(ShareInvitationApiGroup); - -export interface DecodeSuccess { - readonly success: true; - readonly data: A; -} - -export interface DecodeFailure { - readonly success: false; - readonly issues: string; -} - -export type DecodeResult = DecodeSuccess | DecodeFailure; - -/** Plain adapter for legacy runtimes that must not import a second validation library. */ -export function decodeUnknown>( - schema: S, - input: unknown, -): DecodeResult { - const result = Schema.decodeUnknownExit(schema)(input); - return Exit.isSuccess(result) - ? { success: true, data: result.value } - : { success: false, issues: String(result.cause) }; -} - -export function decodeUnknownOrThrow>( - schema: S, - input: unknown, -): S["Type"] { - const result = decodeUnknown(schema, input); - if (result.success) return result.data; - throw new TypeError(result.issues); -} diff --git a/packages/api-contract/src/distribution-read-models.ts b/packages/api-contract/src/distribution-read-models.ts deleted file mode 100644 index e377af14..00000000 --- a/packages/api-contract/src/distribution-read-models.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Schema } from "effect"; - -import { - DistributionBlocker, - DistributionChannelSchema, - DistributionIdSchema, - Sha256Schema, - UtcTimestampSchema, -} from "./distribution"; - -export const DistributionReadinessResultSchema = Schema.Literals([ - "ready", - "blocked", - "manual_review_required", - "revoked", -]); -export type DistributionReadinessResult = Schema.Schema.Type< - typeof DistributionReadinessResultSchema ->; - -export class DistributionReadinessItem extends Schema.Class( - "DistributionReadinessItem", -)({ - distributionId: DistributionIdSchema, - subjectKind: Schema.Literals(["skill_revision", "skill_set"]), - subjectId: Schema.NonEmptyString, - sourceRevisionHash: Sha256Schema, - channel: DistributionChannelSchema, - agentSkillsConformance: Schema.Struct({ - status: Schema.Literals(["conformant", "nonconformant", "unknown"]), - detail: Schema.NonEmptyString, - }), - detectedLicense: Schema.Struct({ - status: Schema.Literals(["verified", "missing", "manual_review_required"]), - expression: Schema.NullOr(Schema.NonEmptyString), - evidenceSha256: Schema.NullOr(Sha256Schema), - }), - termsFile: Schema.Struct({ - status: Schema.Literals(["verified", "not_required", "missing"]), - path: Schema.NullOr(Schema.NonEmptyString), - sha256: Schema.NullOr(Sha256Schema), - }), - provenance: Schema.Struct({ - kind: Schema.NullOr(Schema.NonEmptyString), - strength: Schema.Literals(["verified", "self_attested", "unknown"]), - sourceRepository: Schema.NullOr(Schema.String), - }), - rightsHolder: Schema.Struct({ - kind: Schema.NullOr(Schema.Literals(["organization", "user", "external"])), - label: Schema.NullOr(Schema.NonEmptyString), - verificationState: Schema.NullOr(Schema.NonEmptyString), - }), - channelAttestation: Schema.Struct({ - attested: Schema.Boolean, - termsVersion: Schema.NullOr(Schema.NonEmptyString), - }), - contributorSignals: Schema.Struct({ - state: Schema.Literals(["unconfigured", "disabled", "enabled"]), - defaultState: Schema.Literal("off"), - telemetryRecipientOrganizationId: Schema.NullOr(Schema.String), - allowedFields: Schema.Array(Schema.Literals(["trigger", "grade", "miss_category"])), - }), - blockers: Schema.Array(DistributionBlocker), - finalResult: DistributionReadinessResultSchema, - technicalChecksLegalNotice: Schema.NonEmptyString, - assessedAt: UtcTimestampSchema, -}) {} - -export class DistributionReadinessInventory extends Schema.Class( - "DistributionReadinessInventory", -)({ - generatedAt: UtcTimestampSchema, - items: Schema.Array(DistributionReadinessItem), -}) {} - -export const CreatorSignalCoverageSchema = Schema.Struct({ - status: Schema.Literals(["none", "below_threshold", "available"]), - eventCount: Schema.NullOr(Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)))), - distinctCohorts: Schema.NullOr(Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)))), -}); - -export const CreatorSignalAggregateStateSchema = Schema.Literals([ - "zero_usage", - "no_compatible_host", - "no_consent", - "below_threshold", - "available", -]); - -export class CreatorSignalDistribution extends Schema.Class( - "CreatorSignalDistribution", -)({ - distributionId: DistributionIdSchema, - subjectId: Schema.NonEmptyString, - logicalSkillVersion: Sha256Schema, - channel: DistributionChannelSchema, - distributionStatus: Schema.NonEmptyString, - capability: Schema.Struct({ - status: Schema.Literals(["not_issued", "active", "revoked", "rotated", "expired"]), - issuedAt: Schema.NullOr(UtcTimestampSchema), - revokedAt: Schema.NullOr(UtcTimestampSchema), - }), - connectedCoverage: CreatorSignalCoverageSchema, - portableCoverage: CreatorSignalCoverageSchema, - aggregateState: CreatorSignalAggregateStateSchema, - aggregates: Schema.NullOr( - Schema.Struct({ - totalSignals: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))), - triggeredCount: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))), - missedCount: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))), - triggerRate: Schema.NullOr(Schema.Number), - lastSeenAt: Schema.NullOr(UtcTimestampSchema), - }), - ), -}) {} - -export class CreatorSignalsInventory extends Schema.Class( - "CreatorSignalsInventory", -)({ - source: Schema.Literal("contributor_aggregate"), - generatedAt: UtcTimestampSchema, - privacyThreshold: Schema.Struct({ - minimumTrustedCohorts: Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))), - portableSignalsCountTowardThreshold: Schema.Literal(false), - }), - distributions: Schema.Array(CreatorSignalDistribution), -}) {} diff --git a/packages/api-contract/src/distribution-source-staging.ts b/packages/api-contract/src/distribution-source-staging.ts deleted file mode 100644 index 3edd7c37..00000000 --- a/packages/api-contract/src/distribution-source-staging.ts +++ /dev/null @@ -1,950 +0,0 @@ -import { Schema } from "effect"; - -import { - DistributionActorIdSchema, - DistributionAuthorizationIdSchema, - DistributionSecurityBoundaryParseOptions, - DistributionSubjectSchema, - GitObjectHashSchema, - hasSafeRelativePackagePathCollision, - LicenseKindSchema, - LicensePolicyDispositionSchema, - OrganizationIdSchema, - RightsClaimIdSchema, - SafeRelativePathSchema, - Sha256Schema, - SkillRevisionDistributionSubject, - SkillSetDistributionSubject, - SkillSetIdSchema, - UtcTimestampSchema, -} from "./distribution"; - -export const DistributionSourceRevisionIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionSourceRevisionId"), -); -export type DistributionSourceRevisionId = Schema.Schema.Type< - typeof DistributionSourceRevisionIdSchema ->; - -export const DistributionSourceObjectIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionSourceObjectId"), -); -export type DistributionSourceObjectId = Schema.Schema.Type< - typeof DistributionSourceObjectIdSchema ->; - -export const DistributionSourceRequestIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionSourceRequestId"), -); -export type DistributionSourceRequestId = Schema.Schema.Type< - typeof DistributionSourceRequestIdSchema ->; - -export const DistributionSourceIdempotencyKeySchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionSourceIdempotencyKey"), -); -export type DistributionSourceIdempotencyKey = Schema.Schema.Type< - typeof DistributionSourceIdempotencyKeySchema ->; - -export const MaximumDistributionSourceObjectBytes = 25 * 1024 * 1024; -export const MaximumDistributionSourceAggregateBytes = 25 * 1024 * 1024; -export const MaximumDistributionSourceSkillSetComponents = 500; -export const CanonicalDistributionSourceObjectFormatSchema = Schema.Literals([ - "selftune-package-v2", - "selftune-skill-set-source-v1", -]); -export type CanonicalDistributionSourceObjectFormat = Schema.Schema.Type< - typeof CanonicalDistributionSourceObjectFormatSchema ->; - -const PositiveByteLengthSchema = Schema.Int.check( - Schema.isBetween({ - minimum: 1, - maximum: MaximumDistributionSourceObjectBytes, - }), -); -const NonNegativeOrdinalSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); - -/** Upload receipt binding logical revision identity to one exact canonical package object. */ -export class CanonicalDistributionSourceObject extends Schema.Class( - "CanonicalDistributionSourceObject", -)({ - objectId: DistributionSourceObjectIdSchema, - format: CanonicalDistributionSourceObjectFormatSchema, - sourceRevisionHash: Sha256Schema, - sourceObjectSha256: Sha256Schema, - byteLength: PositiveByteLengthSchema, -}) {} - -export class StandaloneDistributionSourceBom extends Schema.TaggedClass()( - "standalone", - {}, -) {} - -export class DistributionSourceBomComponent extends Schema.Class( - "DistributionSourceBomComponent", -)({ - ordinal: NonNegativeOrdinalSchema, - subject: SkillRevisionDistributionSubject, - object: CanonicalDistributionSourceObject, -}) {} - -export class SkillSetDistributionSourceBom extends Schema.TaggedClass()( - "skill_set", - { - skillSetId: SkillSetIdSchema, - components: Schema.NonEmptyArray(DistributionSourceBomComponent), - }, -) {} - -export const DistributionSourceBomSchema = Schema.Union([ - StandaloneDistributionSourceBom, - SkillSetDistributionSourceBom, -]).check( - Schema.makeFilter((bom) => { - if (bom._tag === "standalone") return undefined; - if (bom.components.length > MaximumDistributionSourceSkillSetComponents) { - return `Skill Set source BOMs are limited to ${MaximumDistributionSourceSkillSetComponents} components`; - } - const componentIds = new Set(); - const objectIds = new Set(); - const objectSha256s = new Set(); - for (const [expectedOrdinal, component] of bom.components.entries()) { - if (component.ordinal !== expectedOrdinal) { - return "Skill Set source BOM ordinals must be contiguous and zero-based"; - } - if (component.subject.sourceRevisionHash !== component.object.sourceRevisionHash) { - return "Every Skill Set component subject must bind its exact source revision hash"; - } - if (component.object.format !== "selftune-package-v2") { - return "Every Skill Set component object must use canonical package version 2"; - } - if (componentIds.has(component.subject.skillRevisionId)) { - return "Skill Set source BOM skill revision ids must be unique"; - } - if (objectIds.has(component.object.objectId)) { - return "Skill Set source BOM component object ids must be unique"; - } - if (objectSha256s.has(component.object.sourceObjectSha256)) { - return "Skill Set source BOM component package hashes must be unique"; - } - componentIds.add(component.subject.skillRevisionId); - objectIds.add(component.object.objectId); - objectSha256s.add(component.object.sourceObjectSha256); - } - return undefined; - }), -); -export type DistributionSourceBom = Schema.Schema.Type; - -export class CanonicalDistributionSourcePackage extends Schema.Class( - "CanonicalDistributionSourcePackage", -)( - Schema.Struct({ - subject: DistributionSubjectSchema, - object: CanonicalDistributionSourceObject, - bom: DistributionSourceBomSchema, - }).check( - Schema.makeFilter((source) => { - if (source.subject.sourceRevisionHash !== source.object.sourceRevisionHash) { - return "The root distribution subject must bind the exact source revision hash"; - } - if (source.bom._tag === "standalone") { - if (source.subject._tag !== "skill_revision") { - return "A standalone source requires a skill-revision subject"; - } - return source.object.format === "selftune-package-v2" - ? undefined - : "A standalone source root must use canonical package version 2"; - } - if ( - source.subject._tag !== "skill_set" || - source.subject.skillSetId !== source.bom.skillSetId - ) { - return "A Skill Set source requires the matching skill-set subject"; - } - if (source.object.format !== "selftune-skill-set-source-v1") { - return "A Skill Set source root must use the canonical Skill Set source manifest format"; - } - const aggregateBytes = - source.object.byteLength + - source.bom.components.reduce((total, component) => total + component.object.byteLength, 0); - if (aggregateBytes > MaximumDistributionSourceAggregateBytes) { - return `A staged source package is limited to ${MaximumDistributionSourceAggregateBytes} aggregate bytes`; - } - return source.bom.components.some( - (component) => component.object.objectId === source.object.objectId, - ) - ? "Skill Set root and component objects must be distinct" - : undefined; - }), - ), -) {} - -export class DistributionSourceOperationContext extends Schema.Class( - "DistributionSourceOperationContext", -)({ - organizationId: OrganizationIdSchema, - actorId: DistributionActorIdSchema, - requestId: DistributionSourceRequestIdSchema, - idempotencyKey: DistributionSourceIdempotencyKeySchema, -}) {} - -export class StageDistributionSourceRevision extends Schema.Class( - "StageDistributionSourceRevision", -)({ - context: DistributionSourceOperationContext, - source: CanonicalDistributionSourcePackage, -}) {} - -export class InspectDistributionSourceRevision extends Schema.Class( - "InspectDistributionSourceRevision", -)({ - context: DistributionSourceOperationContext, - sourceRevisionId: DistributionSourceRevisionIdSchema, -}) {} - -export class StandaloneDistributionSourceAttestation extends Schema.TaggedClass()( - "standalone", - { rightsClaimId: RightsClaimIdSchema }, -) {} - -export class DistributionSourceComponentAttestation extends Schema.Class( - "DistributionSourceComponentAttestation", -)({ - ordinal: NonNegativeOrdinalSchema, - rightsClaimId: RightsClaimIdSchema, -}) {} - -export class SkillSetDistributionSourceAttestation extends Schema.TaggedClass()( - "skill_set", - { - rightsClaimId: RightsClaimIdSchema, - components: Schema.NonEmptyArray(DistributionSourceComponentAttestation), - }, -) {} - -export const DistributionSourceAttestationBindingSchema = Schema.Union([ - StandaloneDistributionSourceAttestation, - SkillSetDistributionSourceAttestation, -]).check( - Schema.makeFilter((binding) => { - if (binding._tag === "standalone") return undefined; - const claimIds = new Set([binding.rightsClaimId]); - for (const [expectedOrdinal, component] of binding.components.entries()) { - if (component.ordinal !== expectedOrdinal) { - return "Skill Set attestation ordinals must be contiguous and zero-based"; - } - if (claimIds.has(component.rightsClaimId)) { - return "Root and component rights claims must be independently identified"; - } - claimIds.add(component.rightsClaimId); - } - return undefined; - }), -); -export type DistributionSourceAttestationBinding = Schema.Schema.Type< - typeof DistributionSourceAttestationBindingSchema ->; - -export const DistributionSourceAuthorizationBindingIdSchema = Schema.String.check( - Schema.isUUID(), -).pipe(Schema.brand("DistributionSourceAuthorizationBindingId")); -export type DistributionSourceAuthorizationBindingId = Schema.Schema.Type< - typeof DistributionSourceAuthorizationBindingIdSchema ->; - -export class DistributionSourceAuthorizationComponentBinding extends Schema.Class( - "DistributionSourceAuthorizationComponentBinding", -)({ - ordinal: NonNegativeOrdinalSchema, - subject: SkillRevisionDistributionSubject, - sourceObjectSha256: Sha256Schema, - rightsClaimId: RightsClaimIdSchema, -}) {} - -/** Server-issued join between an existing distribution authorization and one staged UUID. */ -export class DistributionSourceAuthorizationBinding extends Schema.Class( - "DistributionSourceAuthorizationBinding", -)( - Schema.Struct({ - id: DistributionSourceAuthorizationBindingIdSchema, - organizationId: OrganizationIdSchema, - sourceStagingRevisionId: DistributionSourceRevisionIdSchema, - distributionAuthorizationId: DistributionAuthorizationIdSchema, - subject: DistributionSubjectSchema, - sourceObjectSha256: Sha256Schema, - attestation: DistributionSourceAttestationBindingSchema, - components: Schema.Array(DistributionSourceAuthorizationComponentBinding), - issuedAt: UtcTimestampSchema, - }).check( - Schema.makeFilter((binding) => { - if (binding.subject._tag === "skill_revision") { - return binding.attestation._tag === "standalone" && binding.components.length === 0 - ? undefined - : "A standalone authorization join cannot carry Skill Set component bindings"; - } - if ( - binding.attestation._tag !== "skill_set" || - binding.components.length === 0 || - binding.components.length !== binding.attestation.components.length - ) { - return "A Skill Set authorization join requires every attested component"; - } - for (const [expectedOrdinal, component] of binding.components.entries()) { - const attestation = binding.attestation.components[expectedOrdinal]; - if ( - component.ordinal !== expectedOrdinal || - attestation?.ordinal !== expectedOrdinal || - component.rightsClaimId !== attestation.rightsClaimId - ) { - return "Authorization component bindings must match attestation ordinals and claims"; - } - } - return undefined; - }), - ), -) {} - -export class AttestDistributionSourceRevision extends Schema.Class( - "AttestDistributionSourceRevision", -)({ - context: DistributionSourceOperationContext, - sourceRevisionId: DistributionSourceRevisionIdSchema, - binding: DistributionSourceAttestationBindingSchema, -}) {} - -export class AuthorizeDistributionSourceRevision extends Schema.Class( - "AuthorizeDistributionSourceRevision", -)({ - context: DistributionSourceOperationContext, - sourceRevisionId: DistributionSourceRevisionIdSchema, - authorizationBindingId: DistributionSourceAuthorizationBindingIdSchema, -}) {} - -export class GetDistributionSourceRevision extends Schema.Class( - "GetDistributionSourceRevision", -)({ - organizationId: OrganizationIdSchema, - actorId: DistributionActorIdSchema, - requestId: DistributionSourceRequestIdSchema, - sourceRevisionId: DistributionSourceRevisionIdSchema, -}) {} - -export class DistributionSourceInspectionPending extends Schema.TaggedClass()( - "pending", - {}, -) {} - -export class DistributionSourceInspectionPathHash extends Schema.Class( - "DistributionSourceInspectionPathHash", -)({ - path: SafeRelativePathSchema, - sha256: Sha256Schema, -}) {} - -export class DistributionSourceInspectionFile extends Schema.Class( - "DistributionSourceInspectionFile", -)({ - path: SafeRelativePathSchema, - sha256: Sha256Schema, - byteLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), -}) {} - -const InspectionProvenanceFields = Schema.Struct({ - kind: Schema.Literals([ - "github_verified", - "selftune_authored", - "imported_upstream", - "self_attested_upload", - ]), - sourceRepository: Schema.NullOr(Schema.String.check(Schema.isPattern(/^https:\/\/[^\s]+$/))), - sourceRef: Schema.NullOr(Schema.NonEmptyString), - sourceTreeHash: Schema.NullOr(GitObjectHashSchema), - evidenceSha256: Sha256Schema, -}); - -/** Provenance resolved from stored source authority, never from the inspection caller. */ -export class DistributionSourceInspectionProvenance extends Schema.Class( - "DistributionSourceInspectionProvenance", -)( - InspectionProvenanceFields.check( - Schema.makeFilter((provenance) => { - if (provenance.kind === "github_verified") { - return provenance.sourceRepository !== null && - /^https:\/\/github\.com\/[^/?#]+\/[^/?#]+(?:\.git)?$/.test(provenance.sourceRepository) && - provenance.sourceRef !== null && - provenance.sourceTreeHash !== null - ? undefined - : "GitHub-verified provenance requires an exact GitHub repository, ref, and tree hash"; - } - if (provenance.kind === "imported_upstream") { - return provenance.sourceRepository !== null && - provenance.sourceRef !== null && - provenance.sourceTreeHash !== null - ? undefined - : "Imported-upstream provenance requires an exact repository, ref, and tree hash"; - } - return provenance.sourceRepository === null && - provenance.sourceRef === null && - provenance.sourceTreeHash === null - ? undefined - : "Authored and upload provenance cannot carry upstream repository fields"; - }), - ), -) {} - -export class DistributionSourceInspectionLicense extends Schema.Class( - "DistributionSourceInspectionLicense", -)( - Schema.Struct({ - normalizedExpression: Schema.NonEmptyString, - kind: LicenseKindSchema, - policyDisposition: LicensePolicyDispositionSchema, - normalizationProof: Schema.Union([ - Schema.TaggedStruct("spdx_registry_v1", { - parser: Schema.Literal("spdx-expression-parse"), - parserVersion: Schema.Literal("4.0.0"), - normalizedExpressionSha256: Sha256Schema, - proofSha256: Sha256Schema, - }), - Schema.TaggedStruct("manual_designation_v1", { - normalizedExpressionSha256: Sha256Schema, - }), - ]), - licenseFile: Schema.NullOr(DistributionSourceInspectionPathHash), - notices: Schema.Array(DistributionSourceInspectionPathHash), - licenseEvidenceSha256: Sha256Schema, - noticeEvidenceSha256: Sha256Schema, - }).check( - Schema.makeFilter((license) => { - if (license.kind === "spdx") { - return license.normalizationProof._tag === "spdx_registry_v1" - ? undefined - : "SPDX inspection requires a server-registered parser proof"; - } - return license.normalizationProof._tag === "manual_designation_v1" - ? undefined - : "Manual license designations cannot carry an SPDX parser proof"; - }), - ), -) {} - -export class DistributionSourcePackageInspectionEvidence extends Schema.Class( - "DistributionSourcePackageInspectionEvidence", -)( - Schema.Struct({ - _tag: Schema.Literal("package"), - ordinal: Schema.NullOr(NonNegativeOrdinalSchema), - subject: SkillRevisionDistributionSubject, - sourceObjectSha256: Sha256Schema, - fileManifest: Schema.NonEmptyArray(DistributionSourceInspectionFile), - fileManifestSha256: Sha256Schema, - license: DistributionSourceInspectionLicense, - provenance: DistributionSourceInspectionProvenance, - inspectionEvidenceSha256: Sha256Schema, - }).check( - Schema.makeFilter((artifact) => - hasSafeRelativePackagePathCollision(artifact.fileManifest.map((file) => file.path)) - ? "Inspection file manifests cannot contain portable path or ancestor collisions" - : undefined, - ), - ), -) {} - -export class DistributionSourceSkillSetManifestInspectionEvidence extends Schema.TaggedClass()( - "skill_set_manifest", - { - subject: SkillSetDistributionSubject, - sourceManifestSha256: Sha256Schema, - sourceBomSha256: Sha256Schema, - orderedComponentInspectionEvidenceSha256s: Schema.NonEmptyArray(Sha256Schema), - provenance: DistributionSourceInspectionProvenance, - inspectionEvidenceSha256: Sha256Schema, - }, -) {} - -export class DistributionSourceInspectionEvidence extends Schema.Class( - "DistributionSourceInspectionEvidence", -)( - Schema.Struct({ - root: Schema.Union([ - DistributionSourcePackageInspectionEvidence, - DistributionSourceSkillSetManifestInspectionEvidence, - ]), - components: Schema.Array(DistributionSourcePackageInspectionEvidence), - }).check( - Schema.makeFilter((evidence) => { - if (evidence.root._tag === "package") { - return evidence.root.ordinal === null && evidence.components.length === 0 - ? undefined - : "Standalone inspection evidence cannot carry components"; - } - if ( - evidence.components.length === 0 || - evidence.components.length !== - evidence.root.orderedComponentInspectionEvidenceSha256s.length - ) { - return "Skill Set inspection evidence requires every ordered component"; - } - for (const [ordinal, component] of evidence.components.entries()) { - if ( - component.ordinal !== ordinal || - component.inspectionEvidenceSha256 !== - evidence.root.orderedComponentInspectionEvidenceSha256s[ordinal] - ) { - return "Skill Set component evidence must be contiguous and hash-bound by the root"; - } - } - return undefined; - }), - ), -) {} - -export class DistributionSourceInspectionReady extends Schema.TaggedClass()( - "ready", - { - inspectedAt: UtcTimestampSchema, - policyVersion: Schema.NonEmptyString, - evidence: DistributionSourceInspectionEvidence, - warnings: Schema.Array(Schema.NonEmptyString), - }, -) {} - -export class DistributionSourceInspectionBlocked extends Schema.TaggedClass()( - "blocked", - { - inspectedAt: UtcTimestampSchema, - policyVersion: Schema.NonEmptyString, - issues: Schema.NonEmptyArray(Schema.NonEmptyString), - }, -) {} - -export const DistributionSourceInspectionStateSchema = Schema.Union([ - DistributionSourceInspectionPending, - DistributionSourceInspectionReady, - DistributionSourceInspectionBlocked, -]); -export type DistributionSourceInspectionState = Schema.Schema.Type< - typeof DistributionSourceInspectionStateSchema ->; - -export class DistributionSourceAttestationPending extends Schema.TaggedClass()( - "pending", - {}, -) {} - -export class DistributionSourceAttested extends Schema.TaggedClass()( - "attested", - { - binding: DistributionSourceAttestationBindingSchema, - attestedAt: UtcTimestampSchema, - }, -) {} - -export const DistributionSourceAttestationStateSchema = Schema.Union([ - DistributionSourceAttestationPending, - DistributionSourceAttested, -]); -export type DistributionSourceAttestationState = Schema.Schema.Type< - typeof DistributionSourceAttestationStateSchema ->; - -export class DistributionSourceAuthorizationPending extends Schema.TaggedClass()( - "pending", - {}, -) {} - -export class DistributionSourceAuthorized extends Schema.TaggedClass()( - "authorized", - { - authorizationBindingId: DistributionSourceAuthorizationBindingIdSchema, - authorizationId: DistributionAuthorizationIdSchema, - authorizedAt: UtcTimestampSchema, - }, -) {} - -export const DistributionSourceAuthorizationStateSchema = Schema.Union([ - DistributionSourceAuthorizationPending, - DistributionSourceAuthorized, -]); -export type DistributionSourceAuthorizationState = Schema.Schema.Type< - typeof DistributionSourceAuthorizationStateSchema ->; - -export const DistributionSourceLifecyclePhaseSchema = Schema.Literals([ - "staged", - "inspected", - "attested", - "authorized", -]); -export type DistributionSourceLifecyclePhase = Schema.Schema.Type< - typeof DistributionSourceLifecyclePhaseSchema ->; - -/** Immutable source identity plus monotonic lifecycle evidence. Content fields never change. */ -export class DistributionSourceRevision extends Schema.Class( - "DistributionSourceRevision", -)( - Schema.Struct({ - sourceRevisionId: DistributionSourceRevisionIdSchema, - organizationId: OrganizationIdSchema, - source: CanonicalDistributionSourcePackage, - phase: DistributionSourceLifecyclePhaseSchema, - inspection: DistributionSourceInspectionStateSchema, - attestation: DistributionSourceAttestationStateSchema, - authorization: DistributionSourceAuthorizationStateSchema, - stagedBy: DistributionActorIdSchema, - stagedAt: UtcTimestampSchema, - }).check( - Schema.makeFilter((revision) => { - const inspectionPending = revision.inspection._tag === "pending"; - const attestationPending = revision.attestation._tag === "pending"; - const authorizationPending = revision.authorization._tag === "pending"; - if ( - revision.phase === "staged" && - (!inspectionPending || !attestationPending || !authorizationPending) - ) { - return "A staged source revision cannot carry later lifecycle evidence"; - } - if ( - revision.phase === "inspected" && - (inspectionPending || !attestationPending || !authorizationPending) - ) { - return "An inspected source revision requires only inspection evidence"; - } - if ( - revision.phase === "attested" && - (revision.inspection._tag !== "ready" || attestationPending || !authorizationPending) - ) { - return "An attested source revision requires ready inspection and attestation evidence"; - } - if ( - revision.phase === "authorized" && - (revision.inspection._tag !== "ready" || attestationPending || authorizationPending) - ) { - return "An authorized source revision requires ready inspection, attestation, and authorization evidence"; - } - if (revision.attestation._tag === "attested") { - const binding = revision.attestation.binding; - if (revision.source.bom._tag !== binding._tag) { - return "Source BOM and attestation binding kinds must match"; - } - if ( - revision.source.bom._tag === "skill_set" && - binding._tag === "skill_set" && - (revision.source.bom.components.length !== binding.components.length || - revision.source.bom.components.some( - (component, index) => component.ordinal !== binding.components[index]?.ordinal, - )) - ) { - return "Every Skill Set BOM component requires one ordinally exact rights-claim binding"; - } - } - if ( - revision.inspection._tag !== "pending" && - Date.parse(revision.inspection.inspectedAt) < Date.parse(revision.stagedAt) - ) { - return "Inspection cannot precede staging"; - } - if ( - revision.attestation._tag === "attested" && - (revision.inspection._tag !== "ready" || - Date.parse(revision.attestation.attestedAt) < Date.parse(revision.inspection.inspectedAt)) - ) { - return "Attestation cannot precede a ready inspection"; - } - if ( - revision.authorization._tag === "authorized" && - (revision.attestation._tag !== "attested" || - Date.parse(revision.authorization.authorizedAt) < - Date.parse(revision.attestation.attestedAt)) - ) { - return "Authorization cannot precede attestation"; - } - return undefined; - }), - ), -) {} - -export class DistributionSourceRootTarget extends Schema.TaggedClass()( - "root", - {}, -) {} - -export class DistributionSourceComponentTarget extends Schema.TaggedClass()( - "component", - { ordinal: NonNegativeOrdinalSchema }, -) {} - -export const DistributionSourceObjectTargetSchema = Schema.Union([ - DistributionSourceRootTarget, - DistributionSourceComponentTarget, -]); -export type DistributionSourceObjectTarget = Schema.Schema.Type< - typeof DistributionSourceObjectTargetSchema ->; - -export class ResolveDistributionSourceObject extends Schema.Class( - "ResolveDistributionSourceObject", -)({ - organizationId: OrganizationIdSchema, - actorId: DistributionActorIdSchema, - requestId: DistributionSourceRequestIdSchema, - sourceRevisionId: DistributionSourceRevisionIdSchema, - target: DistributionSourceObjectTargetSchema, -}) {} - -/** Short-lived, organization-authorized object resolution. It is not a durable object locator. */ -export class DistributionSourceObjectResolution extends Schema.Class( - "DistributionSourceObjectResolution", -)( - Schema.Struct({ - sourceRevisionId: DistributionSourceRevisionIdSchema, - target: DistributionSourceObjectTargetSchema, - objectId: DistributionSourceObjectIdSchema, - sourceObjectSha256: Sha256Schema, - method: Schema.Literal("GET"), - url: Schema.String.check( - Schema.isPattern(/^https:\/\/[^\s]+$/, { - description: "an HTTPS object resolution URL", - }), - ), - issuedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, - }).check( - Schema.makeFilter((resolution) => { - const lifetime = Date.parse(resolution.expiresAt) - Date.parse(resolution.issuedAt); - return lifetime > 0 && lifetime <= 15 * 60 * 1_000 - ? undefined - : "Object resolution must expire within fifteen minutes of issuance"; - }), - ), -) {} - -export const DistributionSourceOperationSchema = Schema.Literals([ - "stage", - "inspect", - "attest", - "authorize", -]); -export type DistributionSourceOperation = Schema.Schema.Type< - typeof DistributionSourceOperationSchema ->; - -export class DistributionSourceRevisionNotFound extends Schema.TaggedErrorClass()( - "DistributionSourceRevisionNotFound", - { - sourceRevisionId: DistributionSourceRevisionIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 404 }, -) {} - -export class DistributionSourceObjectNotFound extends Schema.TaggedErrorClass()( - "DistributionSourceObjectNotFound", - { - objectId: DistributionSourceObjectIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 404 }, -) {} - -export class DistributionSourceObjectHashMismatch extends Schema.TaggedErrorClass()( - "DistributionSourceObjectHashMismatch", - { - objectId: DistributionSourceObjectIdSchema, - expectedSha256: Sha256Schema, - actualSha256: Sha256Schema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceObjectSizeMismatch extends Schema.TaggedErrorClass()( - "DistributionSourceObjectSizeMismatch", - { - objectId: DistributionSourceObjectIdSchema, - expectedBytes: PositiveByteLengthSchema, - actualBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceObjectTooLarge extends Schema.TaggedErrorClass()( - "DistributionSourceObjectTooLarge", - { - objectId: DistributionSourceObjectIdSchema, - actualBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - limitBytes: Schema.Int.check(Schema.isGreaterThan(0)), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 413 }, -) {} - -export class DistributionSourcePackageInvalid extends Schema.TaggedErrorClass()( - "DistributionSourcePackageInvalid", - { - objectId: DistributionSourceObjectIdSchema, - issues: Schema.NonEmptyArray(Schema.NonEmptyString), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class DistributionSourceIdempotencyConflict extends Schema.TaggedErrorClass()( - "DistributionSourceIdempotencyConflict", - { - operation: DistributionSourceOperationSchema, - requestId: DistributionSourceRequestIdSchema, - idempotencyKey: DistributionSourceIdempotencyKeySchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceLifecycleConflict extends Schema.TaggedErrorClass()( - "DistributionSourceLifecycleConflict", - { - sourceRevisionId: DistributionSourceRevisionIdSchema, - operation: DistributionSourceOperationSchema, - currentPhase: DistributionSourceLifecyclePhaseSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceInspectionRejected extends Schema.TaggedErrorClass()( - "DistributionSourceInspectionRejected", - { - sourceRevisionId: DistributionSourceRevisionIdSchema, - issues: Schema.NonEmptyArray(Schema.NonEmptyString), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class DistributionSourceRightsClaimNotBound extends Schema.TaggedErrorClass()( - "DistributionSourceRightsClaimNotBound", - { - sourceRevisionId: DistributionSourceRevisionIdSchema, - rightsClaimId: RightsClaimIdSchema, - componentOrdinal: Schema.NullOr(NonNegativeOrdinalSchema), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceActorNotAuthorized extends Schema.TaggedErrorClass()( - "DistributionSourceActorNotAuthorized", - { - organizationId: OrganizationIdSchema, - actorId: DistributionActorIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 403 }, -) {} - -export class DistributionSourceAuthorizationNotBound extends Schema.TaggedErrorClass()( - "DistributionSourceAuthorizationNotBound", - { - sourceRevisionId: DistributionSourceRevisionIdSchema, - authorizationBindingId: DistributionSourceAuthorizationBindingIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceResolutionWindowInvalid extends Schema.TaggedErrorClass()( - "DistributionSourceResolutionWindowInvalid", - { - issuedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 500 }, -) {} - -export class DistributionSourceComponentNotFound extends Schema.TaggedErrorClass()( - "DistributionSourceComponentNotFound", - { - sourceRevisionId: DistributionSourceRevisionIdSchema, - ordinal: NonNegativeOrdinalSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 404 }, -) {} - -export const DistributionSourceFailureSchema = Schema.Union([ - DistributionSourceRevisionNotFound, - DistributionSourceObjectNotFound, - DistributionSourceObjectHashMismatch, - DistributionSourceObjectSizeMismatch, - DistributionSourceObjectTooLarge, - DistributionSourcePackageInvalid, - DistributionSourceIdempotencyConflict, - DistributionSourceLifecycleConflict, - DistributionSourceInspectionRejected, - DistributionSourceRightsClaimNotBound, - DistributionSourceAuthorizationNotBound, - DistributionSourceResolutionWindowInvalid, - DistributionSourceActorNotAuthorized, - DistributionSourceComponentNotFound, -]); -export type DistributionSourceFailure = Schema.Schema.Type; - -export const decodeStageDistributionSourceRevision = (input: unknown) => - Schema.decodeUnknownEffect(StageDistributionSourceRevision)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeDistributionSourceRevision = (input: unknown) => - Schema.decodeUnknownEffect(DistributionSourceRevision)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeInspectDistributionSourceRevision = (input: unknown) => - Schema.decodeUnknownEffect(InspectDistributionSourceRevision)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeAttestDistributionSourceRevision = (input: unknown) => - Schema.decodeUnknownEffect(AttestDistributionSourceRevision)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeAuthorizeDistributionSourceRevision = (input: unknown) => - Schema.decodeUnknownEffect(AuthorizeDistributionSourceRevision)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeResolveDistributionSourceObject = (input: unknown) => - Schema.decodeUnknownEffect(ResolveDistributionSourceObject)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeGetDistributionSourceRevision = (input: unknown) => - Schema.decodeUnknownEffect(GetDistributionSourceRevision)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeDistributionSourceObjectResolution = (input: unknown) => - Schema.decodeUnknownEffect(DistributionSourceObjectResolution)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeDistributionSourceFailure = (input: unknown) => - Schema.decodeUnknownEffect(DistributionSourceFailureSchema)( - input, - DistributionSecurityBoundaryParseOptions, - ); diff --git a/packages/api-contract/src/distribution.ts b/packages/api-contract/src/distribution.ts deleted file mode 100644 index 7fd1547c..00000000 --- a/packages/api-contract/src/distribution.ts +++ /dev/null @@ -1,1137 +0,0 @@ -/* eslint-disable max-lines -- the distribution boundary stays in one auditable contract module */ -import { Effect, Schema, SchemaAST } from "effect"; -import parseSpdxExpression from "spdx-expression-parse"; - -/** A canonical, lowercase, unprefixed SHA-256 digest. */ -export const Sha256Schema = Schema.String.check( - Schema.isPattern(/^[0-9a-f]{64}$/, { - identifier: "Sha256", - description: "a 64-character lowercase hexadecimal SHA-256 digest", - }), -).pipe(Schema.brand("Sha256")); -export type Sha256 = Schema.Schema.Type; - -/** A canonical Git object id for SHA-1 or SHA-256 repositories. */ -export const GitObjectHashSchema = Schema.String.check( - Schema.isPattern(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, { - identifier: "GitObjectHash", - description: "a lowercase hexadecimal Git SHA-1 or SHA-256 object id", - }), -).pipe(Schema.brand("GitObjectHash")); -export type GitObjectHash = Schema.Schema.Type; - -// oxlint-disable-next-line no-control-regex -- portable package paths reject controls. -const WINDOWS_FORBIDDEN_PACKAGE_PATH_CHARACTER = /[<>:"|?*\u0000-\u001f\u007f]/; -const WINDOWS_RESERVED_PACKAGE_PATH_SEGMENT = - /^(?:con|prn|aux|nul|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\..*)?$/i; -const NON_PORTABLE_ASCII_PACKAGE_PATH_CHARACTER = /[^\x20-\x7e]/; - -/** Exact canonical PortablePackagePath predicate shared by API and Cloud boundaries. */ -export function isSafeRelativePackagePath(path: string): boolean { - if ( - path.length === 0 || - path.includes("\\") || - path.startsWith("/") || - /^[A-Za-z]:/.test(path) || - NON_PORTABLE_ASCII_PACKAGE_PATH_CHARACTER.test(path) - ) { - return false; - } - return path - .split("/") - .every( - (segment) => - segment.length > 0 && - segment !== "." && - segment !== ".." && - !WINDOWS_FORBIDDEN_PACKAGE_PATH_CHARACTER.test(segment) && - !WINDOWS_RESERVED_PACKAGE_PATH_SEGMENT.test(segment) && - !/[ .]$/.test(segment), - ); -} - -/** True when paths collide on portable case-insensitive filesystems or as file/ancestor pairs. */ -export function hasSafeRelativePackagePathCollision(paths: ReadonlyArray): boolean { - const identities = new Set(); - for (const path of paths) { - const identity = path.toLowerCase(); - if (identities.has(identity)) return true; - identities.add(identity); - } - for (const identity of identities) { - let separator = identity.indexOf("/"); - while (separator !== -1) { - if (identities.has(identity.slice(0, separator))) return true; - separator = identity.indexOf("/", separator + 1); - } - } - return false; -} - -/** Portable package paths exactly match the canonical PortablePackagePath schema. */ -export const SafeRelativePathSchema = Schema.String.check( - Schema.makeFilter( - isSafeRelativePackagePath, - { message: "Expected a canonical portable package path" }, - true, - ), -).pipe(Schema.brand("SafeRelativePath")); -export type SafeRelativePath = Schema.Schema.Type; - -/** A canonical UTC instant with millisecond precision. */ -export const UtcTimestampSchema = Schema.String.check( - Schema.makeFilter((value) => { - const epochMillis = Date.parse(value); - return Number.isFinite(epochMillis) && new Date(epochMillis).toISOString() === value - ? undefined - : "Expected a canonical ISO-8601 UTC timestamp with millisecond precision"; - }), -).pipe(Schema.brand("UtcTimestamp")); -export type UtcTimestamp = Schema.Schema.Type; - -export const SkillRevisionIdSchema = Schema.NonEmptyString.pipe(Schema.brand("SkillRevisionId")); -export type SkillRevisionId = Schema.Schema.Type; - -export const SkillSetIdSchema = Schema.NonEmptyString.pipe(Schema.brand("SkillSetId")); -export type SkillSetId = Schema.Schema.Type; - -export const DistributionIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionId"), -); -export type DistributionId = Schema.Schema.Type; - -export const RightsClaimIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RightsClaimId"), -); -export type RightsClaimId = Schema.Schema.Type; - -export const DistributionAuthorizationIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionAuthorizationId"), -); -export type DistributionAuthorizationId = Schema.Schema.Type< - typeof DistributionAuthorizationIdSchema ->; - -export const DistributionAuthorizationRequestIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionAuthorizationRequestId"), -); -export type DistributionAuthorizationRequestId = Schema.Schema.Type< - typeof DistributionAuthorizationRequestIdSchema ->; - -export const OrganizationIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("OrganizationId"), -); -export type OrganizationId = Schema.Schema.Type; - -export const DistributionActorIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionActorId"), -); -export type DistributionActorId = Schema.Schema.Type; - -export class SkillRevisionDistributionSubject extends Schema.TaggedClass()( - "skill_revision", - { - skillRevisionId: SkillRevisionIdSchema, - sourceRevisionHash: Sha256Schema, - }, -) {} - -export class SkillSetDistributionSubject extends Schema.TaggedClass()( - "skill_set", - { - skillSetId: SkillSetIdSchema, - sourceRevisionHash: Sha256Schema, - }, -) {} - -export const DistributionSubjectSchema = Schema.Union([ - SkillRevisionDistributionSubject, - SkillSetDistributionSubject, -]); -export type DistributionSubject = Schema.Schema.Type; - -function distributionSubjectKey(subject: DistributionSubject): string { - return subject._tag === "skill_revision" - ? `${subject._tag}:${subject.skillRevisionId}:${subject.sourceRevisionHash}` - : `${subject._tag}:${subject.skillSetId}:${subject.sourceRevisionHash}`; -} - -/** Every policy channel in the Distribution Profile channel matrix. */ -export const DistributionChannelSchema = Schema.Literals([ - "local_authoring", - "same_org_private_backup", - "workspace_discovery_install", - "recipient_scoped_private_share", - "registry_org_bundle", - "registry_unlisted", - "registry_public", - "portable_skill_set_export", -]); -export type DistributionChannel = Schema.Schema.Type; - -/** Channels that cross an organization or installation boundary and require authorization. */ -export const AuthorizedDistributionChannelSchema = Schema.Literals([ - "workspace_discovery_install", - "recipient_scoped_private_share", - "registry_org_bundle", - "registry_unlisted", - "registry_public", - "portable_skill_set_export", -]); -export type AuthorizedDistributionChannel = Schema.Schema.Type< - typeof AuthorizedDistributionChannelSchema ->; - -export const LicenseKindSchema = Schema.Literals(["spdx", "license_ref", "proprietary"]); -export type LicenseKind = Schema.Schema.Type; - -/** Policy outcome for valid license evidence; validity and automated approval are independent. */ -export const LicensePolicyDispositionSchema = Schema.Literals([ - "automated_approved", - "manual_review_required", - "manually_approved", -]); -export type LicensePolicyDisposition = Schema.Schema.Type; - -/** Standalone identifiers eligible for automated readiness; expressions are never inferred. */ -export const AutomatedSpdxLicenseIdentifierSchema = Schema.Literals([ - "0BSD", - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "CC0-1.0", - "ISC", - "MIT", - "Unlicense", -]); -export type AutomatedSpdxLicenseIdentifier = Schema.Schema.Type< - typeof AutomatedSpdxLicenseIdentifierSchema ->; - -const AutomatedSpdxLicenseIdentifiers = new Set([ - "0BSD", - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "CC0-1.0", - "ISC", - "MIT", - "Unlicense", -]); - -function hasUniqueValues(values: ReadonlyArray): boolean { - return new Set(values).size === values.length; -} - -function isAutomatedSpdxExpression(expression: string): boolean { - return AutomatedSpdxLicenseIdentifiers.has(expression); -} - -function hasCanonicalSpdxOperatorSyntax(expression: string): boolean { - const operators = expression.match(/\b(?:and|or|with)\b/gi); - return ( - operators === null || - operators.every((operator) => operator === "AND" || operator === "OR" || operator === "WITH") - ); -} - -function isOfficialSpdxExpression(expression: string): boolean { - if (!hasCanonicalSpdxOperatorSyntax(expression)) return false; - try { - parseSpdxExpression(expression); - return !expression.includes("LicenseRef-") && !expression.includes("DocumentRef-"); - } catch { - return false; - } -} - -function isOfficialLicenseRefExpression(expression: string): boolean { - if (!/^LicenseRef-[A-Za-z0-9.-]+$/.test(expression)) return false; - try { - parseSpdxExpression(expression); - return true; - } catch { - return false; - } -} - -const LicenseEvidenceFields = Schema.Struct({ - sourceRevisionHash: Sha256Schema, - expression: Schema.NonEmptyString, - kind: LicenseKindSchema, - policyDisposition: LicensePolicyDispositionSchema, - filePath: Schema.NullOr(SafeRelativePathSchema), - fileSha256: Schema.NullOr(Sha256Schema), - noticePaths: Schema.Array(SafeRelativePathSchema), -}); - -/** License evidence inspected from the immutable source revision. */ -export class LicenseEvidence extends Schema.Class("LicenseEvidence")( - LicenseEvidenceFields.check( - Schema.makeFilter((evidence) => { - if ((evidence.filePath === null) !== (evidence.fileSha256 === null)) { - return "License file path and hash must either both be present or both be absent"; - } - if (!hasUniqueValues(evidence.noticePaths)) { - return "License notice paths must be unique"; - } - if (evidence.kind === "license_ref") { - if (!isOfficialLicenseRefExpression(evidence.expression)) { - return "LicenseRef evidence requires a valid LicenseRef-* expression"; - } - if (evidence.filePath === null) { - return "LicenseRef evidence requires a bundled terms file and verified hash"; - } - return evidence.policyDisposition === "automated_approved" - ? "LicenseRef evidence cannot receive automated policy approval" - : undefined; - } - if (evidence.kind === "proprietary") { - if (evidence.expression !== "Proprietary") { - return 'Proprietary evidence must use the canonical "Proprietary" designation'; - } - if (evidence.filePath === null) { - return "Proprietary evidence requires a bundled terms file and verified hash"; - } - return evidence.policyDisposition === "automated_approved" - ? "Proprietary evidence cannot receive automated policy approval" - : undefined; - } - if (!isOfficialSpdxExpression(evidence.expression)) { - return "SPDX evidence requires a valid expression using official identifiers and exceptions"; - } - if ( - evidence.policyDisposition === "automated_approved" && - !isAutomatedSpdxExpression(evidence.expression) - ) { - return "Automated approval is limited to one standalone identifier in the automated policy allowlist"; - } - return undefined; - }), - ), -) {} - -export class OrganizationRightsHolder extends Schema.TaggedClass()( - "organization", - { organizationId: OrganizationIdSchema }, -) {} - -export const RightsHolderUserIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RightsHolderUserId"), -); -export type RightsHolderUserId = Schema.Schema.Type; - -export class UserRightsHolder extends Schema.TaggedClass()("user", { - userId: RightsHolderUserIdSchema, -}) {} - -export class ExternalRightsHolder extends Schema.TaggedClass()("external", { - name: Schema.NonEmptyString, -}) {} - -export const RightsHolderSchema = Schema.Union([ - OrganizationRightsHolder, - UserRightsHolder, - ExternalRightsHolder, -]); -export type RightsHolder = Schema.Schema.Type; - -export class DistributionRightsScopes extends Schema.Class( - "DistributionRightsScopes", -)({ - redistribute: Schema.Boolean, - modify: Schema.Boolean, - enableContributorSignals: Schema.Boolean, -}) {} - -function sameLicenseEvidence(left: LicenseEvidence, right: LicenseEvidence): boolean { - return ( - left.sourceRevisionHash === right.sourceRevisionHash && - left.expression === right.expression && - left.kind === right.kind && - left.policyDisposition === right.policyDisposition && - left.filePath === right.filePath && - left.fileSha256 === right.fileSha256 && - left.noticePaths.length === right.noticePaths.length && - left.noticePaths.every((path, index) => path === right.noticePaths[index]) - ); -} - -export class StandaloneLicenseRightsEvidence extends Schema.TaggedClass()( - "standalone_license", - { licenseEvidence: LicenseEvidence }, -) {} - -export class SkillSetCompilationRightsEvidence extends Schema.TaggedClass()( - "skill_set_compilation", - { - sourceRevisionHash: Sha256Schema, - sourceManifestSha256: Sha256Schema, - sourceBomSha256: Sha256Schema, - orderedComponentInspectionEvidenceSha256s: Schema.NonEmptyArray(Sha256Schema), - inspectionEvidenceSha256: Sha256Schema, - }, -) {} - -export const RightsClaimEvidenceSchema = Schema.Union([ - StandaloneLicenseRightsEvidence, - SkillSetCompilationRightsEvidence, -]); -export type RightsClaimEvidence = Schema.Schema.Type; - -export function rightsClaimLicenseEvidence(claim: { - readonly evidence: RightsClaimEvidence; -}): LicenseEvidence | null { - return claim.evidence._tag === "standalone_license" ? claim.evidence.licenseEvidence : null; -} - -const RightsClaimFields = Schema.Struct({ - id: RightsClaimIdSchema, - organizationId: OrganizationIdSchema, - subject: DistributionSubjectSchema, - evidence: RightsClaimEvidenceSchema, - rightsHolder: RightsHolderSchema, - provenanceKind: Schema.Literals([ - "github_verified", - "selftune_authored", - "imported_upstream", - "self_attested_upload", - ]), - sourceRepository: Schema.NullOr( - Schema.String.check( - Schema.isPattern(/^https:\/\/[^\s]+$/, { - description: "an HTTPS source repository URL", - }), - ), - ), - sourceRef: Schema.NullOr(Schema.NonEmptyString), - sourceTreeHash: Schema.NullOr(GitObjectHashSchema), - scopes: DistributionRightsScopes, - attestedChannels: Schema.Array(DistributionChannelSchema), - attestedBy: DistributionActorIdSchema, - attestedAt: UtcTimestampSchema, - attestationTermsVersion: Schema.NonEmptyString, - verificationState: Schema.Literals([ - "self_attested", - "source_verified", - "manually_verified", - "rejected", - ]), - verifiedBy: Schema.NullOr(DistributionActorIdSchema), - verifiedAt: Schema.NullOr(UtcTimestampSchema), - reviewEvidence: Schema.NullOr(Schema.NonEmptyString), - reviewPolicyVersion: Schema.NullOr(Schema.NonEmptyString), - supersedesClaimId: Schema.NullOr(RightsClaimIdSchema), - createdAt: UtcTimestampSchema, -}); - -/** Immutable, version-specific assertion of the rights needed for distribution. */ -export class RightsClaim extends Schema.Class("RightsClaim")( - RightsClaimFields.check( - Schema.makeFilter((claim) => { - if (claim.evidence._tag === "standalone_license") { - if (claim.subject._tag !== "skill_revision") { - return "Standalone license rights evidence requires a skill revision subject"; - } - if ( - claim.subject.sourceRevisionHash !== claim.evidence.licenseEvidence.sourceRevisionHash - ) { - return "Rights claim and license evidence must bind the same source revision hash"; - } - } else { - if (claim.subject._tag !== "skill_set") { - return "Skill Set compilation rights evidence requires a Skill Set subject"; - } - if (claim.subject.sourceRevisionHash !== claim.evidence.sourceRevisionHash) { - return "Rights claim and compilation evidence must bind the same source revision hash"; - } - } - if (!hasUniqueValues(claim.attestedChannels)) { - return "Attested distribution channels must be unique"; - } - if (Date.parse(claim.attestedAt) > Date.parse(claim.createdAt)) { - return "Rights attestation cannot occur after claim creation"; - } - const hasCompleteReview = - claim.verifiedBy !== null && - claim.verifiedAt !== null && - claim.reviewEvidence !== null && - claim.reviewPolicyVersion !== null; - const hasAnyReview = - claim.verifiedBy !== null || - claim.verifiedAt !== null || - claim.reviewEvidence !== null || - claim.reviewPolicyVersion !== null; - const requiresReview = - claim.verificationState === "manually_verified" || claim.verificationState === "rejected"; - if (requiresReview && !hasCompleteReview) { - return "Manual and rejected rights decisions require complete review provenance"; - } - if (!requiresReview && hasAnyReview) { - return "Self-attested and source-verified claims cannot carry manual review provenance"; - } - if (claim.verifiedAt !== null && Date.parse(claim.verifiedAt) > Date.parse(claim.createdAt)) { - return "Rights verification cannot occur after claim creation"; - } - if (claim.provenanceKind === "github_verified") { - return claim.sourceRepository !== null && - /^https:\/\/github\.com\/[^/?#]+\/[^/?#]+(?:\.git)?$/.test(claim.sourceRepository) && - claim.sourceRef !== null && - claim.sourceTreeHash !== null - ? undefined - : "GitHub-verified provenance requires a GitHub HTTPS repository, ref, and tree hash"; - } - if (claim.provenanceKind === "imported_upstream") { - return claim.sourceRepository !== null && - claim.sourceRef !== null && - claim.sourceTreeHash !== null - ? undefined - : "Imported-upstream provenance requires a repository, ref, and tree hash"; - } - return undefined; - }), - ), -) {} - -/** No contributor-signal routing decision has been made. This is the default. */ -export class TelemetryUnconfigured extends Schema.TaggedClass()( - "unconfigured", - {}, -) {} - -/** Contributor signals were explicitly configured off for this distribution profile. */ -export class TelemetryDisabled extends Schema.TaggedClass()("disabled", { - configuredBy: DistributionActorIdSchema, - configuredAt: UtcTimestampSchema, -}) {} - -/** Privacy-safe logical categories. Raw prompt, transcript, file, and code fields are absent. */ -export const ContributorSignalFieldSchema = Schema.Literals(["trigger", "grade", "miss_category"]); -export type ContributorSignalField = Schema.Schema.Type; - -/** Public contributor relay/capability format; independent from canonical push telemetry v2. */ -export const ContributorSignalCapabilityVersionSchema = Schema.Literal(1); -export type ContributorSignalCapabilityVersion = Schema.Schema.Type< - typeof ContributorSignalCapabilityVersionSchema ->; - -/** Deterministic logical-category to relay-v1 wire-field mapping. */ -export class ContributorSignalWireMapping extends Schema.Class( - "ContributorSignalWireMapping", -)({ - trigger: Schema.Tuple([ - Schema.Literal("triggered"), - Schema.Literal("invocation_type"), - Schema.Literal("miss_detected"), - ]), - grade: Schema.Tuple([Schema.Literal("execution_grade")]), - miss_category: Schema.Tuple([Schema.Literal("query_bucket")]), -}) {} - -export const DefaultContributorSignalWireMapping = ContributorSignalWireMapping.make({ - trigger: ["triggered", "invocation_type", "miss_detected"], - grade: ["execution_grade"], - miss_category: ["query_bucket"], -}); - -export class ContributorSignalCapability extends Schema.Class( - "ContributorSignalCapability", -)( - Schema.Struct({ - version: ContributorSignalCapabilityVersionSchema, - allowedSignals: Schema.NonEmptyArray(ContributorSignalFieldSchema), - wireFields: ContributorSignalWireMapping, - }).check( - Schema.makeFilter((capability) => - hasUniqueValues(capability.allowedSignals) - ? undefined - : "Allowed contributor signal categories must be unique", - ), - ), -) {} - -const TelemetryEnabledFields = Schema.Struct({ - recipientOrganizationId: OrganizationIdSchema, - capability: ContributorSignalCapability, - configuredBy: DistributionActorIdSchema, - configuredAt: UtcTimestampSchema, -}); - -/** Creator telemetry is version-scoped and routes only to the named organization. */ -export class TelemetryEnabled extends Schema.TaggedClass()( - "enabled", - TelemetryEnabledFields, -) {} - -export const TelemetryEntitlementSchema = Schema.Union([ - TelemetryUnconfigured, - TelemetryDisabled, - TelemetryEnabled, -]); -export type TelemetryEntitlement = Schema.Schema.Type; - -export const DefaultTelemetryEntitlement = TelemetryUnconfigured.make(); - -const TelemetryEntitlementWithDefault = TelemetryEntitlementSchema.pipe( - Schema.withDecodingDefaultTypeKey(Effect.succeed(DefaultTelemetryEntitlement)), - Schema.withConstructorDefault(Effect.succeed(DefaultTelemetryEntitlement)), -); - -export const DistributionBlockerCodeSchema = Schema.Literals([ - "DistributionSubjectNotFound", - "DistributionContentChanged", - "DistributionSourceObjectChanged", - "DistributionPackageTooLarge", - "DistributionAuthorizationExpired", - "DistributionMaterializationConflict", - "AgentSkillsValidationFailed", - "MissingLicense", - "InvalidLicenseExpression", - "MissingLicenseFile", - "LicenseFileHashMismatch", - "RightsUnverified", - "DistributionScopeNotAttested", - "ManualLicenseReviewRequired", - "TelemetryOwnerMismatch", - "TelemetryNotAuthorized", - "SkillSetComponentBlocked", -]); -export type DistributionBlockerCode = Schema.Schema.Type; - -export class DistributionBlocker extends Schema.Class("DistributionBlocker")({ - code: DistributionBlockerCodeSchema, - message: Schema.NonEmptyString, -}) {} - -export const DistributionRecipientUserIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("DistributionRecipientUserId"), -); -export type DistributionRecipientUserId = Schema.Schema.Type< - typeof DistributionRecipientUserIdSchema ->; - -export class UserDistributionRecipient extends Schema.TaggedClass()( - "user", - { userId: DistributionRecipientUserIdSchema }, -) {} - -export class OrganizationDistributionRecipient extends Schema.TaggedClass()( - "organization", - { organizationId: OrganizationIdSchema }, -) {} - -export class EmailHashDistributionRecipient extends Schema.TaggedClass()( - "email_hash", - { emailSha256: Sha256Schema }, -) {} - -/** A private share bound to possession of one high-entropy, single-claim secret. */ -export class BearerClaimDistributionRecipient extends Schema.TaggedClass()( - "bearer_claim", - { claimSecretSha256: Sha256Schema }, -) {} - -export const DistributionRecipientSchema = Schema.Union([ - UserDistributionRecipient, - OrganizationDistributionRecipient, - EmailHashDistributionRecipient, - BearerClaimDistributionRecipient, -]); -export type DistributionRecipient = Schema.Schema.Type; - -export class LocalAuthoringIntent extends Schema.TaggedClass()( - "local_authoring", - {}, -) {} - -export class SameOrgPrivateBackupIntent extends Schema.TaggedClass()( - "same_org_private_backup", - { organizationId: OrganizationIdSchema }, -) {} - -export class WorkspaceDiscoveryInstallIntent extends Schema.TaggedClass()( - "workspace_discovery_install", - { organizationId: OrganizationIdSchema }, -) {} - -export class RecipientScopedPrivateShareIntent extends Schema.TaggedClass()( - "recipient_scoped_private_share", - { recipient: DistributionRecipientSchema }, -) {} - -export class RegistryOrgBundleIntent extends Schema.TaggedClass()( - "registry_org_bundle", - { organizationId: OrganizationIdSchema }, -) {} - -export class RegistryUnlistedIntent extends Schema.TaggedClass()( - "registry_unlisted", - {}, -) {} - -export class RegistryPublicIntent extends Schema.TaggedClass()( - "registry_public", - {}, -) {} - -export class PortableSkillSetExportIntent extends Schema.TaggedClass()( - "portable_skill_set_export", - {}, -) {} - -export const DistributionIntentSchema = Schema.Union([ - LocalAuthoringIntent, - SameOrgPrivateBackupIntent, - WorkspaceDiscoveryInstallIntent, - RecipientScopedPrivateShareIntent, - RegistryOrgBundleIntent, - RegistryUnlistedIntent, - RegistryPublicIntent, - PortableSkillSetExportIntent, -]); -export type DistributionIntent = Schema.Schema.Type; - -export const AuthorizedDistributionIntentSchema = Schema.Union([ - WorkspaceDiscoveryInstallIntent, - RecipientScopedPrivateShareIntent, - RegistryOrgBundleIntent, - RegistryUnlistedIntent, - RegistryPublicIntent, - PortableSkillSetExportIntent, -]); -export type AuthorizedDistributionIntent = Schema.Schema.Type< - typeof AuthorizedDistributionIntentSchema ->; - -const DistributionDecisionFields = Schema.Struct({ - subject: DistributionSubjectSchema, - channel: DistributionChannelSchema, - status: Schema.Literals(["ready", "not_ready_for_distribution", "manual_review_required"]), - licenseEvidence: Schema.NullOr(LicenseEvidence), - rightsClaim: Schema.NullOr(RightsClaim), - telemetryEntitlement: TelemetryEntitlementWithDefault, - blockers: Schema.Array(DistributionBlocker), - warnings: Schema.Array(Schema.NonEmptyString), - policyVersion: Schema.NonEmptyString, - assessedAt: UtcTimestampSchema, -}); - -/** Read-only policy assessment. It cannot authorize a mutation by itself. */ -export class DistributionDecision extends Schema.Class( - "DistributionDecision", -)( - DistributionDecisionFields.check( - Schema.makeFilter((decision) => { - const claimLicense = - decision.rightsClaim === null ? null : rightsClaimLicenseEvidence(decision.rightsClaim); - if (!hasUniqueValues(decision.blockers.map((blocker) => blocker.code))) { - return "Distribution blocker codes must be unique"; - } - if ( - decision.licenseEvidence !== null && - decision.licenseEvidence.sourceRevisionHash !== decision.subject.sourceRevisionHash - ) { - return "Decision subject and license evidence must bind the same source revision"; - } - if ( - decision.rightsClaim !== null && - distributionSubjectKey(decision.rightsClaim.subject) !== - distributionSubjectKey(decision.subject) - ) { - return "Decision subject and rights claim subject must match"; - } - if ( - decision.licenseEvidence !== null && - claimLicense !== null && - !sameLicenseEvidence(decision.licenseEvidence, claimLicense) - ) { - return "Decision and rights claim license evidence must match"; - } - if ( - decision.rightsClaim?.evidence._tag === "skill_set_compilation" && - decision.licenseEvidence !== null - ) { - return "Skill Set compilation decisions cannot fabricate root license evidence"; - } - if ( - decision.rightsClaim !== null && - Date.parse(decision.rightsClaim.createdAt) > Date.parse(decision.assessedAt) - ) { - return "Rights claim creation must not occur after decision assessment"; - } - if ( - decision.telemetryEntitlement._tag !== "unconfigured" && - Date.parse(decision.telemetryEntitlement.configuredAt) > Date.parse(decision.assessedAt) - ) { - return "Telemetry configuration must not occur after decision assessment"; - } - if (decision.status !== "ready") { - return decision.blockers.length > 0 - ? undefined - : "A non-ready distribution decision must include at least one blocker"; - } - if (decision.blockers.length > 0) { - return "A ready distribution decision cannot contain blockers"; - } - const requiresDistributionRights = - decision.channel !== "local_authoring" && decision.channel !== "same_org_private_backup"; - if (!requiresDistributionRights) { - return decision.telemetryEntitlement._tag === "enabled" - ? "Local authoring and private backup cannot issue contributor telemetry" - : undefined; - } - if (decision.rightsClaim === null) { - return "A ready distribution decision requires a rights claim"; - } - if ( - decision.rightsClaim.evidence._tag === "standalone_license" && - decision.licenseEvidence === null - ) { - return "A ready standalone decision requires license evidence"; - } - if (decision.licenseEvidence?.policyDisposition === "manual_review_required") { - return "License evidence requiring manual review cannot produce a ready decision"; - } - if ( - decision.licenseEvidence?.policyDisposition === "manually_approved" && - decision.rightsClaim.verificationState !== "manually_verified" - ) { - return "Manually approved license evidence requires a manually verified rights claim"; - } - if (decision.rightsClaim.verificationState === "rejected") { - return "A rejected rights claim cannot produce a ready decision"; - } - if (!decision.rightsClaim.scopes.redistribute) { - return "A ready distribution decision requires redistribution rights"; - } - if (!decision.rightsClaim.attestedChannels.includes(decision.channel)) { - return "The rights claim must attest the requested distribution channel"; - } - if ( - decision.telemetryEntitlement._tag === "enabled" && - !decision.rightsClaim.scopes.enableContributorSignals - ) { - return "Enabled contributor signals require an attested telemetry scope"; - } - if (decision.telemetryEntitlement._tag === "enabled" && !decision.rightsClaim.scopes.modify) { - return "Enabled contributor signals require modification rights for generated package artifacts"; - } - return undefined; - }), - ), -) {} - -export class DistributionTransform extends Schema.Class( - "DistributionTransform", -)({ - name: Schema.NonEmptyString, - version: Schema.NonEmptyString, - includesFeedbackArtifacts: Schema.Boolean, -}) {} - -const DistributionAuthorizationFields = Schema.Struct({ - id: DistributionAuthorizationIdSchema, - distributionId: DistributionIdSchema, - authorizationRequestId: DistributionAuthorizationRequestIdSchema, - organizationId: OrganizationIdSchema, - subject: DistributionSubjectSchema, - sourceRevisionHash: Sha256Schema, - sourceObjectSha256: Sha256Schema, - bindingSha256: Sha256Schema, - channel: AuthorizedDistributionChannelSchema, - intent: AuthorizedDistributionIntentSchema, - rightsClaimId: RightsClaimIdSchema, - decision: DistributionDecision, - transform: DistributionTransform, - packagedSha256: Sha256Schema, - authorizedBy: DistributionActorIdSchema, - authorizedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, -}); - -/** Auditable authority to materialize one exact sealed package. */ -export class DistributionAuthorization extends Schema.Class( - "DistributionAuthorization", -)( - DistributionAuthorizationFields.check( - Schema.makeFilter((authorization) => { - if (authorization.decision.status !== "ready") { - return "Only a ready distribution decision can be authorized"; - } - if ( - distributionSubjectKey(authorization.subject) !== - distributionSubjectKey(authorization.decision.subject) - ) { - return "Authorization and decision subjects must match"; - } - if (authorization.sourceRevisionHash !== authorization.subject.sourceRevisionHash) { - return "Authorization source revision hash must match its subject"; - } - if (authorization.decision.rightsClaim?.organizationId !== authorization.organizationId) { - return "Authorization organization must match the rights-claim organization"; - } - if (authorization.channel !== authorization.decision.channel) { - return "Authorization and decision channels must match"; - } - if (authorization.intent._tag !== authorization.channel) { - return "Distribution intent must match the authorized channel"; - } - if (authorization.decision.rightsClaim?.id !== authorization.rightsClaimId) { - return "Authorization and decision rights claim ids must match"; - } - if (Date.parse(authorization.decision.assessedAt) > Date.parse(authorization.authorizedAt)) { - return "Decision assessment must not occur after authorization"; - } - const telemetryEnabled = authorization.decision.telemetryEntitlement._tag === "enabled"; - if (authorization.transform.includesFeedbackArtifacts !== telemetryEnabled) { - return "Feedback artifacts must be included exactly when contributor telemetry is enabled"; - } - if ( - authorization.transform.includesFeedbackArtifacts && - authorization.decision.rightsClaim?.scopes.modify !== true - ) { - return "A package-changing feedback transform requires modification rights"; - } - return Date.parse(authorization.expiresAt) > Date.parse(authorization.authorizedAt) - ? undefined - : "Authorization expiry must be later than its authorization time"; - }), - ), -) {} - -const NonNegativeIntSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); - -export class DistributionSubjectNotFound extends Schema.TaggedErrorClass()( - "DistributionSubjectNotFound", - { - subject: DistributionSubjectSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 404 }, -) {} - -export class DistributionContentChanged extends Schema.TaggedErrorClass()( - "DistributionContentChanged", - { - subject: DistributionSubjectSchema, - expectedSourceRevisionHash: Sha256Schema, - actualSourceRevisionHash: Sha256Schema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionSourceObjectChanged extends Schema.TaggedErrorClass()( - "DistributionSourceObjectChanged", - { - subject: DistributionSubjectSchema, - expectedSourceObjectSha256: Sha256Schema, - actualSourceObjectSha256: Sha256Schema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class DistributionPackageTooLarge extends Schema.TaggedErrorClass()( - "DistributionPackageTooLarge", - { - subject: DistributionSubjectSchema, - dimension: Schema.Literals([ - "encoded_package_bytes", - "file_count", - "decoded_file_bytes", - "decoded_package_bytes", - ]), - actual: NonNegativeIntSchema, - limit: NonNegativeIntSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 413 }, -) {} - -export class DistributionAuthorizationExpired extends Schema.TaggedErrorClass()( - "DistributionAuthorizationExpired", - { - authorizationId: DistributionAuthorizationIdSchema, - expiredAt: UtcTimestampSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 410 }, -) {} - -export class DistributionMaterializationConflict extends Schema.TaggedErrorClass()( - "DistributionMaterializationConflict", - { - authorizationId: DistributionAuthorizationIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class AgentSkillsValidationFailed extends Schema.TaggedErrorClass()( - "AgentSkillsValidationFailed", - { - subject: DistributionSubjectSchema, - issues: Schema.NonEmptyArray(Schema.NonEmptyString), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class MissingLicense extends Schema.TaggedErrorClass()( - "MissingLicense", - { - subject: DistributionSubjectSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class InvalidLicenseExpression extends Schema.TaggedErrorClass()( - "InvalidLicenseExpression", - { - subject: DistributionSubjectSchema, - expression: Schema.String, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class MissingLicenseFile extends Schema.TaggedErrorClass()( - "MissingLicenseFile", - { - subject: DistributionSubjectSchema, - filePath: SafeRelativePathSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class LicenseFileHashMismatch extends Schema.TaggedErrorClass()( - "LicenseFileHashMismatch", - { - subject: DistributionSubjectSchema, - filePath: SafeRelativePathSchema, - expectedHash: Sha256Schema, - actualHash: Sha256Schema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class RightsUnverified extends Schema.TaggedErrorClass()( - "RightsUnverified", - { - subject: DistributionSubjectSchema, - rightsClaimId: Schema.NullOr(RightsClaimIdSchema), - message: Schema.NonEmptyString, - }, - { httpApiStatus: 403 }, -) {} - -export class DistributionScopeNotAttested extends Schema.TaggedErrorClass()( - "DistributionScopeNotAttested", - { - rightsClaimId: RightsClaimIdSchema, - channel: DistributionChannelSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 403 }, -) {} - -export class ManualLicenseReviewRequired extends Schema.TaggedErrorClass()( - "ManualLicenseReviewRequired", - { - subject: DistributionSubjectSchema, - reason: Schema.NonEmptyString, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class TelemetryOwnerMismatch extends Schema.TaggedErrorClass()( - "TelemetryOwnerMismatch", - { - subject: DistributionSubjectSchema, - claimedRecipientOrganizationId: OrganizationIdSchema, - authorizedRecipientOrganizationId: OrganizationIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 409 }, -) {} - -export class TelemetryNotAuthorized extends Schema.TaggedErrorClass()( - "TelemetryNotAuthorized", - { - subject: DistributionSubjectSchema, - channel: DistributionChannelSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 403 }, -) {} - -export class SkillSetComponentBlocked extends Schema.TaggedErrorClass()( - "SkillSetComponentBlocked", - { - skillSetId: SkillSetIdSchema, - component: SkillRevisionDistributionSubject, - blocker: DistributionBlocker, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 424 }, -) {} - -export const DistributionFailureSchema = Schema.Union([ - DistributionSubjectNotFound, - DistributionContentChanged, - DistributionSourceObjectChanged, - DistributionPackageTooLarge, - DistributionAuthorizationExpired, - DistributionMaterializationConflict, - AgentSkillsValidationFailed, - MissingLicense, - InvalidLicenseExpression, - MissingLicenseFile, - LicenseFileHashMismatch, - RightsUnverified, - DistributionScopeNotAttested, - ManualLicenseReviewRequired, - TelemetryOwnerMismatch, - TelemetryNotAuthorized, - SkillSetComponentBlocked, -]); -export type DistributionFailure = Schema.Schema.Type; - -/** Security-sensitive boundary decoding rejects fields that are not in the signed contract. */ -export const DistributionSecurityBoundaryParseOptions: SchemaAST.ParseOptions = { - onExcessProperty: "error", - errors: "all", -}; - -export const decodeLicenseEvidence = (input: unknown) => - Schema.decodeUnknownEffect(LicenseEvidence)(input, DistributionSecurityBoundaryParseOptions); - -export const decodeRightsClaim = (input: unknown) => - Schema.decodeUnknownEffect(RightsClaim)(input, DistributionSecurityBoundaryParseOptions); - -export const decodeTelemetryEntitlement = (input: unknown) => - Schema.decodeUnknownEffect(TelemetryEntitlementSchema)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeDistributionDecision = (input: unknown) => - Schema.decodeUnknownEffect(DistributionDecision)(input, DistributionSecurityBoundaryParseOptions); - -export const decodeDistributionAuthorization = (input: unknown) => - Schema.decodeUnknownEffect(DistributionAuthorization)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeDistributionFailure = (input: unknown) => - Schema.decodeUnknownEffect(DistributionFailureSchema)( - input, - DistributionSecurityBoundaryParseOptions, - ); diff --git a/packages/api-contract/src/github.ts b/packages/api-contract/src/github.ts deleted file mode 100644 index 84e35a70..00000000 --- a/packages/api-contract/src/github.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Schema } from "effect"; - -const PositiveInt = Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))); -const NullableString = Schema.NullOr(Schema.String); - -export class CloudGithubInstallation extends Schema.Class( - "CloudGithubInstallation", -)({ - id: Schema.String, - installationId: PositiveInt, - accountLogin: Schema.String, - accountType: Schema.Literals(["User", "Organization"]), - suspended: Schema.Boolean, - updatedAt: Schema.String, -}) {} - -export class CloudGithubConnection extends Schema.Class( - "CloudGithubConnection", -)({ - id: Schema.String, - entryName: Schema.String, - installationId: PositiveInt, - repository: Schema.String, - branch: Schema.String, - skillPath: Schema.String, - autoPublish: Schema.Boolean, - writeBackEnabled: Schema.Boolean, - lastSyncStatus: NullableString, - lastSyncAt: NullableString, -}) {} - -export class CloudGithubStatus extends Schema.Class("CloudGithubStatus")({ - installations: Schema.Array(CloudGithubInstallation), - connections: Schema.Array(CloudGithubConnection), - canManageConnections: Schema.Boolean, -}) {} - -export class CloudGithubInstallSession extends Schema.Class( - "CloudGithubInstallSession", -)({ - url: Schema.NonEmptyString, -}) {} - -export class CloudGithubConnectionSync extends Schema.Class( - "CloudGithubConnectionSync", -)({ - connectionId: Schema.String, - status: Schema.String, - version: Schema.String, - sourceRef: Schema.String, - publishedAt: Schema.String, - message: Schema.String, -}) {} - -export const CloudGithubApiPaths = { - status: "/api/v1/cloud/github", - startInstall: "/api/v1/cloud/github/install/start", - syncConnection: "/api/v1/cloud/github/connections/:connectionId/sync", -} as const; diff --git a/packages/api-contract/src/install-credentials.ts b/packages/api-contract/src/install-credentials.ts deleted file mode 100644 index ed52e133..00000000 --- a/packages/api-contract/src/install-credentials.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as Schema from "effect/Schema"; - -import { DistributionIdSchema, Sha256Schema, UtcTimestampSchema } from "./distribution"; -import { - RecipientDesktopBootstrapTokenSchema, - RecipientInstallLifecycleReportingDisclosureSchema, -} from "./recipient-actions"; - -function strictStruct(fields: Fields) { - const allowed = new Set(Object.keys(fields)); - return Schema.Record(Schema.String, Schema.Unknown) - .check( - Schema.makeFilter( - (value) => - Object.keys(value).every((key) => allowed.has(key)) - ? undefined - : "Unexpected desktop-install-finalization property", - { identifier: "StrictDesktopInstallFinalizeStruct" }, - true, - ), - ) - .pipe(Schema.decodeTo(Schema.Struct(fields))); -} - -/** - * Submitted only after the local receipt transaction commits. The server sees - * digest-only evidence, never paths, machine identity, email, or file details. - */ -export const DesktopInstallFinalizeRequestSchema = strictStruct({ - bootstrapToken: RecipientDesktopBootstrapTokenSchema, - distributionId: DistributionIdSchema, - sealedPackageSha256: Sha256Schema, - pseudonymousInstallKey: Sha256Schema, - receiptEvidenceSha256: Sha256Schema, - lifecycleReporting: RecipientInstallLifecycleReportingDisclosureSchema, -}); -export type DesktopInstallFinalizeRequest = Schema.Schema.Type< - typeof DesktopInstallFinalizeRequestSchema ->; - -export const DesktopInstallFinalizeResponseSchema = strictStruct({ - finalizationId: Schema.String.check(Schema.isUUID()), - status: Schema.Literal("finalized"), - lifecycleReporting: RecipientInstallLifecycleReportingDisclosureSchema, - finalizedAt: UtcTimestampSchema, -}); -export type DesktopInstallFinalizeResponse = Schema.Schema.Type< - typeof DesktopInstallFinalizeResponseSchema ->; diff --git a/packages/api-contract/src/recipient-actions.ts b/packages/api-contract/src/recipient-actions.ts deleted file mode 100644 index c2a55f3a..00000000 --- a/packages/api-contract/src/recipient-actions.ts +++ /dev/null @@ -1,624 +0,0 @@ -import * as Schema from "effect/Schema"; - -import { - DistributionIdSchema, - OrganizationIdSchema, - Sha256Schema, - UtcTimestampSchema, -} from "./distribution"; -import { ShareInvitationIdSchema } from "./share-invitations"; - -function strictStruct(fields: Fields) { - const allowed = new Set(Object.keys(fields)); - const encodedRecord = Schema.Record(Schema.String, Schema.Unknown).check( - Schema.makeFilter( - (value) => - Object.keys(value).every((key) => allowed.has(key)) - ? undefined - : "Unexpected recipient-action property", - { identifier: "StrictRecipientActionStruct" }, - true, - ), - ); - return encodedRecord.pipe(Schema.decodeTo(Schema.Struct(fields))); -} - -export const RecipientActionRequestIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientActionRequestId"), -); -export type RecipientActionRequestId = Schema.Schema.Type; - -export const RecipientShareIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientShareId"), -); -export type RecipientShareId = Schema.Schema.Type; - -export const RecipientSealedObjectIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientSealedObjectId"), -); -export type RecipientSealedObjectId = Schema.Schema.Type; - -export const RecipientDownloadAuthorizationIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientDownloadAuthorizationId"), -); -export type RecipientDownloadAuthorizationId = Schema.Schema.Type< - typeof RecipientDownloadAuthorizationIdSchema ->; - -export const RecipientUseOnceIssueIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientUseOnceIssueId"), -); -export type RecipientUseOnceIssueId = Schema.Schema.Type; - -const opaqueToken = (identifier: string) => - Schema.String.check( - Schema.isPattern(/^[A-Za-z0-9_-]{43}$/, { - identifier, - description: "a 32-byte base64url opaque token without padding", - }), - ); - -/** A use-once handoff is a distinct authority from an invitation claim token. */ -export const RecipientUseOnceHandoffTokenSchema = opaqueToken("RecipientUseOnceHandoffToken").pipe( - Schema.brand("RecipientUseOnceHandoffToken"), -); -export type RecipientUseOnceHandoffToken = Schema.Schema.Type< - typeof RecipientUseOnceHandoffTokenSchema ->; - -/** The only value permitted in a Desktop deep link. */ -export const RecipientDesktopBootstrapTokenSchema = opaqueToken( - "RecipientDesktopBootstrapToken", -).pipe(Schema.brand("RecipientDesktopBootstrapToken")); -export type RecipientDesktopBootstrapToken = Schema.Schema.Type< - typeof RecipientDesktopBootstrapTokenSchema ->; - -export const RecipientSupportedAgentSchema = Schema.Literals([ - "codex", - "claude_code", - "opencode", - "openclaw", - "pi", -]); -export type RecipientSupportedAgent = Schema.Schema.Type; - -export const RecipientAccessModeSchema = Schema.Literals(["authenticated", "accountless"]); -export type RecipientAccessMode = Schema.Schema.Type; - -export const RecipientLifecycleReportingConsentSchema = Schema.Literals(["not_granted", "granted"]); -export type RecipientLifecycleReportingConsent = Schema.Schema.Type< - typeof RecipientLifecycleReportingConsentSchema ->; - -export const RecipientActionKindSchema = Schema.Literals([ - "portable_download", - "use_once_issue", - "use_once_preview", - "use_once_content", - "use_once_consume", - "desktop_bootstrap", - "desktop_preview", -]); -export type RecipientActionKind = Schema.Schema.Type; - -const BindingFields = { - invitationId: ShareInvitationIdSchema, - shareId: RecipientShareIdSchema, - distributionId: DistributionIdSchema, - sealedObjectId: RecipientSealedObjectIdSchema, - packagedSha256: Sha256Schema, -} as const; - -const TermsRequestFields = { - termsDisclosureSha256: Sha256Schema, - termsAcceptance: Schema.Literal("accepted"), -} as const; - -export const RecipientContributorSignalFieldSchema = Schema.Literals([ - "trigger", - "grade", - "miss_category", -]); -export type RecipientContributorSignalField = Schema.Schema.Type< - typeof RecipientContributorSignalFieldSchema ->; - -const NonEmptyContributorSignalFieldsSchema = Schema.Array( - RecipientContributorSignalFieldSchema, -).check( - Schema.isNonEmpty({ message: "At least one disclosed contributor signal field is required" }), -); - -/** Contributor telemetry remains disabled unless this exact disclosure is separately accepted. */ -export const RecipientContributorSignalDisclosureSchema = Schema.Union([ - strictStruct({ - _tag: Schema.Literal("signals_unavailable"), - signalDisclosureSha256: Sha256Schema, - signalRecipientOrganizationId: Schema.Null, - allowedFields: Schema.Tuple([]), - capability: Schema.Literal("not_capable"), - defaultState: Schema.Literal("off"), - contributorConsent: Schema.Literal("not_applicable"), - enabled: Schema.Literal(false), - }), - strictStruct({ - _tag: Schema.Literal("capable_default_off"), - signalDisclosureSha256: Sha256Schema, - signalRecipientOrganizationId: OrganizationIdSchema, - allowedFields: NonEmptyContributorSignalFieldsSchema, - capability: Schema.Literal("capable"), - defaultState: Schema.Literal("off"), - contributorConsent: Schema.Literal("not_granted"), - enabled: Schema.Literal(false), - }), - strictStruct({ - _tag: Schema.Literal("capable_consented"), - signalDisclosureSha256: Sha256Schema, - signalRecipientOrganizationId: OrganizationIdSchema, - allowedFields: NonEmptyContributorSignalFieldsSchema, - capability: Schema.Literal("capable"), - defaultState: Schema.Literal("off"), - contributorConsent: Schema.Literal("granted"), - enabled: Schema.Literal(true), - }), -]); -export type RecipientContributorSignalDisclosure = Schema.Schema.Type< - typeof RecipientContributorSignalDisclosureSchema ->; - -/** Sender-visible download status is an independent consent from contributor telemetry. */ -export const RecipientDownloadLifecycleReportingDisclosureSchema = Schema.Union([ - strictStruct({ - _tag: Schema.Literal("downloaded_status"), - lifecycleDisclosureSha256: Sha256Schema, - consent: Schema.Literal("not_granted"), - senderVisibleDownloadedStatus: Schema.Literal("disabled"), - }), - strictStruct({ - _tag: Schema.Literal("downloaded_status"), - lifecycleDisclosureSha256: Sha256Schema, - consent: Schema.Literal("granted"), - senderVisibleDownloadedStatus: Schema.Literal("enabled"), - }), -]); -export type RecipientDownloadLifecycleReportingDisclosure = Schema.Schema.Type< - typeof RecipientDownloadLifecycleReportingDisclosureSchema ->; - -/** Sender-visible use-once status has its own disclosure and consent. */ -export const RecipientUseOnceLifecycleReportingDisclosureSchema = Schema.Union([ - strictStruct({ - _tag: Schema.Literal("used_once_status"), - lifecycleDisclosureSha256: Sha256Schema, - consent: Schema.Literal("not_granted"), - senderVisibleUsedOnceStatus: Schema.Literal("disabled"), - }), - strictStruct({ - _tag: Schema.Literal("used_once_status"), - lifecycleDisclosureSha256: Sha256Schema, - consent: Schema.Literal("granted"), - senderVisibleUsedOnceStatus: Schema.Literal("enabled"), - }), -]); -export type RecipientUseOnceLifecycleReportingDisclosure = Schema.Schema.Type< - typeof RecipientUseOnceLifecycleReportingDisclosureSchema ->; - -/** Sender-visible installed status is independent from contributor telemetry. */ -export const RecipientInstallLifecycleReportingDisclosureSchema = Schema.Union([ - strictStruct({ - _tag: Schema.Literal("installed_status"), - lifecycleDisclosureSha256: Sha256Schema, - consent: Schema.Literal("not_granted"), - senderVisibleInstalledStatus: Schema.Literal("disabled"), - }), - strictStruct({ - _tag: Schema.Literal("installed_status"), - lifecycleDisclosureSha256: Sha256Schema, - consent: Schema.Literal("granted"), - senderVisibleInstalledStatus: Schema.Literal("enabled"), - }), -]); -export type RecipientInstallLifecycleReportingDisclosure = Schema.Schema.Type< - typeof RecipientInstallLifecycleReportingDisclosureSchema ->; - -const ContributorSignalFields = { - contributorSignals: RecipientContributorSignalDisclosureSchema, -} as const; - -const DownloadConsentFields = { - ...ContributorSignalFields, - lifecycleReporting: RecipientDownloadLifecycleReportingDisclosureSchema, -} as const; - -const UseOnceConsentFields = { - ...ContributorSignalFields, - lifecycleReporting: RecipientUseOnceLifecycleReportingDisclosureSchema, -} as const; - -/** Authorizes a portable download only; it does not choose a filesystem target or install. */ -export const RecipientPortableDownloadRequestSchema = strictStruct({ - requestId: RecipientActionRequestIdSchema, - ...BindingFields, - ...TermsRequestFields, - ...DownloadConsentFields, - recipientAccess: RecipientAccessModeSchema, -}); -export type RecipientPortableDownloadRequest = Schema.Schema.Type< - typeof RecipientPortableDownloadRequestSchema ->; - -const PortableDownloadResponseFields = { - requestId: RecipientActionRequestIdSchema, - ...BindingFields, - ...TermsRequestFields, - ...DownloadConsentFields, - status: Schema.Literal("authorized"), - downloadAuthorizationId: RecipientDownloadAuthorizationIdSchema, - packageFormat: Schema.Literal("selftune-portable-package-v2"), - localInstall: Schema.Literal("not_requested"), - authorizedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, -} as const; -export const RecipientPortableDownloadResponseSchema = Schema.Union([ - strictStruct({ - ...PortableDownloadResponseFields, - recipientAccess: Schema.Literal("authenticated"), - accountlessPolicyResult: Schema.Literal("authenticated_account"), - }), - strictStruct({ - ...PortableDownloadResponseFields, - recipientAccess: Schema.Literal("accountless"), - accountlessPolicyResult: Schema.Literal("public_allowed"), - }), -]); -export type RecipientPortableDownloadResponse = Schema.Schema.Type< - typeof RecipientPortableDownloadResponseSchema ->; - -export const RecipientUseOnceIssueRequestSchema = strictStruct({ - requestId: RecipientActionRequestIdSchema, - ...BindingFields, - ...TermsRequestFields, - ...UseOnceConsentFields, - supportedAgent: RecipientSupportedAgentSchema, - executionConsent: Schema.Literal("granted"), - recipientAccess: RecipientAccessModeSchema, -}); -export type RecipientUseOnceIssueRequest = Schema.Schema.Type< - typeof RecipientUseOnceIssueRequestSchema ->; - -const UseOnceIssueResponseFields = { - requestId: RecipientActionRequestIdSchema, - ...BindingFields, - ...TermsRequestFields, - ...UseOnceConsentFields, - supportedAgent: RecipientSupportedAgentSchema, - executionConsent: Schema.Literal("granted"), - status: Schema.Literal("issued"), - issueId: RecipientUseOnceIssueIdSchema, - handoffToken: RecipientUseOnceHandoffTokenSchema, - issuedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, - persistence: Schema.Literal("ephemeral_use_once"), - persistentInstall: Schema.Literal("not_authorized"), - trustedTelemetry: Schema.Literal("not_authorized"), -} as const; -export const RecipientUseOnceIssueResponseSchema = Schema.Union([ - strictStruct({ - ...UseOnceIssueResponseFields, - recipientAccess: Schema.Literal("authenticated"), - accountlessPolicyResult: Schema.Literal("authenticated_account"), - }), - strictStruct({ - ...UseOnceIssueResponseFields, - recipientAccess: Schema.Literal("accountless"), - accountlessPolicyResult: Schema.Literal("public_allowed"), - }), -]); -export type RecipientUseOnceIssueResponse = Schema.Schema.Type< - typeof RecipientUseOnceIssueResponseSchema ->; - -/** The helper receives only the opaque one-use handoff and an explicit agent selection. */ -export const RecipientUseOncePreviewRequestSchema = strictStruct({ - handoffToken: RecipientUseOnceHandoffTokenSchema, - supportedAgent: RecipientSupportedAgentSchema, -}); -export type RecipientUseOncePreviewRequest = Schema.Schema.Type< - typeof RecipientUseOncePreviewRequestSchema ->; - -/** Closed request validator for the raw exact-byte endpoint outside HttpApi. */ -export const RecipientUseOnceContentRequestSchema = strictStruct({ - handoffToken: RecipientUseOnceHandoffTokenSchema, - issueId: RecipientUseOnceIssueIdSchema, - supportedAgent: RecipientSupportedAgentSchema, -}); -export type RecipientUseOnceContentRequest = Schema.Schema.Type< - typeof RecipientUseOnceContentRequestSchema ->; - -const RecipientUseOncePublisherSchema = strictStruct({ name: Schema.NonEmptyString }); -const RecipientUseOnceRightsHolderSchema = strictStruct({ - kind: Schema.Literals(["organization", "user", "external"]), - name: Schema.NonEmptyString, -}); -const RecipientUseOnceLicenseSchema = strictStruct({ - expression: Schema.NonEmptyString, - kind: Schema.Literals(["spdx", "license_ref", "proprietary"]), - licenseEvidenceSha256: Sha256Schema, - bundledTerms: Schema.NullOr(strictStruct({ path: Schema.NonEmptyString, sha256: Sha256Schema })), -}); -const RecipientUseOnceProvenanceSchema = strictStruct({ - kind: Schema.Literals([ - "github_verified", - "selftune_authored", - "imported_upstream", - "self_attested_upload", - ]), - sourceRepository: Schema.NullOr(Schema.String), - sourceRef: Schema.NullOr(Schema.String), - sourceTreeHash: Schema.NullOr(Schema.String), -}); - -export const RecipientUseOnceHelperContributorSignalsSchema = Schema.Union([ - strictStruct({ - _tag: Schema.Literal("unavailable"), - signalDisclosureSha256: Sha256Schema, - allowedFields: Schema.Tuple([]), - defaultState: Schema.Literal("off"), - trustedTelemetry: Schema.Literal("not_authorized"), - }), - strictStruct({ - _tag: Schema.Literal("portable_unverified"), - signalDisclosureSha256: Sha256Schema, - allowedFields: NonEmptyContributorSignalFieldsSchema, - defaultState: Schema.Literal("off"), - trustedTelemetry: Schema.Literal("not_authorized"), - }), -]); -export type RecipientUseOnceHelperContributorSignals = Schema.Schema.Type< - typeof RecipientUseOnceHelperContributorSignalsSchema ->; - -export const RecipientUseOncePreviewResponseSchema = strictStruct({ - status: Schema.Literal("preview"), - issueId: RecipientUseOnceIssueIdSchema, - ...BindingFields, - supportedAgent: RecipientSupportedAgentSchema, - issuedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, - package: strictStruct({ - displayName: Schema.NonEmptyString, - version: Schema.NonEmptyString, - format: Schema.Literal("selftune-portable-package-v2"), - }), - terms: strictStruct({ - disclosureSha256: Sha256Schema, - summary: Schema.NonEmptyString, - issueAcceptance: Schema.Literal("accepted_at_issue"), - }), - publisher: RecipientUseOncePublisherSchema, - rightsHolder: RecipientUseOnceRightsHolderSchema, - license: RecipientUseOnceLicenseSchema, - provenance: RecipientUseOnceProvenanceSchema, - contributorSignals: RecipientContributorSignalDisclosureSchema, - lifecycleReporting: RecipientUseOnceLifecycleReportingDisclosureSchema, - helperContributorSignals: RecipientUseOnceHelperContributorSignalsSchema, - persistence: Schema.Literal("ephemeral_use_once"), - persistentInstall: Schema.Literal("not_authorized"), - trustedTelemetry: Schema.Literal("not_authorized"), - contentRetrieval: Schema.Literal("repeatable_exact_object_before_consume"), - previewMutation: Schema.Literal("none"), - usedOnceReporting: Schema.Literal("not_emitted"), - consumeRequired: Schema.Literal(true), - authorityLimits: strictStruct({ - 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 type RecipientUseOncePreviewResponse = Schema.Schema.Type< - typeof RecipientUseOncePreviewResponseSchema ->; - -export const RecipientUseOnceConsumeRequestSchema = strictStruct({ - requestId: RecipientActionRequestIdSchema, - handoffToken: RecipientUseOnceHandoffTokenSchema, - expectedIssueId: RecipientUseOnceIssueIdSchema, - expectedInvitationId: ShareInvitationIdSchema, - expectedShareId: RecipientShareIdSchema, - expectedDistributionId: DistributionIdSchema, - expectedSealedObjectId: RecipientSealedObjectIdSchema, - expectedPackagedSha256: Sha256Schema, - supportedAgent: RecipientSupportedAgentSchema, - ...TermsRequestFields, - ...UseOnceConsentFields, - executionConsent: Schema.Literal("granted"), -}); -export type RecipientUseOnceConsumeRequest = Schema.Schema.Type< - typeof RecipientUseOnceConsumeRequestSchema ->; - -const UseOnceConsumeResponseFields = { - requestId: RecipientActionRequestIdSchema, - issueId: RecipientUseOnceIssueIdSchema, - supportedAgent: RecipientSupportedAgentSchema, - ...BindingFields, - ...TermsRequestFields, - ...UseOnceConsentFields, - executionConsent: Schema.Literal("granted"), - status: Schema.Literal("consumed"), - consumedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, - persistence: Schema.Literal("ephemeral_use_once"), - persistentInstall: Schema.Literal("not_authorized"), - trustedTelemetry: Schema.Literal("not_authorized"), -} as const; -export const RecipientUseOnceConsumeResponseSchema = Schema.Union([ - strictStruct({ - ...UseOnceConsumeResponseFields, - recipientAccess: Schema.Literal("authenticated"), - accountlessPolicyResult: Schema.Literal("authenticated_account"), - }), - strictStruct({ - ...UseOnceConsumeResponseFields, - recipientAccess: Schema.Literal("accountless"), - accountlessPolicyResult: Schema.Literal("public_allowed"), - }), -]); -export type RecipientUseOnceConsumeResponse = Schema.Schema.Type< - typeof RecipientUseOnceConsumeResponseSchema ->; - -export const RecipientDesktopInstallBootstrapRequestSchema = strictStruct({ - requestId: RecipientActionRequestIdSchema, - ...BindingFields, - ...TermsRequestFields, - ...ContributorSignalFields, -}); -export type RecipientDesktopInstallBootstrapRequest = Schema.Schema.Type< - typeof RecipientDesktopInstallBootstrapRequestSchema ->; - -export const RecipientDesktopInstallBootstrapResponseSchema = strictStruct({ - requestId: RecipientActionRequestIdSchema, - ...BindingFields, - ...TermsRequestFields, - ...ContributorSignalFields, - status: Schema.Literal("issued"), - bootstrapToken: RecipientDesktopBootstrapTokenSchema, - issuedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, -}); -export type RecipientDesktopInstallBootstrapResponse = Schema.Schema.Type< - typeof RecipientDesktopInstallBootstrapResponseSchema ->; - -export const RecipientDesktopDeepLinkHandoffSchema = strictStruct({ - bootstrapToken: RecipientDesktopBootstrapTokenSchema, -}); -export type RecipientDesktopDeepLinkHandoff = Schema.Schema.Type< - typeof RecipientDesktopDeepLinkHandoffSchema ->; - -export const RecipientDesktopInstallPreviewRequestSchema = RecipientDesktopDeepLinkHandoffSchema; -export type RecipientDesktopInstallPreviewRequest = Schema.Schema.Type< - typeof RecipientDesktopInstallPreviewRequestSchema ->; - -export const RecipientDesktopInstallPreviewResponseSchema = strictStruct({ - ...BindingFields, - ...TermsRequestFields, - ...ContributorSignalFields, - installLifecycleReporting: Schema.optional(RecipientInstallLifecycleReportingDisclosureSchema), - status: Schema.Literal("preview"), - expiresAt: UtcTimestampSchema, - supportedTargetAgents: Schema.Array(RecipientSupportedAgentSchema).check( - Schema.isNonEmpty({ message: "At least one supported target agent is required" }), - ), - targetAgentSelectionRequired: Schema.Literal(true), - scopeChoices: Schema.Tuple([Schema.Literal("project"), Schema.Literal("global")]), - scopeSelectionRequired: Schema.Literal(true), - installModeDefault: Schema.Literal("copy"), - conflictPolicyChoices: Schema.Tuple([ - Schema.Literal("prompt"), - Schema.Literal("replace"), - Schema.Literal("keep_both"), - ]), - conflictPolicyDefault: Schema.Literal("prompt"), - customPathPolicy: Schema.Literal("unsupported_v1"), - automaticDesktopInstall: Schema.Literal("not_authorized"), - automaticSkillInstall: Schema.Literal("not_authorized"), -}); -export type RecipientDesktopInstallPreviewResponse = Schema.Schema.Type< - typeof RecipientDesktopInstallPreviewResponseSchema ->; - -const ErrorMessage = Schema.NonEmptyString; - -export class RecipientActionInvalid extends Schema.TaggedErrorClass()( - "RecipientActionInvalid", - { action: RecipientActionKindSchema, message: ErrorMessage }, - { httpApiStatus: 400 }, -) {} - -export class RecipientAccountRequired extends Schema.TaggedErrorClass()( - "RecipientAccountRequired", - { - action: Schema.Literals(["portable_download", "use_once_issue"]), - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - policyResult: Schema.Literal("account_required"), - message: ErrorMessage, - }, - { httpApiStatus: 401 }, -) {} - -export class RecipientActionForbidden extends Schema.TaggedErrorClass()( - "RecipientActionForbidden", - { action: RecipientActionKindSchema, message: ErrorMessage }, - { httpApiStatus: 403 }, -) {} - -export class RecipientActionExpired extends Schema.TaggedErrorClass()( - "RecipientActionExpired", - { - action: RecipientActionKindSchema, - expiredAt: UtcTimestampSchema, - message: ErrorMessage, - }, - { httpApiStatus: 410 }, -) {} - -export class RecipientActionReplay extends Schema.TaggedErrorClass()( - "RecipientActionReplay", - { action: RecipientActionKindSchema, message: ErrorMessage }, - { httpApiStatus: 409 }, -) {} - -export class RecipientActionConflict extends Schema.TaggedErrorClass()( - "RecipientActionConflict", - { - action: RecipientActionKindSchema, - distributionId: DistributionIdSchema, - message: ErrorMessage, - }, - { httpApiStatus: 409 }, -) {} - -export const RecipientActionFailureSchema = Schema.Union([ - RecipientActionInvalid, - RecipientAccountRequired, - RecipientActionForbidden, - RecipientActionExpired, - RecipientActionReplay, - RecipientActionConflict, -]); -export type RecipientActionFailure = Schema.Schema.Type; - -export const decodeRecipientPortableDownloadRequest = Schema.decodeUnknownEffect( - RecipientPortableDownloadRequestSchema, -); -export const decodeRecipientUseOnceIssueRequest = Schema.decodeUnknownEffect( - RecipientUseOnceIssueRequestSchema, -); -export const decodeRecipientUseOnceConsumeRequest = Schema.decodeUnknownEffect( - RecipientUseOnceConsumeRequestSchema, -); -export const decodeRecipientDesktopInstallBootstrapRequest = Schema.decodeUnknownEffect( - RecipientDesktopInstallBootstrapRequestSchema, -); -export const decodeRecipientDesktopInstallPreviewRequest = Schema.decodeUnknownEffect( - RecipientDesktopInstallPreviewRequestSchema, -); -export const decodeRecipientActionFailure = Schema.decodeUnknownEffect( - RecipientActionFailureSchema, -); diff --git a/packages/api-contract/src/share-grants.ts b/packages/api-contract/src/share-grants.ts deleted file mode 100644 index 9ed59a9f..00000000 --- a/packages/api-contract/src/share-grants.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Schema } from "effect"; - -import { Sha256Schema, UtcTimestampSchema } from "./distribution"; -import { - NormalizedRecipientEmailSchema, - ShareInvitationClaimTokenSchema, -} from "./share-invitations"; - -export const ShareGrantIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("ShareGrantId"), -); -export type ShareGrantId = Schema.Schema.Type; - -export const ShareGrantModeSchema = Schema.Literals(["reusable_unlisted", "private_single_claim"]); -export type ShareGrantMode = Schema.Schema.Type; - -export const ShareGrantDeliverySchema = Schema.Union([ - Schema.Struct({ _tag: Schema.Literal("copy_link") }), - Schema.Struct({ - _tag: Schema.Literal("email"), - recipientEmail: NormalizedRecipientEmailSchema, - }), -]); -export type ShareGrantDelivery = Schema.Schema.Type; - -const ShareGrantIssueBase = { - skillId: Schema.NonEmptyString, - sourceRevisionHash: Sha256Schema, - expiresAt: UtcTimestampSchema, -}; - -export const ShareGrantIssueRequest = Schema.Union([ - Schema.Struct({ - ...ShareGrantIssueBase, - mode: ShareGrantModeSchema, - delivery: Schema.Struct({ _tag: Schema.Literal("copy_link") }), - }), - Schema.Struct({ - ...ShareGrantIssueBase, - mode: Schema.Literal("private_single_claim"), - delivery: Schema.Struct({ - _tag: Schema.Literal("email"), - recipientEmail: NormalizedRecipientEmailSchema, - }), - }), -]); -export type ShareGrantIssueRequest = Schema.Schema.Type; - -export const ShareGrantSenderStatusSchema = Schema.Literals([ - "active", - "delivered", - "claimed", - "imported", - "expired", - "revoked", -]); - -export class ShareGrantIssueResult extends Schema.Class( - "ShareGrantIssueResult", -)({ - shareId: ShareGrantIdSchema, - mode: ShareGrantModeSchema, - claimToken: ShareInvitationClaimTokenSchema, - status: ShareGrantSenderStatusSchema, - expiresAt: UtcTimestampSchema, -}) {} - -export class ShareGrantSenderView extends Schema.Class( - "ShareGrantSenderView", -)({ - shareId: ShareGrantIdSchema, - skillId: Schema.NonEmptyString, - sourceRevisionHash: Sha256Schema, - mode: ShareGrantModeSchema, - delivery: Schema.Literals(["copy_link", "email"]), - status: ShareGrantSenderStatusSchema, - expiresAt: UtcTimestampSchema, -}) {} - -export class ShareGrantSenderInventory extends Schema.Class( - "ShareGrantSenderInventory", -)({ - shares: Schema.Array(ShareGrantSenderView), -}) {} - -export class ShareGrantRevokeResult extends Schema.Class( - "ShareGrantRevokeResult", -)({ - shareId: ShareGrantIdSchema, - status: Schema.Literal("revoked"), -}) {} diff --git a/packages/api-contract/src/share-invitations.ts b/packages/api-contract/src/share-invitations.ts deleted file mode 100644 index 90233d8d..00000000 --- a/packages/api-contract/src/share-invitations.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { Schema } from "effect"; - -import { - DistributionIdSchema, - DistributionSecurityBoundaryParseOptions, - hasSafeRelativePackagePathCollision, - OrganizationIdSchema, - SafeRelativePathSchema, - Sha256Schema, - UtcTimestampSchema, -} from "./distribution"; - -export const ShareInvitationIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("ShareInvitationId"), -); -export type ShareInvitationId = Schema.Schema.Type; - -/** A 256-bit random secret encoded without padding. This value is never persisted. */ -export const ShareInvitationClaimTokenSchema = Schema.String.check( - Schema.isPattern(/^[A-Za-z0-9_-]{43}$/, { - identifier: "ShareInvitationClaimToken", - description: "a 32-byte base64url claim token without padding", - }), -).pipe(Schema.brand("ShareInvitationClaimToken")); -export type ShareInvitationClaimToken = Schema.Schema.Type; - -/** Canonical recipient address accepted only at the write boundary. */ -export const NormalizedRecipientEmailSchema = Schema.String.check( - Schema.makeFilter((email) => { - if (email !== email.trim() || email !== email.toLowerCase()) { - return "Recipient email must be trimmed and lowercase"; - } - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? undefined : "Recipient email is invalid"; - }), -).pipe(Schema.brand("NormalizedRecipientEmail")); -export type NormalizedRecipientEmail = Schema.Schema.Type; - -/** Write-only issue command. Responses intentionally omit the recipient address. */ -export class ShareInvitationIssue extends Schema.Class( - "ShareInvitationIssue", -)({ - distributionId: DistributionIdSchema, - recipientEmail: NormalizedRecipientEmailSchema, - expiresAt: UtcTimestampSchema, -}) {} - -/** Write-only authenticated claim command. */ -export class ShareInvitationClaim extends Schema.Class( - "ShareInvitationClaim", -)({ - claimToken: ShareInvitationClaimTokenSchema, - claimedOrganizationId: OrganizationIdSchema, -}) {} - -export class ShareInvitationPreviewRequest extends Schema.Class( - "ShareInvitationPreviewRequest", -)({ - claimToken: ShareInvitationClaimTokenSchema, -}) {} - -/** Write-only acknowledgement of the exact disclosure rendered to the recipient. */ -export class ShareInvitationAcceptance extends Schema.Class( - "ShareInvitationAcceptance", -)({ - disclosureSha256: Sha256Schema, -}) {} - -/** Combined acceptance/import command; actor and recipient organization are auth-derived. */ -export class ShareInvitationAcceptImport extends Schema.Class( - "ShareInvitationAcceptImport", -)({ - disclosureSha256: Sha256Schema, -}) {} - -export class ShareInvitationDisclosure extends Schema.Class( - "ShareInvitationDisclosure", -)({ - publisher: Schema.Struct({ name: Schema.NonEmptyString }), - rightsHolder: Schema.Struct({ - kind: Schema.Union([ - Schema.Literal("organization"), - Schema.Literal("user"), - Schema.Literal("external"), - ]), - name: Schema.NonEmptyString, - }), - artifact: Schema.Struct({ - subjectKind: Schema.Literal("skill_revision"), - subjectId: Schema.NonEmptyString, - sourceRevisionHash: Sha256Schema, - packagedSha256: Sha256Schema, - }), - license: Schema.Struct({ - expression: Schema.NonEmptyString, - kind: Schema.Union([ - Schema.Literal("spdx"), - Schema.Literal("license_ref"), - Schema.Literal("proprietary"), - ]), - licenseEvidenceSha256: Sha256Schema, - bundledTerms: Schema.NullOr( - Schema.Struct({ path: SafeRelativePathSchema, sha256: Sha256Schema }), - ), - }), - provenance: Schema.Struct({ - kind: Schema.Union([ - Schema.Literal("github_verified"), - Schema.Literal("selftune_authored"), - Schema.Literal("imported_upstream"), - Schema.Literal("self_attested_upload"), - ]), - sourceRepository: Schema.NullOr(Schema.NonEmptyString), - sourceRef: Schema.NullOr(Schema.NonEmptyString), - sourceTreeHash: Schema.NullOr( - Schema.String.check( - Schema.isPattern(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, { - identifier: "GitObjectHash", - }), - ), - ), - }), - contributorSignals: Schema.Struct({ - status: Schema.Union([ - Schema.Literal("unconfigured"), - Schema.Literal("disabled"), - Schema.Literal("enabled"), - ]), - enabled: Schema.Boolean, - includedInPackage: Schema.Boolean, - activeCapability: Schema.Boolean, - capabilityVersion: Schema.NullOr(Schema.NonEmptyString), - signalSchema: Schema.NullOr(Schema.NonEmptyString), - allowedSignals: Schema.Array( - Schema.Union([ - Schema.Literal("trigger"), - Schema.Literal("grade"), - Schema.Literal("miss_category"), - ]), - ), - }), - /** - * Closed, server-derived disclosures for recipient actions. These hashes are - * the values action requests must echo; clients never invent them. - */ - recipientActions: Schema.Struct({ - accountlessEligibility: Schema.Union([ - Schema.Literal("public_allowed"), - Schema.Literal("account_required"), - ]), - contributorSignals: Schema.Union([ - Schema.Struct({ - _tag: Schema.Literal("signals_unavailable"), - signalDisclosureSha256: Sha256Schema, - signalRecipientOrganizationId: Schema.Null, - allowedFields: Schema.Tuple([]), - capability: Schema.Literal("not_capable"), - defaultState: Schema.Literal("off"), - }), - Schema.Struct({ - _tag: Schema.Literal("capable_default_off"), - signalDisclosureSha256: Sha256Schema, - signalRecipientOrganizationId: OrganizationIdSchema, - allowedFields: Schema.Array( - Schema.Union([ - Schema.Literal("trigger"), - Schema.Literal("grade"), - Schema.Literal("miss_category"), - ]), - ).check(Schema.isNonEmpty()), - capability: Schema.Literal("capable"), - defaultState: Schema.Literal("off"), - }), - ]), - portableDownloadLifecycle: Schema.Struct({ - _tag: Schema.Literal("downloaded_status"), - lifecycleDisclosureSha256: Sha256Schema, - defaultConsent: Schema.Literal("not_granted"), - senderVisibleDownloadedStatus: Schema.Literal("disabled"), - }), - useOnceLifecycle: Schema.Struct({ - _tag: Schema.Literal("used_once_status"), - lifecycleDisclosureSha256: Sha256Schema, - defaultConsent: Schema.Literal("not_granted"), - senderVisibleUsedOnceStatus: Schema.Literal("disabled"), - }), - }), - acceptance: Schema.Struct({ - required: Schema.Boolean, - policyVersion: Schema.NonEmptyString, - disclosureSha256: Sha256Schema, - }), -}) {} - -const ShareInvitationShareIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientShareId"), -); -const ShareInvitationSealedObjectIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("RecipientSealedObjectId"), -); - -/** Exact server-derived identifiers that recipient action requests must echo. */ -export class ShareInvitationActionBindings extends Schema.Class( - "ShareInvitationActionBindings", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - shareId: ShareInvitationShareIdSchema, - sealedObjectId: ShareInvitationSealedObjectIdSchema, - packagedSha256: Sha256Schema, -}) {} - -export class ShareInvitationPackageInspectionFile extends Schema.Class( - "ShareInvitationPackageInspectionFile", -)({ - path: SafeRelativePathSchema, - sha256: Sha256Schema, - byteLength: Schema.Number.check( - Schema.isInt(), - Schema.isBetween({ minimum: 0, maximum: 25 * 1024 * 1024 }), - ), -}) {} - -const ShareInvitationPackageFileManifest = Schema.Array(ShareInvitationPackageInspectionFile) - .check(Schema.isMinLength(1), Schema.isMaxLength(500)) - .check( - Schema.makeFilter((files) => { - const paths = files.map((file) => file.path); - if (paths.some((path, index) => index > 0 && path <= paths[index - 1]!)) { - return "Package inspection paths must use canonical ascending order"; - } - if (!paths.some((path) => path === "SKILL.md")) - return "Package inspection must contain SKILL.md"; - if (hasSafeRelativePackagePathCollision(paths)) { - return "Package inspection paths must not collide"; - } - return files.reduce((total, file) => total + file.byteLength, 0) <= 25 * 1024 * 1024 - ? undefined - : "Package inspection exceeds the canonical byte limit"; - }), - ); - -/** Byte-derived manifest plus the closed SQL-committed delivery decision. */ -export class ShareInvitationPackageInspection extends Schema.Class( - "ShareInvitationPackageInspection", -)({ - fileManifest: ShareInvitationPackageFileManifest, - fileManifestSha256: Sha256Schema, - securityDecision: Schema.Struct({ - _tag: Schema.Literal("authorized_sealed"), - policyVersion: Schema.Literal("recipient-sealed-package-inspection-v1"), - transform: Schema.Struct({ - name: Schema.Literal("selftune-portable-package"), - version: Schema.Literal("1"), - }), - packagedSha256: Sha256Schema, - }), -}) {} - -export const ShareInvitationSenderStatusSchema = Schema.Union([ - Schema.Literal("pending"), - Schema.Literal("delivered"), - Schema.Literal("claimed"), - Schema.Literal("imported"), - Schema.Literal("expired"), - Schema.Literal("revoked"), -]); -export type ShareInvitationSenderStatus = Schema.Schema.Type< - typeof ShareInvitationSenderStatusSchema ->; - -/** Privacy-safe sender projection; it never exposes recipient account state. */ -export class ShareInvitationSenderView extends Schema.Class( - "ShareInvitationSenderView", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - status: ShareInvitationSenderStatusSchema, - expiresAt: UtcTimestampSchema, - licenseAcceptanceRequired: Schema.Boolean, -}) {} - -/** Token-gated, non-consuming public-safe preview. */ -export class ShareInvitationPreview extends Schema.Class( - "ShareInvitationPreview", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - status: Schema.Literal("available"), - expiresAt: UtcTimestampSchema, - disclosure: ShareInvitationDisclosure, - actionBindings: ShareInvitationActionBindings, - packageInspection: ShareInvitationPackageInspection, -}) {} - -/** Claim does not imply license acceptance, import, download, or installation. */ -export class ShareInvitationClaimResult extends Schema.Class( - "ShareInvitationClaimResult", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - status: Schema.Literal("claimed"), - licenseAcceptanceRequired: Schema.Boolean, - licenseAcceptanceSatisfied: Schema.Boolean, - importStatus: Schema.Literal("not_imported"), -}) {} - -export class ShareInvitationRecipientView extends Schema.Class( - "ShareInvitationRecipientView", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - status: Schema.Literal("claimed"), - expiresAt: UtcTimestampSchema, - claimedAt: UtcTimestampSchema, - disclosure: ShareInvitationDisclosure, - actionBindings: ShareInvitationActionBindings, - packageInspection: ShareInvitationPackageInspection, - licenseAcceptance: Schema.Struct({ - required: Schema.Boolean, - satisfied: Schema.Boolean, - reference: Schema.NullOr(Schema.NonEmptyString), - acceptedAt: Schema.NullOr(UtcTimestampSchema), - }), - importStatus: Schema.Union([Schema.Literal("not_imported"), Schema.Literal("imported")]), -}) {} - -export class ShareInvitationAcceptanceResult extends Schema.Class( - "ShareInvitationAcceptanceResult", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - status: Schema.Literal("accepted"), - acceptanceReference: Schema.NonEmptyString, - acceptedAt: UtcTimestampSchema, - disclosure: ShareInvitationDisclosure, - importStatus: Schema.Literal("not_imported"), -}) {} - -export class ShareInvitationImportResult extends Schema.Class( - "ShareInvitationImportResult", -)({ - invitationId: ShareInvitationIdSchema, - distributionId: DistributionIdSchema, - status: Schema.Literal("imported"), - importStatus: Schema.Literal("imported"), - artifactId: Schema.NonEmptyString, - objectId: Schema.String.check(Schema.isUUID()), - snapshotId: Schema.String.check(Schema.isUUID()), - packagedSha256: Sha256Schema, - acceptanceReference: Schema.NullOr(Schema.NonEmptyString), - acceptedAt: Schema.NullOr(UtcTimestampSchema), - importedAt: UtcTimestampSchema, -}) {} - -/** Enumeration-safe failure for invalid, expired, revoked, used, or wrong-audience claims. */ -export class ShareInvitationUnavailable extends Schema.TaggedErrorClass()( - "ShareInvitationUnavailable", - { message: Schema.NonEmptyString }, - { httpApiStatus: 404 }, -) {} - -export class ShareInvitationDistributionNotEligible extends Schema.TaggedErrorClass()( - "ShareInvitationDistributionNotEligible", - { - distributionId: DistributionIdSchema, - message: Schema.NonEmptyString, - }, - { httpApiStatus: 422 }, -) {} - -export class ShareInvitationConflict extends Schema.TaggedErrorClass()( - "ShareInvitationConflict", - { message: Schema.NonEmptyString }, - { httpApiStatus: 409 }, -) {} - -export const ShareInvitationFailureSchema = Schema.Union([ - ShareInvitationUnavailable, - ShareInvitationDistributionNotEligible, - ShareInvitationConflict, -]); -export type ShareInvitationFailure = Schema.Schema.Type; - -export const decodeShareInvitationIssue = (input: unknown) => - Schema.decodeUnknownEffect(ShareInvitationIssue)(input, DistributionSecurityBoundaryParseOptions); - -export const decodeShareInvitationClaim = (input: unknown) => - Schema.decodeUnknownEffect(ShareInvitationClaim)(input, DistributionSecurityBoundaryParseOptions); - -export const decodeShareInvitationAcceptance = (input: unknown) => - Schema.decodeUnknownEffect(ShareInvitationAcceptance)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeShareInvitationAcceptImport = (input: unknown) => - Schema.decodeUnknownEffect(ShareInvitationAcceptImport)( - input, - DistributionSecurityBoundaryParseOptions, - ); - -export const decodeShareInvitationFailure = (input: unknown) => - Schema.decodeUnknownEffect(ShareInvitationFailureSchema)( - input, - DistributionSecurityBoundaryParseOptions, - ); diff --git a/packages/api-contract/src/signal-routing-capability.ts b/packages/api-contract/src/signal-routing-capability.ts deleted file mode 100644 index 409d0cf6..00000000 --- a/packages/api-contract/src/signal-routing-capability.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Schema } from "effect"; - -import { - ContributorSignalFieldSchema, - DistributionIdSchema, - OrganizationIdSchema, - Sha256Schema, - UtcTimestampSchema, -} from "./distribution"; - -const Base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; -const Ed25519SignatureByteLength = 64; - -function decodeBase64Url(value: string): Uint8Array | null { - if (value.length % 4 === 1) return null; - const bytes: number[] = []; - let accumulator = 0; - let bitCount = 0; - for (const character of value) { - const digit = Base64UrlAlphabet.indexOf(character); - if (digit < 0) return null; - accumulator = (accumulator << 6) | digit; - bitCount += 6; - if (bitCount >= 8) { - bitCount -= 8; - bytes.push((accumulator >> bitCount) & 0xff); - accumulator &= (1 << bitCount) - 1; - } - } - return Uint8Array.from(bytes); -} - -function encodeBase64Url(bytes: Uint8Array): string { - let encoded = ""; - let accumulator = 0; - let bitCount = 0; - for (const byte of bytes) { - accumulator = (accumulator << 8) | byte; - bitCount += 8; - while (bitCount >= 6) { - bitCount -= 6; - encoded += Base64UrlAlphabet[(accumulator >> bitCount) & 0x3f]; - accumulator &= (1 << bitCount) - 1; - } - } - if (bitCount > 0) encoded += Base64UrlAlphabet[(accumulator << (6 - bitCount)) & 0x3f]; - return encoded; -} - -export function isCanonicalSignalRoutingCapabilitySignature(value: string): boolean { - const decoded = decodeBase64Url(value); - return ( - decoded !== null && - decoded.byteLength === Ed25519SignatureByteLength && - encodeBase64Url(decoded) === value - ); -} - -export const SignalRoutingCapabilityPayloadVersionSchema = Schema.Literal(1); -export type SignalRoutingCapabilityPayloadVersion = Schema.Schema.Type< - typeof SignalRoutingCapabilityPayloadVersionSchema ->; - -export const SignalRoutingSigningKeyIdSchema = Schema.NonEmptyString.check( - Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/, { - identifier: "SignalRoutingSigningKeyId", - description: "a stable public signing-key identifier", - }), -).pipe(Schema.brand("SignalRoutingSigningKeyId")); -export type SignalRoutingSigningKeyId = Schema.Schema.Type; - -export const SignalRoutingPublicCapabilityIdSchema = Schema.String.check(Schema.isUUID()).pipe( - Schema.brand("SignalRoutingPublicCapabilityId"), -); -export type SignalRoutingPublicCapabilityId = Schema.Schema.Type< - typeof SignalRoutingPublicCapabilityIdSchema ->; - -export const SignalRoutingCapabilitySignatureSchema = Schema.String.check( - Schema.isPattern(/^[A-Za-z0-9_-]{86}$/, { - identifier: "Ed25519Signature", - description: "an unpadded base64url-encoded 64-byte Ed25519 signature", - }), - Schema.makeFilter(isCanonicalSignalRoutingCapabilitySignature, { - identifier: "CanonicalEd25519Signature", - description: "the canonical base64url encoding of exactly 64 signature bytes", - }), -).pipe(Schema.brand("SignalRoutingCapabilitySignature")); -export type SignalRoutingCapabilitySignature = Schema.Schema.Type< - typeof SignalRoutingCapabilitySignatureSchema ->; - -export const SignalRoutingAllowedSchema = Schema.Literal("selftune.contributor-signals.v1"); -export type SignalRoutingAllowedSchema = Schema.Schema.Type; - -export class SignalRoutingLogicalSkill extends Schema.Class( - "SignalRoutingLogicalSkill", -)({ - id: Schema.NonEmptyString, - /** The immutable source-revision hash is the portable logical version identity. */ - version: Sha256Schema, -}) {} - -const CanonicalSignalFields = ["grade", "miss_category", "trigger"] as const; -const MaximumCapabilityLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000; - -function areCanonicalSignalFields(fields: ReadonlyArray): boolean { - if (new Set(fields).size !== fields.length) return false; - const canonical = CanonicalSignalFields.filter((field) => fields.includes(field)); - return ( - canonical.length === fields.length && canonical.every((field, index) => field === fields[index]) - ); -} - -/** - * A public, non-secret routing assertion. It provides integrity, not installation - * authentication or contributor consent. - */ -export class SignalRoutingCapabilityPayload extends Schema.Class( - "SignalRoutingCapabilityPayload", -)( - Schema.Struct({ - version: SignalRoutingCapabilityPayloadVersionSchema, - kid: SignalRoutingSigningKeyIdSchema, - publicCapabilityId: SignalRoutingPublicCapabilityIdSchema, - distributionId: DistributionIdSchema, - packagedSha256: Sha256Schema, - logicalSkill: SignalRoutingLogicalSkill, - telemetryRecipientOrganizationId: OrganizationIdSchema, - allowedSignalSchema: SignalRoutingAllowedSchema, - allowedSignalFields: Schema.NonEmptyArray(ContributorSignalFieldSchema), - issuedAt: UtcTimestampSchema, - expiresAt: UtcTimestampSchema, - }).check( - Schema.makeFilter((payload) => - !areCanonicalSignalFields(payload.allowedSignalFields) - ? "Allowed contributor signal fields must be unique and in canonical order" - : Date.parse(payload.expiresAt) <= Date.parse(payload.issuedAt) - ? "Signal routing capability expiry must be after issuance" - : Date.parse(payload.expiresAt) - Date.parse(payload.issuedAt) > - MaximumCapabilityLifetimeMilliseconds - ? "Signal routing capability lifetime exceeds the maximum" - : undefined, - ), - ), -) {} - -export class SignalRoutingCapabilityEnvelope extends Schema.Class( - "SignalRoutingCapabilityEnvelope", -)({ - payload: SignalRoutingCapabilityPayload, - signature: SignalRoutingCapabilitySignatureSchema, -}) {} diff --git a/packages/api-contract/src/team.ts b/packages/api-contract/src/team.ts deleted file mode 100644 index 1bb3b158..00000000 --- a/packages/api-contract/src/team.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Schema } from "effect"; - -export const TeamApiPaths = { - status: "/api/v1/cloud/team", - invite: "/api/v1/cloud/team/invitations", - member: "/api/v1/cloud/team/members/:userId", - invitation: "/api/v1/cloud/team/invitations/:invitationId", -} as const; -export const TeamRole = Schema.Literals(["viewer", "member", "admin", "owner"]); -export type TeamRole = Schema.Schema.Type; -export const TeamInviteRole = Schema.Literals(["viewer", "member", "admin"]); -export const TeamMember = Schema.Struct({ - userId: Schema.String, - email: Schema.String, - name: Schema.NullOr(Schema.String), - avatarUrl: Schema.NullOr(Schema.String), - role: TeamRole, - joinedAt: Schema.String, -}); -export type TeamMember = Schema.Schema.Type; -export const TeamInvitation = Schema.Struct({ - id: Schema.String, - email: Schema.String, - role: TeamRole, - invitedBy: Schema.String, - invitedAt: Schema.String, -}); -export type TeamInvitation = Schema.Schema.Type; -export const TeamStatus = Schema.Struct({ - currentUserId: Schema.String, - currentRole: TeamRole, - readOnly: Schema.Boolean, - seatUsage: Schema.Int, - seatLimit: Schema.NullOr(Schema.Int), - billingPath: Schema.String, - members: Schema.mutable(Schema.Array(TeamMember)), - invitations: Schema.mutable(Schema.Array(TeamInvitation)), -}); -export type TeamStatus = Schema.Schema.Type; -export class TeamInviteInput extends Schema.Class("TeamInviteInput")({ - email: Schema.String.pipe( - Schema.check( - Schema.isMinLength(3), - Schema.isMaxLength(320), - Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), - ), - ), - role: TeamInviteRole, -}) {} -export class TeamRoleChangeInput extends Schema.Class("TeamRoleChangeInput")({ - role: TeamInviteRole, -}) {} diff --git a/packages/api-contract/tests/billing.test.ts b/packages/api-contract/tests/billing.test.ts deleted file mode 100644 index 7f766b5c..00000000 --- a/packages/api-contract/tests/billing.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { BillingCheckoutInput, BillingStatus, createCloudApiClient, decodeUnknown } from "../index"; - -const billingStatus = { - plan: "free", - subscriptionStatus: "none", - currentPeriodEnd: null, - trialEnd: null, - seatCount: 1, - hasStripeCustomer: false, - canManageBilling: true, - availablePlans: [ - { - id: "free", - name: "Community", - price: "$0", - period: "forever", - description: "For individual developers", - features: ["Five skills"], - highlighted: false, - seats: null, - }, - { - id: "team", - name: "Team", - price: "$49", - period: "/month", - description: "For teams", - features: ["Unlimited skills"], - highlighted: true, - seats: { minimum: 1, label: "Seats" }, - }, - ], -}; - -function jsonFetch( - respond: (request: Request) => Response | Promise, - requests: Request[], -): typeof fetch { - return async (input, init) => { - const request = new Request(input, init); - requests.push(request); - return respond(request); - }; -} - -describe("billing contract", () => { - it("rejects invalid Stripe states and non-positive seat counts", () => { - expect( - decodeUnknown(BillingStatus, { ...billingStatus, subscriptionStatus: "pending" }).success, - ).toBe(false); - expect(decodeUnknown(BillingStatus, { ...billingStatus, seatCount: 0 }).success).toBe(false); - expect(decodeUnknown(BillingCheckoutInput, { plan: "enterprise" }).success).toBe(false); - expect(decodeUnknown(BillingCheckoutInput, { plan: "team", seats: 0 }).success).toBe(false); - expect( - decodeUnknown(BillingStatus, { - ...billingStatus, - availablePlans: [ - { ...billingStatus.availablePlans[1], seats: { minimum: 0, label: null } }, - ], - }).success, - ).toBe(false); - }); - - it("wires billing methods through the generated client with validated responses", async () => { - const requests: Request[] = []; - const finalized = { - finalized: true, - billing: billingStatus, - sessionStatus: "complete", - paymentStatus: "paid", - }; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch((request) => { - if (request.url.endsWith("/status")) return Response.json(billingStatus); - if (request.url.endsWith("/checkout/finalize")) { - return Response.json(finalized); - } - return Response.json({ url: "https://checkout.stripe.com/session" }); - }, requests), - }); - - await expect(client.billingStatus()).resolves.toEqual(billingStatus); - await expect(client.createBillingCheckout({ plan: "team", seats: 3 })).resolves.toEqual({ - url: "https://checkout.stripe.com/session", - }); - await expect(client.createBillingPortal()).resolves.toEqual({ - url: "https://checkout.stripe.com/session", - }); - await expect(client.finalizeBillingCheckout({ sessionId: "cs_test_123" })).resolves.toEqual( - expect.objectContaining(finalized), - ); - - expect(requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`)).toEqual([ - "GET /api/v1/cloud/billing/status", - "POST /api/v1/cloud/billing/checkout", - "POST /api/v1/cloud/billing/portal", - "POST /api/v1/cloud/billing/checkout/finalize", - ]); - await expect(requests[1]?.json()).resolves.toEqual({ plan: "team", seats: 3 }); - await expect(requests[3]?.json()).resolves.toEqual({ sessionId: "cs_test_123" }); - }); -}); diff --git a/packages/api-contract/tests/client.test.ts b/packages/api-contract/tests/client.test.ts deleted file mode 100644 index 3e083ea8..00000000 --- a/packages/api-contract/tests/client.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - ApiClientError, - CloudBootstrapSchema, - SelfTuneCloudApi, - ShareInvitationAcceptance, - ShareInvitationClaim, - ShareInvitationPreview, - ShareInvitationPreviewRequest, - createCloudApiClient, - decodeUnknown, - type CloudBootstrap, -} from "../index"; - -const bootstrapFixture: CloudBootstrap = { - viewer: { - id: "user-1", - email: "person@example.com", - name: "Person", - avatarUrl: null, - }, - memberships: [ - { - orgId: "org-1", - orgName: "Acme", - orgSlug: "acme", - role: "owner", - plan: "team", - }, - ], - activeWorkspace: { id: "org-1", name: "Acme", slug: "acme" }, - plan: "team", - onboardingState: "inventory_seeded", -}; - -function jsonFetch( - respond: (request: Request) => Response | Promise, - requests: Request[] = [], -): typeof fetch { - return async (input, init) => { - const request = new Request(input, init); - requests.push(request); - return respond(request); - }; -} - -describe("Effect cloud contract", () => { - it("uses Effect Schema as the wire authority", () => { - expect(Schema.decodeUnknownSync(CloudBootstrapSchema)(bootstrapFixture)).toEqual( - bootstrapFixture, - ); - - const result = decodeUnknown(CloudBootstrapSchema, { - ...bootstrapFixture, - memberships: [{ ...bootstrapFixture.memberships[0], role: "superuser" }], - }); - expect(result.success).toBe(false); - }); - - it("publishes the canonical API as an Effect HttpApi", () => { - expect(SelfTuneCloudApi.groups).toHaveProperty("cloud"); - expect(SelfTuneCloudApi.groups).toHaveProperty("shareInvitations"); - }); -}); - -describe("createCloudApiClient", () => { - it("uses the generated HttpApi client with auth and organization context", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev/", - getAccessToken: async () => "jwt-token", - getActiveOrganizationId: () => "org-1", - fetch: jsonFetch(() => Response.json(bootstrapFixture), requests), - }); - - await expect(client.bootstrap()).resolves.toEqual(bootstrapFixture); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe("https://cloud.selftune.dev/api/v1/cloud/bootstrap"); - expect(requests[0]?.method).toBe("GET"); - expect(requests[0]?.credentials).toBe("include"); - expect(requests[0]?.headers.get("authorization")).toBe("Bearer jwt-token"); - expect(requests[0]?.headers.get("x-selftune-active-org-id")).toBe("org-1"); - }); - - it("exposes the canonical Cloud Library and Skill Set operations", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch((request) => { - if (request.url.endsWith("/device-code")) { - return Response.json({ status: "approved" }); - } - if (request.method === "DELETE") return Response.json({ deleted: true }); - if (request.url.includes("/export")) { - return Response.json({ - filename: "set.json", - contentType: "application/json", - content: "{}", - }); - } - if (request.url.endsWith("/library")) { - return Response.json({ skills: [], categoryOptions: [] }); - } - if (request.method === "GET") { - return Response.json({ - skillSets: [], - availableSkills: [], - receipts: [], - }); - } - return Response.json( - { - id: "engineering", - name: "Engineering", - description: "", - connections: ["codex"], - skills: [], - revision: 1, - revisionHash: "hash", - updatedAt: "2026-07-19T00:00:00.000Z", - }, - { status: request.method === "POST" ? 201 : 200 }, - ); - }, requests), - }); - - await client.library(); - await client.skillSets(); - await client.createSkillSet({ - name: "Engineering", - description: "", - connections: ["codex"], - skills: [{ name: "tdd", packagePath: "cloud://tdd" }], - }); - await client.updateSkillSet({ - id: "engineering", - parentRevisionHash: "hash", - name: "Engineering", - description: "", - connections: ["codex"], - skills: [{ name: "tdd", packagePath: "cloud://tdd" }], - }); - await client.deleteSkillSet("engineering"); - await client.exportSkillSet("engineering"); - await client.materializeSkillSetExport("engineering"); - await client.decideDeviceCode({ userCode: "ABCD-1234", action: "approve" }); - - expect(requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`)).toEqual([ - "GET /api/v1/cloud/library", - "GET /api/v1/cloud/skill-sets", - "POST /api/v1/cloud/skill-sets", - "PATCH /api/v1/cloud/skill-sets/engineering", - "DELETE /api/v1/cloud/skill-sets/engineering", - "GET /api/v1/cloud/skill-sets/engineering/export", - "POST /api/v1/cloud/skill-sets/engineering/export/materialize", - "POST /api/v1/cloud/device-code", - ]); - await expect(requests[2]?.json()).resolves.toEqual({ - name: "Engineering", - description: "", - connections: ["codex"], - skills: [{ name: "tdd", packagePath: "cloud://tdd" }], - }); - }); - - it("decodes typed HttpApi failures", async () => { - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch(() => - Response.json( - { - _tag: "CloudForbidden", - code: "workspace_forbidden", - message: "You cannot access this workspace.", - }, - { status: 403 }, - ), - ), - }); - - await expect(client.bootstrap()).rejects.toMatchObject({ - name: "ApiClientError", - kind: "http", - status: 403, - code: "workspace_forbidden", - message: "You cannot access this workspace.", - }); - }); - - it("preserves the materialization action conflict as a typed client error", async () => { - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch(() => - Response.json( - { - _tag: "CloudConflict", - code: "cloud_skill_set_export_materialization_conflict", - message: "Another authorized actor owns the active lease.", - }, - { status: 409 }, - ), - ), - }); - - await expect(client.materializeSkillSetExport("engineering")).rejects.toMatchObject({ - name: "ApiClientError", - kind: "http", - status: 409, - code: "cloud_skill_set_export_materialization_conflict", - }); - }); - - it("preserves a retriable export failure as a typed service-unavailable error", async () => { - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch(() => - Response.json( - { - _tag: "CloudServiceUnavailable", - code: "cloud_skill_set_export_unavailable", - message: "The materialized export is temporarily unavailable.", - }, - { status: 503 }, - ), - ), - }); - - await expect(client.exportSkillSet("engineering")).rejects.toMatchObject({ - name: "ApiClientError", - kind: "http", - status: 503, - code: "cloud_skill_set_export_unavailable", - }); - }); - - it("exposes preview, claim, recipient view, and exact acceptance through the generated client", async () => { - const requests: Request[] = []; - const recipientAuthority = { - actionBindings: { - invitationId: "11111111-1111-4111-8111-111111111111", - distributionId: "22222222-2222-4222-8222-222222222222", - shareId: "44444444-4444-4444-8444-444444444444", - sealedObjectId: "55555555-5555-4555-8555-555555555555", - packagedSha256: "b".repeat(64), - }, - packageInspection: { - fileManifest: [{ path: "SKILL.md", sha256: "3".repeat(64), byteLength: 12 }], - fileManifestSha256: "4".repeat(64), - securityDecision: { - _tag: "authorized_sealed", - policyVersion: "recipient-sealed-package-inspection-v1", - transform: { name: "selftune-portable-package", version: "1" }, - packagedSha256: "b".repeat(64), - }, - }, - } as const; - const preview = Schema.decodeUnknownSync(ShareInvitationPreview)({ - invitationId: "11111111-1111-4111-8111-111111111111", - distributionId: "22222222-2222-4222-8222-222222222222", - status: "available", - expiresAt: "2026-07-22T00:00:00.000Z", - ...recipientAuthority, - disclosure: { - publisher: { name: "Acme Skills" }, - rightsHolder: { kind: "external", name: "Example Author" }, - artifact: { - subjectKind: "skill_revision", - subjectId: "review-helper", - sourceRevisionHash: "a".repeat(64), - packagedSha256: "b".repeat(64), - }, - license: { - expression: "Proprietary", - kind: "proprietary", - licenseEvidenceSha256: "c".repeat(64), - bundledTerms: { path: "LICENSE.txt", sha256: "d".repeat(64) }, - }, - provenance: { - kind: "self_attested_upload", - sourceRepository: null, - sourceRef: null, - sourceTreeHash: null, - }, - contributorSignals: { - status: "disabled", - enabled: false, - includedInPackage: false, - activeCapability: false, - capabilityVersion: null, - signalSchema: null, - allowedSignals: [], - }, - recipientActions: { - accountlessEligibility: "account_required", - contributorSignals: { - _tag: "signals_unavailable", - signalDisclosureSha256: "f".repeat(64), - signalRecipientOrganizationId: null, - allowedFields: [], - capability: "not_capable", - defaultState: "off", - }, - portableDownloadLifecycle: { - _tag: "downloaded_status", - lifecycleDisclosureSha256: "1".repeat(64), - defaultConsent: "not_granted", - senderVisibleDownloadedStatus: "disabled", - }, - useOnceLifecycle: { - _tag: "used_once_status", - lifecycleDisclosureSha256: "2".repeat(64), - defaultConsent: "not_granted", - senderVisibleUsedOnceStatus: "disabled", - }, - }, - acceptance: { - required: true, - policyVersion: "skill-share-license-acceptance-v1", - disclosureSha256: "e".repeat(64), - }, - }, - }); - const claim = Schema.decodeUnknownSync(ShareInvitationClaim)({ - claimToken: "A".repeat(43), - claimedOrganizationId: "33333333-3333-4333-8333-333333333333", - }); - const acceptance = Schema.decodeUnknownSync(ShareInvitationAcceptance)({ - disclosureSha256: "e".repeat(64), - }); - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch((request) => { - if (request.url.endsWith("/preview")) return Response.json(preview); - if (request.url.endsWith("/claim")) { - return Response.json({ - invitationId: preview.invitationId, - distributionId: preview.distributionId, - status: "claimed", - licenseAcceptanceRequired: true, - licenseAcceptanceSatisfied: false, - importStatus: "not_imported", - }); - } - if (request.url.endsWith("/license-acceptance")) { - return Response.json({ - invitationId: preview.invitationId, - distributionId: preview.distributionId, - status: "accepted", - acceptanceReference: "skill-share-license-acceptance-v1:" + acceptance.disclosureSha256, - acceptedAt: "2026-07-21T12:01:00.000Z", - disclosure: preview.disclosure, - importStatus: "not_imported", - }); - } - return Response.json({ - invitationId: preview.invitationId, - distributionId: preview.distributionId, - status: "claimed", - expiresAt: preview.expiresAt, - claimedAt: "2026-07-21T12:00:00.000Z", - disclosure: preview.disclosure, - actionBindings: preview.actionBindings, - packageInspection: preview.packageInspection, - licenseAcceptance: { - required: true, - satisfied: false, - reference: null, - acceptedAt: null, - }, - importStatus: "not_imported", - }); - }, requests), - }); - const previewInput = Schema.decodeUnknownSync(ShareInvitationPreviewRequest)({ - claimToken: "A".repeat(43), - }); - - await client.previewShareInvitation(previewInput.claimToken); - await client.claimShareInvitation(claim); - await client.getShareInvitation(preview.invitationId); - await client.acceptShareInvitationLicense(preview.invitationId, acceptance); - - expect(requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`)).toEqual([ - "POST /api/v1/public/share-invitations/preview", - "POST /api/v1/share-invitations/claim", - `GET /api/v1/share-invitations/${preview.invitationId}`, - `POST /api/v1/share-invitations/${preview.invitationId}/license-acceptance`, - ]); - }); - - it("distinguishes invalid successful payloads from transport failures", async () => { - const invalid = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: jsonFetch(() => Response.json({ viewer: null })), - }); - await expect(invalid.bootstrap()).rejects.toBeInstanceOf(ApiClientError); - await expect(invalid.bootstrap()).rejects.toMatchObject({ - kind: "invalid_response", - code: "invalid_response_payload", - }); - - const offline = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async () => { - throw new Error("socket closed"); - }, - }); - await expect(offline.bootstrap()).rejects.toMatchObject({ - kind: "network", - status: null, - message: "Unable to reach the selftune API.", - }); - }); -}); diff --git a/packages/api-contract/tests/cloud-improve.test.ts b/packages/api-contract/tests/cloud-improve.test.ts deleted file mode 100644 index 614327a5..00000000 --- a/packages/api-contract/tests/cloud-improve.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createCloudApiClient } from "../index"; - -const proposal = { - id: "proposal-1", - skillId: "skill-1", - skillName: "TDD", - proposalType: "structure", - currentValue: "old", - proposedValue: "new", - rationale: null, - passRateBefore: null, - projectedPassRate: null, - status: "pending", - createdAt: "2026-07-24T10:00:00.000Z", - reviewedAt: null, - appliedAt: null, - runId: null, - candidateId: null, - applyTarget: null, - diffText: null, -}; - -describe("Cloud improve contract client", () => { - it("uses the maintained Cloud proposal paths and decodes review responses", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async (input, init) => { - requests.push(new Request(input, init)); - return Response.json(proposal); - }, - }); - await expect( - client.reviewProposal("proposal-1", { status: "approved" }), - ).resolves.toMatchObject({ id: "proposal-1", status: "pending" }); - expect(requests[0]?.url).toBe("https://cloud.selftune.dev/api/v1/cloud/proposals/proposal-1"); - expect(requests[0]?.method).toBe("PATCH"); - }); - - it("keeps run-to-proposal correlation in the typed query", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async (input, init) => { - requests.push(new Request(input, init)); - return Response.json({ proposals: [], total: 0 }); - }, - }); - - await client.proposals({ runId: "run / 1" }); - - expect(requests[0]?.url).toBe( - "https://cloud.selftune.dev/api/v1/cloud/proposals?runId=run+%2F+1", - ); - }); -}); diff --git a/packages/api-contract/tests/distribution-source-staging.test.ts b/packages/api-contract/tests/distribution-source-staging.test.ts deleted file mode 100644 index 11e0572e..00000000 --- a/packages/api-contract/tests/distribution-source-staging.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - CanonicalDistributionSourcePackage, - DistributionSecurityBoundaryParseOptions, - DistributionSourceFailureSchema, - DistributionSourceAuthorizationBinding, - DistributionSourceObjectResolution, - DistributionSourcePackageInspectionEvidence, - DistributionSourceRevision, - DistributionSourceRevisionIdSchema, - MaximumDistributionSourceAggregateBytes, - MaximumDistributionSourceObjectBytes, - MaximumDistributionSourceSkillSetComponents, - StageDistributionSourceRevision, -} from "../index"; - -const ids = { - organization: "10000000-0000-4000-8000-000000000001", - actor: "20000000-0000-4000-8000-000000000002", - request: "30000000-0000-4000-8000-000000000003", - idempotency: "40000000-0000-4000-8000-000000000004", - rootObject: "50000000-0000-4000-8000-000000000005", - componentOne: "60000000-0000-4000-8000-000000000006", - componentTwo: "70000000-0000-4000-8000-000000000007", - revision: "80000000-0000-4000-8000-000000000008", - claim: "90000000-0000-4000-8000-000000000009", - authorization: "a0000000-0000-4000-8000-00000000000a", - authorizationBinding: "b0000000-0000-4000-8000-00000000000b", -}; - -function object( - objectId: string, - character: string, - byteLength = 4, - format: "selftune-package-v2" | "selftune-skill-set-source-v1" = "selftune-package-v2", -) { - return { - objectId, - format, - sourceRevisionHash: character.repeat(64), - sourceObjectSha256: character.repeat(64), - byteLength, - }; -} - -function standaloneSource() { - return { - subject: { - _tag: "skill_revision", - skillRevisionId: "review-helper", - sourceRevisionHash: "a".repeat(64), - }, - object: object(ids.rootObject, "a"), - bom: { _tag: "standalone" }, - }; -} - -function skillSetSource() { - return { - subject: { - _tag: "skill_set", - skillSetId: "research-suite", - sourceRevisionHash: "a".repeat(64), - }, - object: object(ids.rootObject, "a", 4, "selftune-skill-set-source-v1"), - bom: { - _tag: "skill_set", - skillSetId: "research-suite", - components: [ - { - ordinal: 0, - subject: { - _tag: "skill_revision", - skillRevisionId: "research", - sourceRevisionHash: "b".repeat(64), - }, - object: object(ids.componentOne, "b"), - }, - { - ordinal: 1, - subject: { - _tag: "skill_revision", - skillRevisionId: "summarize", - sourceRevisionHash: "c".repeat(64), - }, - object: object(ids.componentTwo, "c"), - }, - ], - }, - }; -} - -function context() { - return { - organizationId: ids.organization, - actorId: ids.actor, - requestId: ids.request, - idempotencyKey: ids.idempotency, - }; -} - -function stagedRevision(source = standaloneSource()) { - return { - sourceRevisionId: ids.revision, - organizationId: ids.organization, - source, - phase: "staged", - inspection: { _tag: "pending" }, - attestation: { _tag: "pending" }, - authorization: { _tag: "pending" }, - stagedBy: ids.actor, - stagedAt: "2026-07-21T08:00:00.000Z", - }; -} - -function standaloneInspectionEvidence() { - return { - root: { - _tag: "package", - ordinal: null, - subject: standaloneSource().subject, - sourceObjectSha256: "a".repeat(64), - fileManifest: [{ path: "SKILL.md", sha256: "b".repeat(64), byteLength: 42 }], - fileManifestSha256: "c".repeat(64), - license: { - normalizedExpression: "MIT", - kind: "spdx", - policyDisposition: "automated_approved", - normalizationProof: { - _tag: "spdx_registry_v1", - parser: "spdx-expression-parse", - parserVersion: "4.0.0", - normalizedExpressionSha256: "2".repeat(64), - proofSha256: "3".repeat(64), - }, - licenseFile: null, - notices: [], - licenseEvidenceSha256: "d".repeat(64), - noticeEvidenceSha256: "e".repeat(64), - }, - provenance: { - kind: "selftune_authored", - sourceRepository: null, - sourceRef: null, - sourceTreeHash: null, - evidenceSha256: "f".repeat(64), - }, - inspectionEvidenceSha256: "1".repeat(64), - }, - components: [], - }; -} - -function expectDecodeFailure(schema: Schema.Decoder, input: unknown): void { - expect(() => Schema.decodeUnknownSync(schema)(input)).toThrow(); -} - -describe("distribution source staging contract", () => { - it.each([ - "../SKILL.md", - "/SKILL.md", - "C:/SKILL.md", - "folder\\SKILL.md", - "file.txt:stream", - "folder./SKILL.md", - "folder /SKILL.md", - "CON", - "COM1.txt", - "COM¹.txt", - "control\u0001.txt", - "café/SKILL.md", - ])("rejects non-portable package path %j", (path) => { - const artifact = standaloneInspectionEvidence().root; - expectDecodeFailure(DistributionSourcePackageInspectionEvidence, { - ...artifact, - fileManifest: [{ ...artifact.fileManifest[0], path }], - }); - }); - - it("rejects file/ancestor package path collisions", () => { - const artifact = standaloneInspectionEvidence().root; - expectDecodeFailure(DistributionSourcePackageInspectionEvidence, { - ...artifact, - fileManifest: [ - artifact.fileManifest[0], - { path: "SKILL.md/child", sha256: "4".repeat(64), byteLength: 1 }, - ], - }); - }); - - it("keeps the server source revision id distinct from every content hash", () => { - const stage = Schema.decodeUnknownSync(StageDistributionSourceRevision)({ - context: context(), - source: standaloneSource(), - }); - expect(stage.source.object.sourceRevisionHash).toBe("a".repeat(64)); - expectDecodeFailure(DistributionSourceRevisionIdSchema, "a".repeat(64)); - expect( - Schema.decodeUnknownSync(DistributionSourceRevision)(stagedRevision()).sourceRevisionId, - ).toBe(ids.revision); - }); - - it("binds logical subject hashes while allowing revision and object hashes to differ", () => { - const distinctHashes = { - ...standaloneSource(), - subject: { - ...standaloneSource().subject, - sourceRevisionHash: "f".repeat(64), - }, - object: { - ...standaloneSource().object, - sourceRevisionHash: "f".repeat(64), - }, - }; - expect( - Schema.decodeUnknownSync(CanonicalDistributionSourcePackage)(distinctHashes).object - .sourceObjectSha256, - ).toBe("a".repeat(64)); - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...distinctHashes, - subject: { - ...distinctHashes.subject, - sourceRevisionHash: "e".repeat(64), - }, - }); - }); - - it("accepts standalone packages and nonempty, contiguous, unique Skill Set BOMs", () => { - expect( - Schema.decodeUnknownSync(CanonicalDistributionSourcePackage)(standaloneSource()).bom._tag, - ).toBe("standalone"); - const set = Schema.decodeUnknownSync(CanonicalDistributionSourcePackage)(skillSetSource()); - expect(set.bom._tag).toBe("skill_set"); - if (set.bom._tag === "skill_set") expect(set.bom.components).toHaveLength(2); - - const invalidBoms = [ - { ...skillSetSource(), bom: { ...skillSetSource().bom, components: [] } }, - { - ...skillSetSource(), - bom: { - ...skillSetSource().bom, - components: [ - { - ordinal: 1, - subject: skillSetSource().bom.components[0]?.subject, - object: object(ids.componentOne, "b"), - }, - ], - }, - }, - { - ...skillSetSource(), - bom: { - ...skillSetSource().bom, - components: [ - { - ordinal: 0, - subject: skillSetSource().bom.components[0]?.subject, - object: object(ids.componentOne, "b"), - }, - { - ordinal: 1, - subject: skillSetSource().bom.components[0]?.subject, - object: object(ids.componentTwo, "b"), - }, - ], - }, - }, - { - ...skillSetSource(), - bom: { - ...skillSetSource().bom, - components: skillSetSource().bom.components.map((component, index) => - index === 1 - ? { - ...component, - object: { - ...component.object, - sourceObjectSha256: "b".repeat(64), - }, - } - : component, - ), - }, - }, - ]; - for (const invalid of invalidBoms) { - expectDecodeFailure(CanonicalDistributionSourcePackage, invalid); - } - }); - - it("uses a distinct source-manifest format for Skill Set roots only", () => { - const set = Schema.decodeUnknownSync(CanonicalDistributionSourcePackage)(skillSetSource()); - expect(set.object.format).toBe("selftune-skill-set-source-v1"); - if (set.bom._tag === "skill_set") { - expect( - set.bom.components.every((component) => component.object.format === "selftune-package-v2"), - ).toBe(true); - } - - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...skillSetSource(), - object: object(ids.rootObject, "a"), - }); - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...standaloneSource(), - object: object(ids.rootObject, "a", 4, "selftune-skill-set-source-v1"), - }); - const setWithManifestComponent = skillSetSource(); - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...setWithManifestComponent, - bom: { - ...setWithManifestComponent.bom, - components: setWithManifestComponent.bom.components.map((component, index) => - index === 0 - ? { - ...component, - object: { - ...component.object, - format: "selftune-skill-set-source-v1", - }, - } - : component, - ), - }, - }); - }); - - it("bounds each object, Skill Set component count, and aggregate package bytes", () => { - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...standaloneSource(), - object: { - ...standaloneSource().object, - byteLength: MaximumDistributionSourceObjectBytes + 1, - }, - }); - - const set = skillSetSource(); - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...set, - object: { ...set.object, byteLength: 10 * 1024 * 1024 }, - bom: { - ...set.bom, - components: set.bom.components.map((component) => ({ - ...component, - object: { ...component.object, byteLength: 8 * 1024 * 1024 }, - })), - }, - }); - expect(MaximumDistributionSourceAggregateBytes).toBe(25 * 1024 * 1024); - - const components = Array.from( - { length: MaximumDistributionSourceSkillSetComponents + 1 }, - (_, ordinal) => ({ - ordinal, - subject: { - _tag: "skill_revision", - skillRevisionId: `skill-${ordinal}`, - sourceRevisionHash: "d".repeat(64), - }, - object: object(`c0000000-0000-4000-8000-${String(ordinal + 1).padStart(12, "0")}`, "d", 1), - }), - ); - expectDecodeFailure(CanonicalDistributionSourcePackage, { - ...set, - bom: { ...set.bom, components }, - }); - }); - - it("enforces the stage, inspect, attest, authorize evidence sequence", () => { - const staged = stagedRevision(); - const inspected = { - ...staged, - phase: "inspected", - inspection: { - _tag: "ready", - inspectedAt: "2026-07-21T08:01:00.000Z", - policyVersion: "source-inspection-v1", - evidence: standaloneInspectionEvidence(), - warnings: [], - }, - }; - const attested = { - ...inspected, - phase: "attested", - attestation: { - _tag: "attested", - binding: { _tag: "standalone", rightsClaimId: ids.claim }, - attestedAt: "2026-07-21T08:02:00.000Z", - }, - }; - const authorized = { - ...attested, - phase: "authorized", - authorization: { - _tag: "authorized", - authorizationBindingId: ids.authorizationBinding, - authorizationId: ids.authorization, - authorizedAt: "2026-07-21T08:03:00.000Z", - }, - }; - - for (const revision of [staged, inspected, attested, authorized]) { - expect(Schema.decodeUnknownSync(DistributionSourceRevision)(revision).phase).toBe( - revision.phase, - ); - } - expectDecodeFailure(DistributionSourceRevision, { - ...staged, - phase: "authorized", - authorization: authorized.authorization, - }); - expectDecodeFailure(DistributionSourceRevision, { - ...attested, - inspection: { - _tag: "blocked", - inspectedAt: "2026-07-21T08:01:00.000Z", - policyVersion: "source-inspection-v1", - issues: ["invalid SKILL.md"], - }, - }); - }); - - it("bounds object resolutions to HTTPS GET URLs lasting at most fifteen minutes", () => { - const resolution = { - sourceRevisionId: ids.revision, - target: { _tag: "root" }, - objectId: ids.rootObject, - sourceObjectSha256: "a".repeat(64), - method: "GET", - url: "https://objects.selftune.dev/signed/root", - issuedAt: "2026-07-21T08:00:00.000Z", - expiresAt: "2026-07-21T08:15:00.000Z", - }; - expect(Schema.decodeUnknownSync(DistributionSourceObjectResolution)(resolution).method).toBe( - "GET", - ); - expectDecodeFailure(DistributionSourceObjectResolution, { - ...resolution, - expiresAt: "2026-07-21T08:15:00.001Z", - }); - expectDecodeFailure(DistributionSourceObjectResolution, { - ...resolution, - url: "http://objects.selftune.dev/root", - }); - }); - - it("represents the server-issued join from an existing authorization to the staging UUID", () => { - const binding = Schema.decodeUnknownSync(DistributionSourceAuthorizationBinding)({ - id: ids.authorizationBinding, - organizationId: ids.organization, - sourceStagingRevisionId: ids.revision, - distributionAuthorizationId: ids.authorization, - subject: standaloneSource().subject, - sourceObjectSha256: standaloneSource().object.sourceObjectSha256, - attestation: { _tag: "standalone", rightsClaimId: ids.claim }, - components: [], - issuedAt: "2026-07-21T08:02:00.000Z", - }); - expect(binding.sourceStagingRevisionId).toBe(ids.revision); - expect(binding.distributionAuthorizationId).toBe(ids.authorization); - expectDecodeFailure(DistributionSourceAuthorizationBinding, { - ...binding, - sourceStagingRevisionId: "not-a-uuid", - }); - }); - - it("exposes strict tagged failures and rejects excess security-boundary fields", () => { - const failure = Schema.decodeUnknownSync(DistributionSourceFailureSchema)({ - _tag: "DistributionSourceObjectHashMismatch", - objectId: ids.rootObject, - expectedSha256: "a".repeat(64), - actualSha256: "b".repeat(64), - message: "Object hash changed.", - }); - expect(failure._tag).toBe("DistributionSourceObjectHashMismatch"); - expect(() => - Schema.decodeUnknownSync(StageDistributionSourceRevision)( - { - context: context(), - source: standaloneSource(), - registryVersion: "1.2.3", - }, - DistributionSecurityBoundaryParseOptions, - ), - ).toThrow(); - }); -}); diff --git a/packages/api-contract/tests/distribution.test.ts b/packages/api-contract/tests/distribution.test.ts deleted file mode 100644 index a559a721..00000000 --- a/packages/api-contract/tests/distribution.test.ts +++ /dev/null @@ -1,892 +0,0 @@ -import { Effect, Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - AgentSkillsValidationFailed, - DistributionAuthorization, - DistributionAuthorizationExpired, - DistributionChannelSchema, - DistributionContentChanged, - DistributionDecision, - DistributionFailureSchema, - DistributionMaterializationConflict, - DistributionPackageTooLarge, - DistributionRecipientSchema, - DistributionScopeNotAttested, - DistributionSourceObjectChanged, - DistributionSubjectNotFound, - InvalidLicenseExpression, - LicenseEvidence, - LicenseFileHashMismatch, - ManualLicenseReviewRequired, - MissingLicense, - MissingLicenseFile, - RightsClaim, - RightsUnverified, - Sha256Schema, - SkillSetComponentBlocked, - TelemetryEnabled, - TelemetryNotAuthorized, - TelemetryOwnerMismatch, - TelemetryUnconfigured, - decodeDistributionAuthorization, -} from "../index"; - -const ids = { - organization: "550e8400-e29b-41d4-a716-446655440000", - actor: "550e8400-e29b-41d4-a716-446655440001", - claim: "550e8400-e29b-41d4-a716-446655440002", - distribution: "550e8400-e29b-41d4-a716-446655440003", - authorization: "550e8400-e29b-41d4-a716-446655440004", - authorizationRequest: "550e8400-e29b-41d4-a716-446655440007", - recipient: "550e8400-e29b-41d4-a716-446655440005", - alternateOrganization: "550e8400-e29b-41d4-a716-446655440006", -}; - -const timestamps = { - attested: "2026-07-20T00:00:00.000Z", - assessed: "2026-07-20T00:01:00.000Z", - authorized: "2026-07-20T00:02:00.000Z", - expires: "2026-07-20T00:17:00.000Z", -}; - -function makeFixtures() { - const sourceRevisionHash = "c".repeat(64); - const subject = { - _tag: "skill_revision", - skillRevisionId: "revision-1", - sourceRevisionHash, - }; - const licenseEvidence = { - sourceRevisionHash, - expression: "MIT", - kind: "spdx", - policyDisposition: "automated_approved", - filePath: "LICENSE", - fileSha256: "d".repeat(64), - noticePaths: ["NOTICE"], - }; - const rightsClaim = { - id: ids.claim, - organizationId: ids.organization, - subject, - evidence: { _tag: "standalone_license", licenseEvidence }, - rightsHolder: { _tag: "organization", organizationId: ids.organization }, - provenanceKind: "selftune_authored", - sourceRepository: null, - sourceRef: null, - sourceTreeHash: null, - scopes: { - redistribute: true, - modify: true, - enableContributorSignals: true, - }, - attestedChannels: ["registry_public", "recipient_scoped_private_share"], - attestedBy: ids.actor, - attestedAt: timestamps.attested, - attestationTermsVersion: "distribution-profile-v1", - verificationState: "self_attested", - verifiedBy: null, - verifiedAt: null, - reviewEvidence: null, - reviewPolicyVersion: null, - supersedesClaimId: null, - createdAt: timestamps.attested, - }; - const decision = { - subject, - channel: "registry_public", - status: "ready", - licenseEvidence, - rightsClaim, - blockers: [], - warnings: [], - policyVersion: "distribution-profile-v1", - assessedAt: timestamps.assessed, - }; - const authorization = { - id: ids.authorization, - distributionId: ids.distribution, - authorizationRequestId: ids.authorizationRequest, - organizationId: ids.organization, - subject, - sourceRevisionHash, - sourceObjectSha256: "a".repeat(64), - bindingSha256: "b".repeat(64), - channel: "registry_public", - intent: { _tag: "registry_public" }, - rightsClaimId: ids.claim, - decision, - transform: { - name: "selftune-distribution-package", - version: "1", - includesFeedbackArtifacts: false, - }, - packagedSha256: "e".repeat(64), - authorizedBy: ids.actor, - authorizedAt: timestamps.authorized, - expiresAt: timestamps.expires, - }; - return { - authorization, - decision, - licenseEvidence, - rightsClaim, - sourceRevisionHash, - subject, - }; -} - -function makeEnabledTelemetry(configuredAt: string = timestamps.attested) { - return { - _tag: "enabled", - recipientOrganizationId: ids.organization, - capability: { - version: 1, - allowedSignals: ["trigger", "grade", "miss_category"], - wireFields: { - trigger: ["triggered", "invocation_type", "miss_detected"], - grade: ["execution_grade"], - miss_category: ["query_bucket"], - }, - }, - configuredBy: ids.actor, - configuredAt, - }; -} - -function expectDecodeFailure(schema: Schema.Decoder, input: unknown): void { - expect(() => Schema.decodeUnknownSync(schema)(input)).toThrow(); -} - -describe("distribution contract public interface", () => { - it("exports canonical hashes and exactly the policy-matrix channels", () => { - const digest = "a".repeat(64); - const channels = [ - "local_authoring", - "same_org_private_backup", - "workspace_discovery_install", - "recipient_scoped_private_share", - "registry_org_bundle", - "registry_unlisted", - "registry_public", - "portable_skill_set_export", - ]; - - expect(Schema.decodeUnknownSync(Sha256Schema)(digest)).toBe(digest); - expectDecodeFailure(Sha256Schema, digest.toUpperCase()); - expect( - channels.map((channel) => Schema.decodeUnknownSync(DistributionChannelSchema)(channel)), - ).toEqual(channels); - expectDecodeFailure(DistributionChannelSchema, "community_bundle"); - }); - - it("models email-bound and email-less private recipients distinctly", () => { - const email = Schema.decodeUnknownSync(DistributionRecipientSchema)({ - _tag: "email_hash", - emailSha256: "a".repeat(64), - }); - const bearer = Schema.decodeUnknownSync(DistributionRecipientSchema)({ - _tag: "bearer_claim", - claimSecretSha256: "b".repeat(64), - }); - - expect(email._tag).toBe("email_hash"); - expect(bearer._tag).toBe("bearer_claim"); - expectDecodeFailure(DistributionRecipientSchema, { - _tag: "bearer_claim", - emailSha256: "b".repeat(64), - }); - }); - - it("decodes a fully bound decision and authorization with telemetry off by default", () => { - const fixture = makeFixtures(); - const license = Schema.decodeUnknownSync(LicenseEvidence)(fixture.licenseEvidence); - const claim = Schema.decodeUnknownSync(RightsClaim)(fixture.rightsClaim); - const decision = Schema.decodeUnknownSync(DistributionDecision)(fixture.decision); - const authorization = Schema.decodeUnknownSync(DistributionAuthorization)( - fixture.authorization, - ); - - expect(license.sourceRevisionHash).toBe(fixture.sourceRevisionHash); - expect(claim.subject.sourceRevisionHash).toBe(fixture.sourceRevisionHash); - expect(decision.telemetryEntitlement).toBeInstanceOf(TelemetryUnconfigured); - expect(authorization.intent._tag).toBe("registry_public"); - expect(authorization.packagedSha256).toBe("e".repeat(64)); - }); - - it("keeps local authoring and same-org backup advisory when license evidence is missing", () => { - const fixture = makeFixtures(); - for (const channel of ["local_authoring", "same_org_private_backup"]) { - const decision = Schema.decodeUnknownSync(DistributionDecision)({ - ...fixture.decision, - channel, - licenseEvidence: null, - rightsClaim: null, - }); - expect(decision.status).toBe("ready"); - } - }); - - it("rejects invalid identifiers, timestamps, paths, license evidence, and provenance", () => { - const fixture = makeFixtures(); - const cases = [ - { schema: RightsClaim, input: { ...fixture.rightsClaim, id: "claim-1" } }, - { - schema: DistributionAuthorization, - input: { ...fixture.authorization, id: "authorization-1" }, - }, - { - schema: RightsClaim, - input: { - ...fixture.rightsClaim, - attestedAt: "2026-07-20T03:00:00+03:00", - }, - }, - { - schema: LicenseEvidence, - input: { ...fixture.licenseEvidence, filePath: "../LICENSE" }, - }, - { - schema: LicenseEvidence, - input: { - ...fixture.licenseEvidence, - noticePaths: ["NOTICE", "NOTICE"], - }, - }, - { - schema: LicenseEvidence, - input: { - ...fixture.licenseEvidence, - expression: "MIT AND", - policyDisposition: "manual_review_required", - }, - }, - { - schema: LicenseEvidence, - input: { - ...fixture.licenseEvidence, - expression: "Fake-License-1.0", - policyDisposition: "manual_review_required", - }, - }, - { - schema: LicenseEvidence, - input: { - ...fixture.licenseEvidence, - expression: "Apache-2.0 WITH Fake-exception", - policyDisposition: "manual_review_required", - }, - }, - { - schema: LicenseEvidence, - input: { - ...fixture.licenseEvidence, - kind: "license_ref", - expression: "Custom", - }, - }, - { - schema: LicenseEvidence, - input: { - ...fixture.licenseEvidence, - kind: "proprietary", - expression: "Proprietary", - filePath: null, - fileSha256: null, - }, - }, - { - schema: RightsClaim, - input: { - ...fixture.rightsClaim, - provenanceKind: "github_verified", - sourceRepository: "https://github.com/selftune-dev/selftune", - sourceRef: "main", - sourceTreeHash: null, - }, - }, - { - schema: RightsClaim, - input: { - ...fixture.rightsClaim, - evidence: { - _tag: "standalone_license", - licenseEvidence: { - ...fixture.licenseEvidence, - sourceRevisionHash: "f".repeat(64), - }, - }, - }, - }, - { - schema: RightsClaim, - input: { ...fixture.rightsClaim, verificationState: "rejected" }, - }, - { - schema: RightsClaim, - input: { - ...fixture.rightsClaim, - verificationState: "manually_verified", - verifiedBy: ids.actor, - }, - }, - { - schema: RightsClaim, - input: { - ...fixture.rightsClaim, - verifiedBy: ids.actor, - verifiedAt: timestamps.attested, - reviewEvidence: "ticket:LEGAL-1", - reviewPolicyVersion: "legal-review-v1", - }, - }, - ]; - - for (const entry of cases) expectDecodeFailure(entry.schema, entry.input); - - expect( - Schema.decodeUnknownSync(LicenseEvidence)({ - ...fixture.licenseEvidence, - expression: "Apache-2.0 WITH LLVM-exception", - policyDisposition: "manual_review_required", - }).expression, - ).toBe("Apache-2.0 WITH LLVM-exception"); - expect( - Schema.decodeUnknownSync(LicenseEvidence)({ - ...fixture.licenseEvidence, - kind: "license_ref", - expression: "LicenseRef-Acme-Commercial", - policyDisposition: "manual_review_required", - }).kind, - ).toBe("license_ref"); - expect( - Schema.decodeUnknownSync(RightsClaim)({ - ...fixture.rightsClaim, - verificationState: "manually_verified", - verifiedBy: ids.actor, - verifiedAt: timestamps.attested, - reviewEvidence: "ticket:LEGAL-1", - reviewPolicyVersion: "legal-review-v1", - }).verificationState, - ).toBe("manually_verified"); - }); - - it("fails closed for compound SPDX expressions and exceptions", () => { - const fixture = makeFixtures(); - const expressionsRequiringReview = [ - "MIT WITH LLVM-exception", - "MIT AND Apache-2.0", - "MIT OR Apache-2.0", - "MIT+", - ]; - - for (const expression of expressionsRequiringReview) { - expect( - Schema.decodeUnknownSync(LicenseEvidence)({ - ...fixture.licenseEvidence, - expression, - policyDisposition: "manual_review_required", - }).expression, - ).toBe(expression); - expectDecodeFailure(LicenseEvidence, { - ...fixture.licenseEvidence, - expression, - policyDisposition: "automated_approved", - }); - } - - for (const expression of ["mit and apache-2.0", "MIT and Apache-2.0", "MIT AnD Apache-2.0"]) { - expectDecodeFailure(LicenseEvidence, { - ...fixture.licenseEvidence, - expression, - policyDisposition: "manual_review_required", - }); - } - }); - - it("retains valid non-allowlisted SPDX evidence and gates readiness on manual approval", () => { - const fixture = makeFixtures(); - const reviewRequiredLicense = { - ...fixture.licenseEvidence, - expression: "GPL-3.0-only", - policyDisposition: "manual_review_required", - }; - const decodedLicense = Schema.decodeUnknownSync(LicenseEvidence)(reviewRequiredLicense); - - expect(decodedLicense.kind).toBe("spdx"); - expect(decodedLicense.expression).toBe("GPL-3.0-only"); - expect(decodedLicense.policyDisposition).toBe("manual_review_required"); - expectDecodeFailure(LicenseEvidence, { - ...reviewRequiredLicense, - policyDisposition: "automated_approved", - }); - expectDecodeFailure(DistributionDecision, { - ...fixture.decision, - licenseEvidence: reviewRequiredLicense, - rightsClaim: { - ...fixture.rightsClaim, - evidence: { - _tag: "standalone_license", - licenseEvidence: reviewRequiredLicense, - }, - }, - }); - - const manuallyApprovedLicense = { - ...reviewRequiredLicense, - policyDisposition: "manually_approved", - }; - const manuallyVerifiedClaim = { - ...fixture.rightsClaim, - evidence: { - _tag: "standalone_license", - licenseEvidence: manuallyApprovedLicense, - }, - verificationState: "manually_verified", - verifiedBy: ids.actor, - verifiedAt: timestamps.attested, - reviewEvidence: "ticket:LEGAL-2", - reviewPolicyVersion: "legal-review-v1", - }; - expectDecodeFailure(DistributionDecision, { - ...fixture.decision, - licenseEvidence: manuallyApprovedLicense, - rightsClaim: { - ...fixture.rightsClaim, - evidence: { - _tag: "standalone_license", - licenseEvidence: manuallyApprovedLicense, - }, - }, - }); - expect( - Schema.decodeUnknownSync(DistributionDecision)({ - ...fixture.decision, - licenseEvidence: manuallyApprovedLicense, - rightsClaim: manuallyVerifiedClaim, - }).status, - ).toBe("ready"); - }); - - it("binds relay-v1 capability categories to deterministic privacy-safe wire fields", () => { - const base = makeEnabledTelemetry(); - const enabled = Schema.decodeUnknownSync(TelemetryEnabled)(base); - - expect(enabled.capability.version).toBe(1); - expect(enabled.capability.allowedSignals).toEqual(["trigger", "grade", "miss_category"]); - expect(enabled.capability.wireFields).toMatchObject({ - trigger: ["triggered", "invocation_type", "miss_detected"], - grade: ["execution_grade"], - miss_category: ["query_bucket"], - }); - for (const input of [ - { ...base, capability: { ...base.capability, version: 2 } }, - { ...base, recipientOrganizationId: "org-1" }, - { - ...base, - capability: { - ...base.capability, - allowedSignals: ["trigger", "trigger"], - }, - }, - { - ...base, - capability: { ...base.capability, allowedSignals: ["prompt_text"] }, - }, - { - ...base, - capability: { ...base.capability, allowedSignals: ["transcript_path"] }, - }, - { - ...base, - capability: { - ...base.capability, - wireFields: { ...base.capability.wireFields, grade: ["raw_grade"] }, - }, - }, - ]) { - expectDecodeFailure(TelemetryEnabled, input); - } - }); - - it("rejects inconsistent and non-ready distribution decisions", () => { - const fixture = makeFixtures(); - const otherHash = "f".repeat(64); - const otherSubject = { ...fixture.subject, sourceRevisionHash: otherHash }; - const otherLicense = { - ...fixture.licenseEvidence, - sourceRevisionHash: otherHash, - }; - const otherClaim = { - ...fixture.rightsClaim, - subject: otherSubject, - evidence: { _tag: "standalone_license", licenseEvidence: otherLicense }, - }; - const blocker = { - code: "MissingLicense", - message: "A license is required.", - }; - const cases = [ - { ...fixture.decision, subject: otherSubject }, - { ...fixture.decision, licenseEvidence: otherLicense }, - { ...fixture.decision, rightsClaim: otherClaim }, - { ...fixture.decision, status: "not_ready_for_distribution" }, - { - ...fixture.decision, - rightsClaim: { ...fixture.rightsClaim, verificationState: "rejected" }, - }, - { - ...fixture.decision, - rightsClaim: { - ...fixture.rightsClaim, - scopes: { ...fixture.rightsClaim.scopes, redistribute: false }, - }, - }, - { - ...fixture.decision, - rightsClaim: { - ...fixture.rightsClaim, - attestedChannels: ["registry_unlisted"], - }, - }, - { - ...fixture.decision, - rightsClaim: { ...fixture.rightsClaim, createdAt: timestamps.expires }, - }, - { - ...fixture.decision, - telemetryEntitlement: makeEnabledTelemetry(timestamps.expires), - }, - { - ...fixture.decision, - telemetryEntitlement: makeEnabledTelemetry(), - rightsClaim: { - ...fixture.rightsClaim, - scopes: { - ...fixture.rightsClaim.scopes, - enableContributorSignals: false, - }, - }, - }, - { - ...fixture.decision, - telemetryEntitlement: makeEnabledTelemetry(), - rightsClaim: { - ...fixture.rightsClaim, - scopes: { ...fixture.rightsClaim.scopes, modify: false }, - }, - }, - { - ...fixture.decision, - status: "not_ready_for_distribution", - blockers: [blocker, blocker], - }, - ]; - - for (const input of cases) expectDecodeFailure(DistributionDecision, input); - }); - - it("binds authorization to its ready decision, exact hash, channel, claim, intent, and expiry", () => { - const fixture = makeFixtures(); - const blocker = { - code: "MissingLicense", - message: "A license is required.", - }; - const telemetryAuthorization = Schema.decodeUnknownSync(DistributionAuthorization)({ - ...fixture.authorization, - transform: { - ...fixture.authorization.transform, - includesFeedbackArtifacts: true, - }, - decision: { - ...fixture.decision, - telemetryEntitlement: makeEnabledTelemetry(), - }, - }); - expect(telemetryAuthorization.transform.includesFeedbackArtifacts).toBe(true); - - const cases = [ - { ...fixture.authorization, sourceRevisionHash: "f".repeat(64) }, - { ...fixture.authorization, channel: "registry_unlisted" }, - { ...fixture.authorization, rightsClaimId: ids.alternateOrganization }, - { ...fixture.authorization, intent: { _tag: "registry_unlisted" } }, - { ...fixture.authorization, expiresAt: timestamps.authorized }, - { - ...fixture.authorization, - decision: { ...fixture.decision, assessedAt: timestamps.expires }, - }, - { - ...fixture.authorization, - transform: { - ...fixture.authorization.transform, - includesFeedbackArtifacts: true, - }, - }, - { - ...fixture.authorization, - decision: { - ...fixture.decision, - status: "not_ready_for_distribution", - blockers: [blocker], - }, - }, - { - ...fixture.authorization, - decision: { - ...fixture.decision, - telemetryEntitlement: makeEnabledTelemetry(), - }, - }, - { - ...fixture.authorization, - transform: { - ...fixture.authorization.transform, - includesFeedbackArtifacts: true, - }, - decision: { - ...fixture.decision, - telemetryEntitlement: makeEnabledTelemetry(), - rightsClaim: { - ...fixture.rightsClaim, - scopes: { ...fixture.rightsClaim.scopes, modify: false }, - }, - }, - }, - ]; - - for (const input of cases) expectDecodeFailure(DistributionAuthorization, input); - }); - - it("requires typed private-share recipients and strict security-boundary decoding", async () => { - const fixture = makeFixtures(); - const privateDecision = { - ...fixture.decision, - channel: "recipient_scoped_private_share", - }; - const privateAuthorization = { - ...fixture.authorization, - channel: "recipient_scoped_private_share", - intent: { - _tag: "recipient_scoped_private_share", - recipient: { _tag: "user", userId: ids.recipient }, - }, - decision: privateDecision, - }; - - expect( - Schema.decodeUnknownSync(DistributionAuthorization)(privateAuthorization).intent._tag, - ).toBe("recipient_scoped_private_share"); - expectDecodeFailure(DistributionAuthorization, { - ...privateAuthorization, - intent: { - _tag: "recipient_scoped_private_share", - recipient: { _tag: "user", userId: "not-a-uuid" }, - }, - }); - await expect( - Effect.runPromise( - decodeDistributionAuthorization({ - ...fixture.authorization, - unsignedExtra: true, - }), - ), - ).rejects.toThrow(); - }); - - it("decodes every tagged failure shape and exposes its HTTP status", () => { - const fixture = makeFixtures(); - const skillSetSubject = { - _tag: "skill_set", - skillSetId: "set-1", - sourceRevisionHash: fixture.sourceRevisionHash, - }; - const blocker = { - code: "MissingLicense", - message: "A license is required.", - }; - const errors = [ - { - schema: DistributionSubjectNotFound, - status: 404, - input: { - _tag: "DistributionSubjectNotFound", - subject: fixture.subject, - message: "missing", - }, - }, - { - schema: DistributionContentChanged, - status: 409, - input: { - _tag: "DistributionContentChanged", - subject: fixture.subject, - expectedSourceRevisionHash: fixture.sourceRevisionHash, - actualSourceRevisionHash: "f".repeat(64), - message: "changed", - }, - }, - { - schema: DistributionSourceObjectChanged, - status: 409, - input: { - _tag: "DistributionSourceObjectChanged", - subject: fixture.subject, - expectedSourceObjectSha256: "a".repeat(64), - actualSourceObjectSha256: "f".repeat(64), - message: "object changed", - }, - }, - { - schema: DistributionPackageTooLarge, - status: 413, - input: { - _tag: "DistributionPackageTooLarge", - subject: fixture.subject, - dimension: "file_count", - actual: 501, - limit: 500, - message: "too large", - }, - }, - { - schema: DistributionAuthorizationExpired, - status: 410, - input: { - _tag: "DistributionAuthorizationExpired", - authorizationId: ids.authorization, - expiredAt: timestamps.expires, - message: "expired", - }, - }, - { - schema: DistributionMaterializationConflict, - status: 409, - input: { - _tag: "DistributionMaterializationConflict", - authorizationId: ids.authorization, - message: "conflict", - }, - }, - { - schema: AgentSkillsValidationFailed, - status: 422, - input: { - _tag: "AgentSkillsValidationFailed", - subject: fixture.subject, - issues: ["invalid SKILL.md"], - message: "invalid", - }, - }, - { - schema: MissingLicense, - status: 422, - input: { - _tag: "MissingLicense", - subject: fixture.subject, - message: "missing", - }, - }, - { - schema: InvalidLicenseExpression, - status: 422, - input: { - _tag: "InvalidLicenseExpression", - subject: fixture.subject, - expression: "MIT AND", - message: "invalid", - }, - }, - { - schema: MissingLicenseFile, - status: 422, - input: { - _tag: "MissingLicenseFile", - subject: fixture.subject, - filePath: "LICENSE", - message: "missing", - }, - }, - { - schema: LicenseFileHashMismatch, - status: 409, - input: { - _tag: "LicenseFileHashMismatch", - subject: fixture.subject, - filePath: "LICENSE", - expectedHash: "a".repeat(64), - actualHash: "b".repeat(64), - message: "mismatch", - }, - }, - { - schema: RightsUnverified, - status: 403, - input: { - _tag: "RightsUnverified", - subject: fixture.subject, - rightsClaimId: ids.claim, - message: "unverified", - }, - }, - { - schema: DistributionScopeNotAttested, - status: 403, - input: { - _tag: "DistributionScopeNotAttested", - rightsClaimId: ids.claim, - channel: "registry_public", - message: "not attested", - }, - }, - { - schema: ManualLicenseReviewRequired, - status: 422, - input: { - _tag: "ManualLicenseReviewRequired", - subject: fixture.subject, - reason: "custom terms", - message: "review", - }, - }, - { - schema: TelemetryOwnerMismatch, - status: 409, - input: { - _tag: "TelemetryOwnerMismatch", - subject: fixture.subject, - claimedRecipientOrganizationId: ids.alternateOrganization, - authorizedRecipientOrganizationId: ids.organization, - message: "mismatch", - }, - }, - { - schema: TelemetryNotAuthorized, - status: 403, - input: { - _tag: "TelemetryNotAuthorized", - subject: fixture.subject, - channel: "registry_public", - message: "not authorized", - }, - }, - { - schema: SkillSetComponentBlocked, - status: 424, - input: { - _tag: "SkillSetComponentBlocked", - skillSetId: skillSetSubject.skillSetId, - component: fixture.subject, - blocker, - message: "component blocked", - }, - }, - ]; - - for (const entry of errors) { - const decoded = Schema.decodeUnknownSync(DistributionFailureSchema)(entry.input); - expect(decoded._tag).toBe(entry.input._tag); - expect(entry.schema.ast.annotations?.httpApiStatus).toBe(entry.status); - } - }); -}); diff --git a/packages/api-contract/tests/github.test.ts b/packages/api-contract/tests/github.test.ts deleted file mode 100644 index 47903b95..00000000 --- a/packages/api-contract/tests/github.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { CloudGithubStatus, createCloudApiClient, decodeUnknown } from "../index"; - -describe("cloud GitHub contract", () => { - it("validates a tenant-scoped status projection", () => { - expect( - decodeUnknown(CloudGithubStatus, { - installations: [], - connections: [], - canManageConnections: true, - }).success, - ).toBe(true); - }); - - it("uses only the maintained cloud GitHub paths", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async (input, init) => { - requests.push(new Request(input, init)); - if (new Request(input, init).url.includes("/sync")) - return Response.json({ - connectionId: "connection-1", - status: "published", - version: "v1", - sourceRef: "main", - publishedAt: "2026-07-24T00:00:00.000Z", - message: "Synced", - }); - if (new Request(input, init).url.includes("/install/start")) - return Response.json({ - url: "https://github.com/apps/selftune/installations/new?state=signed", - }); - return Response.json({ installations: [], connections: [], canManageConnections: true }); - }, - }); - await client.githubStatus(); - await client.startGithubInstall(); - await client.syncGithubConnection("connection-1"); - expect(requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`)).toEqual([ - "GET /api/v1/cloud/github", - "POST /api/v1/cloud/github/install/start", - "POST /api/v1/cloud/github/connections/connection-1/sync", - ]); - }); -}); diff --git a/packages/api-contract/tests/install-credentials.test.ts b/packages/api-contract/tests/install-credentials.test.ts deleted file mode 100644 index 8cc20538..00000000 --- a/packages/api-contract/tests/install-credentials.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { decodeUnknown } from "../index"; -import { DesktopInstallFinalizeRequestSchema } from "../src/install-credentials"; - -const uuid = "11111111-1111-4111-8111-111111111111"; -const hash = (value: string) => value.repeat(64); -const bootstrapToken = "B".repeat(43); - -describe("desktop install finalization contracts", () => { - const base = { - bootstrapToken, - distributionId: uuid, - sealedPackageSha256: hash("a"), - pseudonymousInstallKey: hash("c"), - receiptEvidenceSha256: hash("d"), - }; - - it("keeps sender-visible installed status on a separate explicit consent", () => { - expect( - decodeUnknown(DesktopInstallFinalizeRequestSchema, { - ...base, - lifecycleReporting: { - _tag: "installed_status", - lifecycleDisclosureSha256: hash("f"), - consent: "not_granted", - senderVisibleInstalledStatus: "disabled", - }, - }).success, - ).toBe(true); - expect( - decodeUnknown(DesktopInstallFinalizeRequestSchema, { - ...base, - lifecycleReporting: { - _tag: "installed_status", - lifecycleDisclosureSha256: hash("f"), - consent: "not_granted", - senderVisibleInstalledStatus: "enabled", - }, - }).success, - ).toBe(false); - }); - - it("rejects raw local details", () => { - expect( - decodeUnknown(DesktopInstallFinalizeRequestSchema, { - ...base, - targetPath: "/private/project/.agents/skills/review-helper", - lifecycleReporting: { - _tag: "installed_status", - lifecycleDisclosureSha256: hash("f"), - consent: "not_granted", - senderVisibleInstalledStatus: "disabled", - }, - }).success, - ).toBe(false); - }); -}); diff --git a/packages/api-contract/tests/recipient-actions-client.test.ts b/packages/api-contract/tests/recipient-actions-client.test.ts deleted file mode 100644 index 273125a0..00000000 --- a/packages/api-contract/tests/recipient-actions-client.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - RecipientAccountRequired, - RecipientActionConflict, - RecipientActionExpired, - RecipientActionForbidden, - RecipientActionInvalid, - RecipientActionReplay, - RecipientActionApi, - RecipientDesktopInstallBootstrapRequestSchema, - RecipientDesktopInstallPreviewRequestSchema, - RecipientPortableDownloadRequestSchema, - RecipientUseOnceConsumeRequestSchema, - RecipientUseOnceIssueRequestSchema, - RecipientUseOncePreviewRequestSchema, - UtcTimestampSchema, - createCloudApiClient, -} from "../index"; - -const requestId = "10000000-0000-4000-8000-000000000001"; -const invitationId = "20000000-0000-4000-8000-000000000002"; -const shareId = "30000000-0000-4000-8000-000000000003"; -const distributionId = "40000000-0000-4000-8000-000000000004"; -const sealedObjectId = "50000000-0000-4000-8000-000000000005"; -const authorizationId = "60000000-0000-4000-8000-000000000006"; -const signalRecipientOrganizationId = "70000000-0000-4000-8000-000000000007"; -const issueId = "80000000-0000-4000-8000-000000000008"; -const packageHash = "a".repeat(64); -const termsHash = "b".repeat(64); -const signalsHash = "c".repeat(64); -const lifecycleHash = "d".repeat(64); -const handoffToken = "A".repeat(43); -const issuedAt = "2026-07-21T12:00:00.000Z"; -const expiresAt = "2026-07-21T12:05:00.000Z"; - -const binding = { - invitationId, - shareId, - distributionId, - sealedObjectId, - packagedSha256: packageHash, -}; -const terms = { termsDisclosureSha256: termsHash, termsAcceptance: "accepted" } as const; -const contributorSignals = { - _tag: "capable_default_off", - signalDisclosureSha256: signalsHash, - signalRecipientOrganizationId, - allowedFields: ["trigger", "grade"], - capability: "capable", - defaultState: "off", - contributorConsent: "not_granted", - enabled: false, -} as const; -const lifecycleReporting = { - _tag: "downloaded_status", - lifecycleDisclosureSha256: lifecycleHash, - consent: "not_granted", - senderVisibleDownloadedStatus: "disabled", -} as const; -const useOnceLifecycleReporting = { - _tag: "used_once_status", - lifecycleDisclosureSha256: lifecycleHash, - consent: "not_granted", - senderVisibleUsedOnceStatus: "disabled", -} as const; -const download = Schema.decodeUnknownSync(RecipientPortableDownloadRequestSchema)({ - requestId, - ...binding, - ...terms, - recipientAccess: "accountless", - contributorSignals, - lifecycleReporting, -}); -const issueUseOnce = Schema.decodeUnknownSync(RecipientUseOnceIssueRequestSchema)({ - requestId, - ...binding, - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - supportedAgent: "codex", - executionConsent: "granted", - recipientAccess: "accountless", -}); -const consumeUseOnce = Schema.decodeUnknownSync(RecipientUseOnceConsumeRequestSchema)({ - requestId, - handoffToken, - expectedIssueId: issueId, - expectedInvitationId: invitationId, - expectedShareId: shareId, - expectedDistributionId: distributionId, - expectedSealedObjectId: sealedObjectId, - expectedPackagedSha256: packageHash, - supportedAgent: "codex", - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - executionConsent: "granted", -}); -const bootstrapDesktop = Schema.decodeUnknownSync(RecipientDesktopInstallBootstrapRequestSchema)({ - requestId, - ...binding, - ...terms, - contributorSignals, -}); -const previewDesktop = Schema.decodeUnknownSync(RecipientDesktopInstallPreviewRequestSchema)({ - bootstrapToken: handoffToken, -}); -const previewUseOnce = Schema.decodeUnknownSync(RecipientUseOncePreviewRequestSchema)({ - handoffToken, - supportedAgent: "codex", -}); -const helperPreviewResponse = { - status: "preview", - issueId, - ...binding, - supportedAgent: "codex", - issuedAt, - expiresAt, - package: { - displayName: "review-helper", - version: packageHash, - format: "selftune-portable-package-v2", - }, - terms: { - disclosureSha256: termsHash, - summary: "Use once under MIT; persistent install and trusted telemetry are not authorized.", - issueAcceptance: "accepted_at_issue", - }, - publisher: { name: "Acme Skills" }, - rightsHolder: { kind: "organization", name: "Acme Skills" }, - license: { - expression: "MIT", - kind: "spdx", - licenseEvidenceSha256: "e".repeat(64), - bundledTerms: null, - }, - provenance: { - kind: "selftune_authored", - sourceRepository: null, - sourceRef: null, - sourceTreeHash: null, - }, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - helperContributorSignals: { - _tag: "portable_unverified", - signalDisclosureSha256: signalsHash, - allowedFields: ["trigger", "grade"], - defaultState: "off", - trustedTelemetry: "not_authorized", - }, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - contentRetrieval: "repeatable_exact_object_before_consume", - previewMutation: "none", - usedOnceReporting: "not_emitted", - consumeRequired: true, - authorityLimits: { - localPath: "not_provided", - command: "not_provided", - url: "not_provided", - bytes: "not_provided", - credential: "not_provided", - installAuthority: "not_authorized", - }, -} as const; - -function recordingFetch(requests: Request[]): typeof fetch { - return async (input, init) => { - const request = new Request(input, init); - requests.push(request); - const path = new URL(request.url).pathname; - if (path.endsWith("/download")) { - return Response.json({ - ...download, - status: "authorized", - downloadAuthorizationId: authorizationId, - accountlessPolicyResult: "public_allowed", - packageFormat: "selftune-portable-package-v2", - localInstall: "not_requested", - authorizedAt: issuedAt, - expiresAt, - }); - } - if (path.endsWith("/use-once/issue")) { - return Response.json({ - ...issueUseOnce, - status: "issued", - issueId, - handoffToken, - accountlessPolicyResult: "public_allowed", - issuedAt, - expiresAt, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - }); - } - if (path.endsWith("/use-once/preview")) { - return Response.json(helperPreviewResponse); - } - if (path.endsWith("/use-once/consume")) { - return Response.json({ - ...binding, - requestId, - issueId, - supportedAgent: "codex", - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - executionConsent: "granted", - recipientAccess: "accountless", - accountlessPolicyResult: "public_allowed", - status: "consumed", - consumedAt: issuedAt, - expiresAt, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - }); - } - if (path.endsWith("/desktop/bootstrap")) { - return Response.json({ - ...bootstrapDesktop, - status: "issued", - bootstrapToken: handoffToken, - issuedAt, - expiresAt, - }); - } - return Response.json({ - ...binding, - ...terms, - contributorSignals, - status: "preview", - expiresAt, - supportedTargetAgents: ["codex", "claude_code"], - targetAgentSelectionRequired: true, - scopeChoices: ["project", "global"], - scopeSelectionRequired: true, - installModeDefault: "copy", - conflictPolicyChoices: ["prompt", "replace", "keep_both"], - conflictPolicyDefault: "prompt", - customPathPolicy: "unsupported_v1", - automaticDesktopInstall: "not_authorized", - automaticSkillInstall: "not_authorized", - }); - }; -} - -describe("recipient action HttpApi client seam", () => { - it("publishes the recipient action group and all six host-neutral requests", async () => { - expect(RecipientActionApi.groups).toHaveProperty("recipientActions"); - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: recordingFetch(requests), - }); - - await expect(client.requestRecipientPortableDownload(download)).resolves.toMatchObject({ - accountlessPolicyResult: "public_allowed", - localInstall: "not_requested", - }); - await client.issueRecipientUseOnce(issueUseOnce); - await expect(client.previewRecipientUseOnce(previewUseOnce)).resolves.toMatchObject({ - issueId, - contentRetrieval: "repeatable_exact_object_before_consume", - trustedTelemetry: "not_authorized", - }); - await client.consumeRecipientUseOnce(consumeUseOnce); - await client.bootstrapRecipientDesktopInstall(bootstrapDesktop); - await client.previewRecipientDesktopInstall(previewDesktop); - - expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual( - [ - "POST /api/v1/recipient-actions/download", - "POST /api/v1/recipient-actions/use-once/issue", - "POST /api/v1/recipient-actions/use-once/preview", - "POST /api/v1/recipient-actions/use-once/consume", - "POST /api/v1/recipient-actions/desktop/bootstrap", - "POST /api/v1/recipient-actions/desktop/preview", - ], - ); - await expect(requests[5]?.json()).resolves.toEqual({ bootstrapToken: handoffToken }); - }); - - it.each([ - [new RecipientActionInvalid({ action: "portable_download", message: "invalid" }), 400], - [ - new RecipientAccountRequired({ - action: "portable_download", - invitationId: download.invitationId, - distributionId: download.distributionId, - policyResult: "account_required", - message: "account required", - }), - 401, - ], - [ - new RecipientAccountRequired({ - action: "use_once_issue", - invitationId: issueUseOnce.invitationId, - distributionId: issueUseOnce.distributionId, - policyResult: "account_required", - message: "account required for use once", - }), - 401, - ], - [ - new RecipientActionExpired({ - action: "portable_download", - expiredAt: Schema.decodeUnknownSync(UtcTimestampSchema)(expiresAt), - message: "expired", - }), - 410, - ], - [ - RecipientActionForbidden.make({ - action: "portable_download", - message: "recipient action unavailable", - }), - 403, - ], - [ - new RecipientActionConflict({ - action: "portable_download", - distributionId: download.distributionId, - message: "conflict", - }), - 409, - ], - [new RecipientActionReplay({ action: "use_once_consume", message: "already used" }), 409], - ] as const)("decodes typed recipient failure %s", async (failure, status) => { - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async () => Response.json(failure, { status }), - }); - - await expect(client.requestRecipientPortableDownload(download)).rejects.toMatchObject({ - kind: "http", - status, - code: failure._tag, - message: failure.message, - }); - }); -}); diff --git a/packages/api-contract/tests/recipient-actions-exports.test.ts b/packages/api-contract/tests/recipient-actions-exports.test.ts deleted file mode 100644 index 3a073d43..00000000 --- a/packages/api-contract/tests/recipient-actions-exports.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import * as root from "../index"; -import * as subpath from "@selftune/api-contract/recipient-actions"; - -describe("recipient action package exports", () => { - it("exposes the contract from the root and official package subpath", () => { - expect(root.RecipientPortableDownloadRequestSchema).toBe( - subpath.RecipientPortableDownloadRequestSchema, - ); - expect(root.RecipientUseOnceIssueRequestSchema).toBe( - subpath.RecipientUseOnceIssueRequestSchema, - ); - expect(root.RecipientUseOncePreviewResponseSchema).toBe( - subpath.RecipientUseOncePreviewResponseSchema, - ); - expect(root.RecipientDesktopInstallPreviewResponseSchema).toBe( - subpath.RecipientDesktopInstallPreviewResponseSchema, - ); - }); -}); diff --git a/packages/api-contract/tests/recipient-actions.test.ts b/packages/api-contract/tests/recipient-actions.test.ts deleted file mode 100644 index 06d30ce8..00000000 --- a/packages/api-contract/tests/recipient-actions.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { Exit, Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - RecipientDesktopDeepLinkHandoffSchema, - RecipientDesktopInstallBootstrapRequestSchema, - RecipientDesktopInstallBootstrapResponseSchema, - RecipientDesktopInstallPreviewRequestSchema, - RecipientDesktopInstallPreviewResponseSchema, - RecipientPortableDownloadRequestSchema, - RecipientPortableDownloadResponseSchema, - RecipientUseOnceConsumeRequestSchema, - RecipientUseOnceConsumeResponseSchema, - RecipientUseOnceHandoffTokenSchema, - RecipientUseOnceIssueRequestSchema, - RecipientUseOnceIssueResponseSchema, - RecipientUseOncePreviewRequestSchema, - RecipientUseOncePreviewResponseSchema, - ShareInvitationClaimTokenSchema, -} from "../index"; - -const ids = { - request: "10000000-0000-4000-8000-000000000001", - invitation: "20000000-0000-4000-8000-000000000002", - share: "30000000-0000-4000-8000-000000000003", - distribution: "40000000-0000-4000-8000-000000000004", - sealedObject: "50000000-0000-4000-8000-000000000005", - authorization: "60000000-0000-4000-8000-000000000006", - signalRecipient: "70000000-0000-4000-8000-000000000007", - issue: "80000000-0000-4000-8000-000000000008", -} as const; -const hashes = { - package: "a".repeat(64), - terms: "b".repeat(64), - signals: "c".repeat(64), - lifecycle: "d".repeat(64), -} as const; -const issuedAt = "2026-07-21T12:00:00.000Z"; -const expiresAt = "2026-07-21T12:05:00.000Z"; -const token = "A".repeat(43); - -const binding = { - invitationId: ids.invitation, - shareId: ids.share, - distributionId: ids.distribution, - sealedObjectId: ids.sealedObject, - packagedSha256: hashes.package, -} as const; -const terms = { - termsDisclosureSha256: hashes.terms, - termsAcceptance: "accepted", -} as const; -const contributorSignals = { - _tag: "capable_default_off", - signalDisclosureSha256: hashes.signals, - signalRecipientOrganizationId: ids.signalRecipient, - allowedFields: ["trigger", "grade"], - capability: "capable", - defaultState: "off", - contributorConsent: "not_granted", - enabled: false, -} as const; -const lifecycleReporting = { - _tag: "downloaded_status", - lifecycleDisclosureSha256: hashes.lifecycle, - consent: "not_granted", - senderVisibleDownloadedStatus: "disabled", -} as const; -const useOnceLifecycleReporting = { - _tag: "used_once_status", - lifecycleDisclosureSha256: hashes.lifecycle, - consent: "not_granted", - senderVisibleUsedOnceStatus: "disabled", -} as const; - -function succeeds(schema: Schema.Decoder, value: unknown): boolean { - return Exit.isSuccess(Schema.decodeUnknownExit(schema)(value)); -} - -describe("recipient action contracts", () => { - it("models authorized portable download without implying a local install", () => { - const request = { - requestId: ids.request, - ...binding, - ...terms, - recipientAccess: "accountless", - contributorSignals, - lifecycleReporting, - }; - const response = { - ...request, - status: "authorized", - downloadAuthorizationId: ids.authorization, - accountlessPolicyResult: "public_allowed", - packageFormat: "selftune-portable-package-v2", - localInstall: "not_requested", - authorizedAt: issuedAt, - expiresAt, - }; - - expect(succeeds(RecipientPortableDownloadRequestSchema, request)).toBe(true); - expect(succeeds(RecipientPortableDownloadResponseSchema, response)).toBe(true); - }); - - it("models a distinct opaque, single-use handoff with explicit consent and no install trust", () => { - const issueRequest = { - requestId: ids.request, - ...binding, - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - supportedAgent: "codex", - executionConsent: "granted", - recipientAccess: "accountless", - }; - const issueResponse = { - ...issueRequest, - status: "issued", - handoffToken: token, - issueId: ids.issue, - accountlessPolicyResult: "public_allowed", - issuedAt, - expiresAt, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - }; - const consumeRequest = { - requestId: ids.request, - handoffToken: token, - expectedIssueId: ids.issue, - expectedInvitationId: ids.invitation, - expectedShareId: ids.share, - expectedDistributionId: ids.distribution, - expectedSealedObjectId: ids.sealedObject, - expectedPackagedSha256: hashes.package, - supportedAgent: "codex", - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - executionConsent: "granted", - }; - const consumeResponse = { - ...binding, - requestId: ids.request, - issueId: ids.issue, - supportedAgent: "codex", - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - executionConsent: "granted", - recipientAccess: "accountless", - accountlessPolicyResult: "public_allowed", - status: "consumed", - consumedAt: issuedAt, - expiresAt, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - }; - - expect(RecipientUseOnceHandoffTokenSchema).not.toBe(ShareInvitationClaimTokenSchema); - expect(succeeds(RecipientUseOnceIssueRequestSchema, issueRequest)).toBe(true); - expect(succeeds(RecipientUseOnceIssueResponseSchema, issueResponse)).toBe(true); - expect(succeeds(RecipientUseOnceConsumeRequestSchema, consumeRequest)).toBe(true); - expect(succeeds(RecipientUseOnceConsumeResponseSchema, consumeResponse)).toBe(true); - }); - - it("models a read-only helper preview from only token plus agent", () => { - const request = { handoffToken: token, supportedAgent: "codex" }; - const response = { - status: "preview", - issueId: ids.issue, - ...binding, - supportedAgent: "codex", - issuedAt, - expiresAt, - package: { - displayName: "review-helper", - version: "a".repeat(64), - format: "selftune-portable-package-v2", - }, - terms: { - disclosureSha256: hashes.terms, - summary: "Use once under MIT; persistent install and trusted telemetry are not authorized.", - issueAcceptance: "accepted_at_issue", - }, - publisher: { name: "Acme Skills" }, - rightsHolder: { kind: "organization", name: "Acme Skills" }, - license: { - expression: "MIT", - kind: "spdx", - licenseEvidenceSha256: "e".repeat(64), - bundledTerms: null, - }, - provenance: { - kind: "selftune_authored", - sourceRepository: null, - sourceRef: null, - sourceTreeHash: null, - }, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - helperContributorSignals: { - _tag: "portable_unverified", - signalDisclosureSha256: hashes.signals, - allowedFields: ["trigger", "grade"], - defaultState: "off", - trustedTelemetry: "not_authorized", - }, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - contentRetrieval: "repeatable_exact_object_before_consume", - previewMutation: "none", - usedOnceReporting: "not_emitted", - consumeRequired: true, - authorityLimits: { - localPath: "not_provided", - command: "not_provided", - url: "not_provided", - bytes: "not_provided", - credential: "not_provided", - installAuthority: "not_authorized", - }, - }; - - expect(succeeds(RecipientUseOncePreviewRequestSchema, request)).toBe(true); - expect(succeeds(RecipientUseOncePreviewResponseSchema, response)).toBe(true); - expect(succeeds(RecipientUseOncePreviewRequestSchema, { ...request, issueId: ids.issue })).toBe( - false, - ); - expect( - succeeds(RecipientUseOncePreviewResponseSchema, { - ...response, - authorityLimits: { ...response.authorityLimits, url: "https://objects.example.test" }, - }), - ).toBe(false); - }); - - it("models Desktop bootstrap and preview without automatic installation or custom paths", () => { - const bootstrapRequest = { - requestId: ids.request, - ...binding, - ...terms, - contributorSignals, - }; - const bootstrapResponse = { - ...bootstrapRequest, - status: "issued", - bootstrapToken: token, - issuedAt, - expiresAt, - }; - const previewRequest = { bootstrapToken: token }; - const previewResponse = { - ...binding, - ...terms, - contributorSignals, - status: "preview", - expiresAt, - supportedTargetAgents: ["codex", "claude_code"], - targetAgentSelectionRequired: true, - scopeChoices: ["project", "global"], - scopeSelectionRequired: true, - installModeDefault: "copy", - conflictPolicyChoices: ["prompt", "replace", "keep_both"], - conflictPolicyDefault: "prompt", - customPathPolicy: "unsupported_v1", - automaticDesktopInstall: "not_authorized", - automaticSkillInstall: "not_authorized", - }; - - expect(succeeds(RecipientDesktopInstallBootstrapRequestSchema, bootstrapRequest)).toBe(true); - expect(succeeds(RecipientDesktopInstallBootstrapResponseSchema, bootstrapResponse)).toBe(true); - expect(succeeds(RecipientDesktopDeepLinkHandoffSchema, previewRequest)).toBe(true); - expect(succeeds(RecipientDesktopInstallPreviewRequestSchema, previewRequest)).toBe(true); - expect(succeeds(RecipientDesktopInstallPreviewResponseSchema, previewResponse)).toBe(true); - }); - - it("rejects path, URL, credential, package-byte, and arbitrary metadata leakage", () => { - const download = { - requestId: ids.request, - ...binding, - ...terms, - recipientAccess: "authenticated", - contributorSignals, - lifecycleReporting, - }; - for (const forbidden of [ - { localPath: "/tmp/skill" }, - { downloadUrl: "https://objects.example.test/package" }, - { authorization: "Bearer secret" }, - { packageBytes: new Uint8Array([1, 2, 3]) }, - { metadata: { arbitrary: true } }, - ]) { - expect(succeeds(RecipientPortableDownloadRequestSchema, { ...download, ...forbidden })).toBe( - false, - ); - } - expect( - succeeds(RecipientDesktopDeepLinkHandoffSchema, { - bootstrapToken: token, - distributionId: ids.distribution, - }), - ).toBe(false); - expect(succeeds(RecipientUseOnceHandoffTokenSchema, `https://example.test/${token}`)).toBe( - false, - ); - }); - - it("rejects contradictory access-policy and consent-state combinations", () => { - const downloadResponse = { - requestId: ids.request, - ...binding, - ...terms, - contributorSignals, - lifecycleReporting, - recipientAccess: "accountless", - status: "authorized", - downloadAuthorizationId: ids.authorization, - accountlessPolicyResult: "authenticated_account", - packageFormat: "selftune-portable-package-v2", - localInstall: "not_requested", - authorizedAt: issuedAt, - expiresAt, - }; - expect(succeeds(RecipientPortableDownloadResponseSchema, downloadResponse)).toBe(false); - expect( - succeeds(RecipientPortableDownloadResponseSchema, { - ...downloadResponse, - accountlessPolicyResult: "public_allowed", - contributorSignals: { - ...contributorSignals, - contributorConsent: "granted", - }, - }), - ).toBe(false); - expect( - succeeds(RecipientPortableDownloadResponseSchema, { - ...downloadResponse, - accountlessPolicyResult: "public_allowed", - lifecycleReporting: { - ...lifecycleReporting, - consent: "granted", - }, - }), - ).toBe(false); - - const issueResponse = { - requestId: ids.request, - ...binding, - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - supportedAgent: "codex", - executionConsent: "granted", - recipientAccess: "accountless", - accountlessPolicyResult: "authenticated_account", - status: "issued", - issueId: ids.issue, - handoffToken: token, - issuedAt, - expiresAt, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - }; - expect(succeeds(RecipientUseOnceIssueResponseSchema, issueResponse)).toBe(false); - - const consumeResponse = { - requestId: ids.request, - issueId: ids.issue, - supportedAgent: "codex", - ...binding, - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - executionConsent: "granted", - recipientAccess: "authenticated", - accountlessPolicyResult: "public_allowed", - status: "consumed", - consumedAt: issuedAt, - expiresAt, - persistence: "ephemeral_use_once", - persistentInstall: "not_authorized", - trustedTelemetry: "not_authorized", - }; - expect(succeeds(RecipientUseOnceConsumeResponseSchema, consumeResponse)).toBe(false); - }); - - it("requires the full use-once issue and distribution binding even for identical bytes", () => { - const consume = { - requestId: ids.request, - handoffToken: token, - expectedIssueId: ids.issue, - expectedInvitationId: ids.invitation, - expectedShareId: ids.share, - expectedDistributionId: ids.distribution, - expectedSealedObjectId: ids.sealedObject, - expectedPackagedSha256: hashes.package, - supportedAgent: "codex", - ...terms, - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - executionConsent: "granted", - }; - const { expectedDistributionId: _omitted, ...withoutDistribution } = consume; - expect(succeeds(RecipientUseOnceConsumeRequestSchema, withoutDistribution)).toBe(false); - expect( - succeeds(RecipientUseOnceConsumeRequestSchema, { - ...consume, - claimToken: token, - }), - ).toBe(false); - }); - - it("rejects lifecycle disclosures from another recipient action", () => { - expect( - succeeds(RecipientPortableDownloadRequestSchema, { - requestId: ids.request, - ...binding, - ...terms, - recipientAccess: "accountless", - contributorSignals, - lifecycleReporting: useOnceLifecycleReporting, - }), - ).toBe(false); - expect( - succeeds(RecipientUseOnceIssueRequestSchema, { - requestId: ids.request, - ...binding, - ...terms, - contributorSignals, - lifecycleReporting, - supportedAgent: "codex", - executionConsent: "granted", - recipientAccess: "accountless", - }), - ).toBe(false); - expect( - succeeds(RecipientDesktopInstallBootstrapRequestSchema, { - requestId: ids.request, - ...binding, - ...terms, - contributorSignals, - lifecycleReporting, - }), - ).toBe(false); - }); - - it("rejects noncanonical identities, hashes, timestamps, enums, and unsupported custom paths", () => { - expect( - succeeds(RecipientPortableDownloadRequestSchema, { - requestId: "not-a-uuid", - ...binding, - ...terms, - recipientAccess: "public-ish", - contributorSignals, - lifecycleReporting, - }), - ).toBe(false); - expect( - succeeds(RecipientDesktopInstallPreviewResponseSchema, { - ...binding, - ...terms, - contributorSignals, - status: "preview", - expiresAt: "2026-07-21", - supportedTargetAgents: ["unknown_agent"], - targetAgentSelectionRequired: true, - scopeChoices: ["project", "global"], - scopeSelectionRequired: true, - installModeDefault: "symlink", - conflictPolicyChoices: ["prompt", "replace", "keep_both"], - conflictPolicyDefault: "prompt", - customPathPolicy: "/custom/path", - automaticDesktopInstall: "not_authorized", - automaticSkillInstall: "not_authorized", - }), - ).toBe(false); - }); -}); diff --git a/packages/api-contract/tests/share-grants.test.ts b/packages/api-contract/tests/share-grants.test.ts deleted file mode 100644 index 592491de..00000000 --- a/packages/api-contract/tests/share-grants.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { ShareGrantIssueRequest } from "../src/share-grants"; - -const base = { - skillId: "research", - sourceRevisionHash: "a".repeat(64), - expiresAt: "2026-08-01T00:00:00.000Z", -}; - -describe("share grant contract", () => { - it("supports reusable and private copy links", () => { - for (const mode of ["reusable_unlisted", "private_single_claim"] as const) { - expect( - Schema.decodeUnknownSync(ShareGrantIssueRequest)({ - ...base, - mode, - delivery: { _tag: "copy_link" }, - }).mode, - ).toBe(mode); - } - }); - - it("allows email only for private single-claim sharing", () => { - const privateEmail = { - ...base, - mode: "private_single_claim", - delivery: { _tag: "email", recipientEmail: "person@example.com" }, - }; - expect(Schema.decodeUnknownSync(ShareGrantIssueRequest)(privateEmail).mode).toBe( - "private_single_claim", - ); - expect(() => - Schema.decodeUnknownSync(ShareGrantIssueRequest)({ - ...privateEmail, - mode: "reusable_unlisted", - }), - ).toThrow(); - }); -}); diff --git a/packages/api-contract/tests/share-invitations.test.ts b/packages/api-contract/tests/share-invitations.test.ts deleted file mode 100644 index c3396b8b..00000000 --- a/packages/api-contract/tests/share-invitations.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { Effect, Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - ShareInvitationAcceptance, - ShareInvitationAcceptanceResult, - ShareInvitationClaim, - ShareInvitationClaimResult, - ShareInvitationDisclosure, - ShareInvitationIssue, - ShareInvitationPreview, - ShareInvitationRecipientView, - ShareInvitationSenderView, - ShareInvitationUnavailable, - decodeShareInvitationClaim, -} from "../index"; - -const ids = { - invitation: "550e8400-e29b-41d4-a716-446655440010", - distribution: "550e8400-e29b-41d4-a716-446655440011", -}; -const expiresAt = "2026-07-22T00:00:00.000Z"; -const recipientAuthority = { - actionBindings: { - invitationId: ids.invitation, - distributionId: ids.distribution, - shareId: "550e8400-e29b-41d4-a716-446655440013", - sealedObjectId: "550e8400-e29b-41d4-a716-446655440014", - packagedSha256: "b".repeat(64), - }, - packageInspection: { - fileManifest: [{ path: "SKILL.md", sha256: "3".repeat(64), byteLength: 12 }], - fileManifestSha256: "4".repeat(64), - securityDecision: { - _tag: "authorized_sealed", - policyVersion: "recipient-sealed-package-inspection-v1", - transform: { name: "selftune-portable-package", version: "1" }, - packagedSha256: "b".repeat(64), - }, - }, -} as const; -const disclosureInput = { - publisher: { name: "Acme Skills" }, - rightsHolder: { kind: "external", name: "Example Author" }, - artifact: { - subjectKind: "skill_revision", - subjectId: "review-helper", - sourceRevisionHash: "a".repeat(64), - packagedSha256: "b".repeat(64), - }, - license: { - expression: "Proprietary", - kind: "proprietary", - licenseEvidenceSha256: "c".repeat(64), - bundledTerms: { path: "LICENSE.txt", sha256: "d".repeat(64) }, - }, - provenance: { - kind: "self_attested_upload", - sourceRepository: null, - sourceRef: null, - sourceTreeHash: null, - }, - contributorSignals: { - status: "disabled", - enabled: false, - includedInPackage: false, - activeCapability: false, - capabilityVersion: null, - signalSchema: null, - allowedSignals: [], - }, - recipientActions: { - accountlessEligibility: "account_required", - contributorSignals: { - _tag: "signals_unavailable", - signalDisclosureSha256: "f".repeat(64), - signalRecipientOrganizationId: null, - allowedFields: [], - capability: "not_capable", - defaultState: "off", - }, - portableDownloadLifecycle: { - _tag: "downloaded_status", - lifecycleDisclosureSha256: "1".repeat(64), - defaultConsent: "not_granted", - senderVisibleDownloadedStatus: "disabled", - }, - useOnceLifecycle: { - _tag: "used_once_status", - lifecycleDisclosureSha256: "2".repeat(64), - defaultConsent: "not_granted", - senderVisibleUsedOnceStatus: "disabled", - }, - }, - acceptance: { - required: true, - policyVersion: "skill-share-license-acceptance-v1", - disclosureSha256: "e".repeat(64), - }, -} as const; - -describe("share invitation contract", () => { - it("keeps recipient email and claim tokens confined to write inputs", () => { - const issue = Schema.decodeUnknownSync(ShareInvitationIssue)({ - distributionId: ids.distribution, - recipientEmail: "recipient@example.com", - expiresAt, - }); - const claim = Schema.decodeUnknownSync(ShareInvitationClaim)({ - claimToken: "A".repeat(43), - claimedOrganizationId: "550e8400-e29b-41d4-a716-446655440012", - }); - - expect(issue.recipientEmail).toBe("recipient@example.com"); - expect(claim.claimToken).toHaveLength(43); - for (const response of [ - Schema.decodeUnknownSync(ShareInvitationSenderView)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "pending", - expiresAt, - licenseAcceptanceRequired: true, - }), - Schema.decodeUnknownSync(ShareInvitationPreview)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "available", - expiresAt, - disclosure: disclosureInput, - ...recipientAuthority, - }), - Schema.decodeUnknownSync(ShareInvitationClaimResult)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "claimed", - licenseAcceptanceRequired: true, - licenseAcceptanceSatisfied: false, - importStatus: "not_imported", - }), - ]) { - expect(JSON.stringify(response)).not.toContain("recipient@example.com"); - expect(JSON.stringify(response)).not.toContain("claimToken"); - expect(JSON.stringify(response)).not.toContain("claimedOrganizationId"); - expect(JSON.stringify(response)).not.toContain("claimedUserId"); - } - }); - - it("rejects malformed or excess claim input at the security boundary", async () => { - await expect( - Effect.runPromise( - decodeShareInvitationClaim({ - claimToken: "short", - claimedOrganizationId: ids.invitation, - }), - ), - ).rejects.toBeDefined(); - await expect( - Effect.runPromise( - decodeShareInvitationClaim({ - claimToken: "A".repeat(43), - claimedOrganizationId: ids.invitation, - recipientEmail: "leak@example.com", - }), - ), - ).rejects.toBeDefined(); - }); - - it("uses one enumeration-safe claim failure without recipient details", () => { - const error = ShareInvitationUnavailable.make({ - message: "This invitation is unavailable.", - }); - expect(error._tag).toBe("ShareInvitationUnavailable"); - expect(JSON.stringify(error)).not.toContain(ids.invitation); - expect(JSON.stringify(error)).not.toContain("email"); - }); - - it("binds preview and acceptance to the exact public-safe distribution disclosure", () => { - const disclosure = Schema.decodeUnknownSync(ShareInvitationDisclosure)(disclosureInput); - const preview = Schema.decodeUnknownSync(ShareInvitationPreview)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "available", - expiresAt, - disclosure, - ...recipientAuthority, - }); - const command = Schema.decodeUnknownSync(ShareInvitationAcceptance)({ - disclosureSha256: disclosure.acceptance.disclosureSha256, - }); - const acceptedAt = "2026-07-21T18:00:00.000Z"; - const acceptance = Schema.decodeUnknownSync(ShareInvitationAcceptanceResult)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "accepted", - acceptanceReference: - "skill-share-license-acceptance-v1:" + disclosure.acceptance.disclosureSha256, - acceptedAt, - disclosure, - importStatus: "not_imported", - }); - - expect(preview.disclosure).toEqual(disclosure); - expect(command.disclosureSha256).toBe(disclosure.acceptance.disclosureSha256); - expect(acceptance.acceptanceReference).toContain(disclosure.acceptance.disclosureSha256); - expect(JSON.stringify([preview, acceptance])).not.toContain("recipientEmail"); - expect(JSON.stringify([preview, acceptance])).not.toContain("claimedUserId"); - expect(JSON.stringify([preview, acceptance])).not.toContain("objectUrl"); - expect(JSON.stringify([preview, acceptance])).not.toContain("install"); - }); - - it.each([null, "12", 12.5, 1e-1, true, undefined])( - "rejects malformed package byteLength value %s at the wire boundary", - (byteLength) => { - const file = { - path: "SKILL.md", - sha256: "3".repeat(64), - ...(byteLength === undefined ? {} : { byteLength }), - }; - expect(() => - Schema.decodeUnknownSync(ShareInvitationPreview)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "available", - expiresAt, - disclosure: disclosureInput, - ...recipientAuthority, - packageInspection: { - ...recipientAuthority.packageInspection, - fileManifest: [file], - }, - }), - ).toThrow(); - }, - ); - - it("rejects Skill Set disclosure until a complete component license BOM is supported", () => { - expect(() => - Schema.decodeUnknownSync(ShareInvitationDisclosure)({ - ...disclosureInput, - artifact: { ...disclosureInput.artifact, subjectKind: "skill_set" }, - }), - ).toThrow(); - }); - - it("keeps authenticated recipient view separate from claim, acceptance, and import", () => { - const view = Schema.decodeUnknownSync(ShareInvitationRecipientView)({ - invitationId: ids.invitation, - distributionId: ids.distribution, - status: "claimed", - expiresAt, - claimedAt: "2026-07-21T17:00:00.000Z", - ...recipientAuthority, - disclosure: { - publisher: { name: "Acme Skills" }, - rightsHolder: { kind: "organization", name: "Acme Skills" }, - artifact: { - subjectKind: "skill_revision", - subjectId: "review-helper", - sourceRevisionHash: "a".repeat(64), - packagedSha256: "b".repeat(64), - }, - license: { - expression: "MIT", - kind: "spdx", - licenseEvidenceSha256: "c".repeat(64), - bundledTerms: null, - }, - provenance: { - kind: "github_verified", - sourceRepository: "https://github.com/acme/review-helper", - sourceRef: "v1.0.0", - sourceTreeHash: "f".repeat(40), - }, - contributorSignals: { - status: "enabled", - enabled: true, - includedInPackage: true, - activeCapability: true, - capabilityVersion: "1", - signalSchema: "selftune.signal.v1", - allowedSignals: ["trigger"], - }, - recipientActions: { - accountlessEligibility: "public_allowed", - contributorSignals: { - _tag: "capable_default_off", - signalDisclosureSha256: "9".repeat(64), - signalRecipientOrganizationId: "550e8400-e29b-41d4-a716-446655440099", - allowedFields: ["trigger"], - capability: "capable", - defaultState: "off", - }, - portableDownloadLifecycle: { - _tag: "downloaded_status", - lifecycleDisclosureSha256: "1".repeat(64), - defaultConsent: "not_granted", - senderVisibleDownloadedStatus: "disabled", - }, - useOnceLifecycle: { - _tag: "used_once_status", - lifecycleDisclosureSha256: "2".repeat(64), - defaultConsent: "not_granted", - senderVisibleUsedOnceStatus: "disabled", - }, - }, - acceptance: { - required: false, - policyVersion: "skill-share-license-acceptance-v1", - disclosureSha256: "e".repeat(64), - }, - }, - licenseAcceptance: { - required: false, - satisfied: true, - reference: null, - acceptedAt: null, - }, - importStatus: "not_imported", - }); - - expect(view.status).toBe("claimed"); - expect(view.importStatus).toBe("not_imported"); - expect(JSON.stringify(view)).not.toContain("claimedUserId"); - expect(JSON.stringify(view)).not.toContain("claimedOrganizationId"); - }); -}); diff --git a/packages/api-contract/tests/signal-routing-capability-exports.test.ts b/packages/api-contract/tests/signal-routing-capability-exports.test.ts deleted file mode 100644 index 87ce54f0..00000000 --- a/packages/api-contract/tests/signal-routing-capability-exports.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SignalRoutingCapabilityEnvelope as RootSignalRoutingCapabilityEnvelope } from "@selftune/api-contract"; -import { SignalRoutingCapabilityEnvelope as SubpathSignalRoutingCapabilityEnvelope } from "@selftune/api-contract/signal-routing-capability"; -import { describe, expect, it } from "vitest"; - -describe("signal-routing capability package exports", () => { - it("exposes the contract from the root and official package subpath", () => { - expect(RootSignalRoutingCapabilityEnvelope).toBe(SubpathSignalRoutingCapabilityEnvelope); - }); -}); diff --git a/packages/api-contract/tests/signal-routing-capability.test.ts b/packages/api-contract/tests/signal-routing-capability.test.ts deleted file mode 100644 index 3d190a82..00000000 --- a/packages/api-contract/tests/signal-routing-capability.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - SignalRoutingCapabilityEnvelope, - SignalRoutingCapabilityPayload, -} from "../src/signal-routing-capability"; - -const payload = { - version: 1, - kid: "signals-2026-07", - publicCapabilityId: "550e8400-e29b-41d4-a716-446655440001", - distributionId: "550e8400-e29b-41d4-a716-446655440002", - packagedSha256: "a".repeat(64), - logicalSkill: { - id: "review-helper", - version: "b".repeat(64), - }, - telemetryRecipientOrganizationId: "550e8400-e29b-41d4-a716-446655440003", - allowedSignalSchema: "selftune.contributor-signals.v1", - allowedSignalFields: ["grade", "miss_category", "trigger"], - issuedAt: "2026-07-21T18:00:00.000Z", - expiresAt: "2026-08-20T18:00:00.000Z", -} as const; - -describe("signal routing capability contract", () => { - it("models one non-secret signed envelope bound to exact routing authority", () => { - const decodedPayload = Schema.decodeUnknownSync(SignalRoutingCapabilityPayload)(payload); - const envelope = Schema.decodeUnknownSync(SignalRoutingCapabilityEnvelope)({ - payload: decodedPayload, - signature: "A".repeat(86), - }); - - expect(envelope.payload).toEqual(payload); - expect(envelope.signature).toHaveLength(86); - expect(JSON.stringify(envelope)).not.toContain("secret"); - expect(JSON.stringify(envelope)).not.toContain("createdBy"); - expect(JSON.stringify(envelope)).not.toContain("publisherId"); - }); - - it.each([ - { ...payload, allowedSignalFields: ["trigger", "grade"] }, - { ...payload, allowedSignalFields: ["grade", "grade"] }, - { ...payload, allowedSignalFields: ["prompt_text"] }, - { ...payload, issuedAt: "2026-07-21T18:00:00Z" }, - { ...payload, expiresAt: "2026-08-20T18:00:00Z" }, - { ...payload, expiresAt: payload.issuedAt }, - { ...payload, expiresAt: "2026-07-20T18:00:00.000Z" }, - { ...payload, expiresAt: "2026-08-20T18:00:00.001Z" }, - ])("rejects noncanonical or unsafe routing authority %#", (candidate) => { - expect(() => Schema.decodeUnknownSync(SignalRoutingCapabilityPayload)(candidate)).toThrow(); - }); - - it("rejects a noncanonical base64url spelling of the same 64 signature bytes", () => { - expect(() => - Schema.decodeUnknownSync(SignalRoutingCapabilityEnvelope)({ - payload, - signature: `${"A".repeat(85)}B`, - }), - ).toThrow(); - }); -}); diff --git a/packages/api-contract/tests/skill-detail.test.ts b/packages/api-contract/tests/skill-detail.test.ts deleted file mode 100644 index 28d814af..00000000 --- a/packages/api-contract/tests/skill-detail.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { CloudSkillDetailSchema, createCloudApiClient, decodeUnknown } from "../index"; - -const detail = { - id: "skill-1", - name: "TDD", - platform: "codex", - description: null, - sources: [ - { - id: "source-1", - label: "engineering-skills", - kind: "github", - status: "active", - capabilityStatus: "ready", - repoFullName: "acme/skills", - skillPath: "skills/tdd", - updatedAt: "2026-07-24T10:00:00.000Z", - }, - ], - activity: { evalSuites: 2, improvementRuns: 3, pendingProposals: 1 }, -}; - -describe("cloud skill detail contract", () => { - it("rejects an unrecognized source kind", () => { - expect( - decodeUnknown(CloudSkillDetailSchema, { - ...detail, - sources: [{ ...detail.sources[0], kind: "gitlab" }], - }).success, - ).toBe(false); - }); - - it("gets a detail through the generated client", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async (input, init) => { - requests.push(new Request(input, init)); - return Response.json(detail); - }, - }); - - await expect(client.skillDetail("skill-1")).resolves.toEqual(detail); - expect(requests[0]?.url).toBe("https://cloud.selftune.dev/api/v1/cloud/skills/skill-1"); - }); -}); diff --git a/packages/api-contract/tests/team.test.ts b/packages/api-contract/tests/team.test.ts deleted file mode 100644 index 081380e5..00000000 --- a/packages/api-contract/tests/team.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createCloudApiClient, decodeUnknown, TeamInviteInput } from "../index"; - -const STATUS = { - currentUserId: "user-1", - currentRole: "owner" as const, - readOnly: false, - seatUsage: 1, - seatLimit: 5, - billingPath: "/settings/billing", - members: [], - invitations: [], -}; - -describe("team contract", () => { - it("validates an invite email and excludes owner invitations", () => { - expect(decodeUnknown(TeamInviteInput, { email: "not-an-email", role: "member" }).success).toBe( - false, - ); - expect( - decodeUnknown(TeamInviteInput, { email: "person@example.com", role: "owner" }).success, - ).toBe(false); - expect( - decodeUnknown(TeamInviteInput, { email: "person@example.com", role: "admin" }).success, - ).toBe(true); - }); - - it("uses only the maintained Team route family", async () => { - const requests: Request[] = []; - const client = createCloudApiClient({ - baseUrl: "https://cloud.selftune.dev", - fetch: async (input, init) => { - requests.push(new Request(input, init)); - return Response.json(STATUS); - }, - }); - - await client.teamStatus(); - await client.inviteTeamMember({ email: "person@example.com", role: "member" }); - await client.changeTeamMemberRole("user-2", { role: "admin" }); - await client.removeTeamMember("user-2"); - await client.cancelTeamInvitation("invite-1"); - - expect(requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`)).toEqual([ - "GET /api/v1/cloud/team", - "POST /api/v1/cloud/team/invitations", - "PATCH /api/v1/cloud/team/members/user-2", - "DELETE /api/v1/cloud/team/members/user-2", - "DELETE /api/v1/cloud/team/invitations/invite-1", - ]); - }); -}); diff --git a/packages/api-contract/tsconfig.json b/packages/api-contract/tsconfig.json deleted file mode 100644 index 0e664477..00000000 --- a/packages/api-contract/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["DOM", "ES2022"], - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "types": ["vitest/globals"] - }, - "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"] -} diff --git a/packages/app-core/CHANGELOG.md b/packages/app-core/CHANGELOG.md deleted file mode 100644 index b22feb23..00000000 --- a/packages/app-core/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# @selftune/app-core - -## 0.1.1 - -### Patch Changes - -- Updated dependencies [[`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262)]: - - @selftune/dashboard-core@1.1.0 diff --git a/packages/app-core/index.ts b/packages/app-core/index.ts index bf7d3b71..b151c1a7 100644 --- a/packages/app-core/index.ts +++ b/packages/app-core/index.ts @@ -1,6 +1,5 @@ export * from "./src/host"; export * from "./src/evidence-body-evolution-review"; export * from "./src/manifest"; -export * from "./src/route-factory"; export * from "./src/routes"; export * from "./src/shell"; diff --git a/packages/app-core/package.json b/packages/app-core/package.json index b12b9252..1ca9182d 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/app-core", - "version": "0.1.1", + "version": "0.1.0", "private": true, "description": "Canonical shared selftune application routes and host composition helpers", "license": "MIT", @@ -9,7 +9,6 @@ ".": "./index.ts", "./shell": "./src/shell.ts", "./manifest": "./src/manifest.ts", - "./route-factory": "./src/route-factory.tsx", "./routes": "./src/routes.tsx", "./host": "./src/host.tsx", "./styles.css": "./styles.css" @@ -26,6 +25,7 @@ "tw-animate-css": "^1.3.6" }, "devDependencies": { + "@types/node": "^20", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "typescript": "^5.9.3", diff --git a/packages/app-core/src/host.tsx b/packages/app-core/src/host.tsx index 6cb65ec1..1efc7adb 100644 --- a/packages/app-core/src/host.tsx +++ b/packages/app-core/src/host.tsx @@ -1,22 +1,22 @@ import type { ReactNode } from "react"; import { Outlet } from "@tanstack/react-router"; -import { DashboardHostProvider, type DashboardHostAdapter } from "@selftune/dashboard-core/host"; +import { DashboardHostProvider, type DashboardHostModules } from "@selftune/dashboard-core/host"; export interface AppCoreHostProviderProps { - adapter: DashboardHostAdapter; + modules: DashboardHostModules; children: ReactNode; } -/** Supplies the host adapter consumed by every shared app screen. */ -export function AppCoreHostProvider({ adapter, children }: AppCoreHostProviderProps) { - return {children}; +/** Supplies the journey modules consumed by every shared app screen. */ +export function AppCoreHostProvider({ modules, children }: AppCoreHostProviderProps) { + return {children}; } /** Convenience root content for hosts that do not need another wrapper around the outlet. */ -export function AppCoreHostOutlet({ adapter }: { adapter: DashboardHostAdapter }) { +export function AppCoreHostOutlet({ modules }: { modules: DashboardHostModules }) { return ( - + ); diff --git a/packages/app-core/src/manifest.ts b/packages/app-core/src/manifest.ts index feed0e31..09ccd92c 100644 --- a/packages/app-core/src/manifest.ts +++ b/packages/app-core/src/manifest.ts @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { ProjectsScreen } from "@selftune/dashboard-core/screens/projects"; import { SkillsLibraryScreen } from "@selftune/dashboard-core/screens/skills"; import { RecipientShareScreen } from "@selftune/dashboard-core/screens/recipient-shares"; +import { TeamCollaborationScreen } from "@selftune/dashboard-core/screens/team-collaboration"; import { APP_CORE_RECIPIENT_ROUTE_REGISTRY, @@ -30,19 +31,14 @@ export interface AppCoreRecipientRouteManifestEntry { readonly Component: AppCoreScreenComponent; } -export const APP_CORE_RECIPIENT_ROUTE_COMPONENTS = { - publicRecipientShare: RecipientShareScreen, - claimedRecipientShare: RecipientShareScreen, -} satisfies Record; - export const APP_CORE_RECIPIENT_ROUTE_MANIFEST: readonly AppCoreRecipientRouteManifestEntry[] = [ { ...APP_CORE_RECIPIENT_ROUTE_REGISTRY.publicRecipientShare, - Component: APP_CORE_RECIPIENT_ROUTE_COMPONENTS.publicRecipientShare, + Component: RecipientShareScreen, }, { ...APP_CORE_RECIPIENT_ROUTE_REGISTRY.claimedRecipientShare, - Component: APP_CORE_RECIPIENT_ROUTE_COMPONENTS.claimedRecipientShare, + Component: RecipientShareScreen, }, ]; @@ -50,12 +46,13 @@ export const APP_CORE_RECIPIENT_ROUTE_MANIFEST: readonly AppCoreRecipientRouteMa * The canonical application route/screen source. * * A route belongs here only after its screen is host-neutral and reads data through - * DashboardHostAdapter. Host composition roots may explicitly exclude or replace an + * DashboardHostModules. Host composition roots may explicitly exclude or replace an * entry, but must not copy its screen. */ -export const APP_CORE_ROUTE_COMPONENTS = { +const APP_CORE_SCREENS = { skills: SkillsLibraryScreen, projects: ProjectsScreen, + collaboration: TeamCollaborationScreen, } as const satisfies Record; /** @@ -72,14 +69,22 @@ export const APP_CORE_ROUTE_MANIFEST: readonly AppCoreRouteManifestEntry[] = APP label: route.navigation.label, tooltip: route.navigation.tooltip, headerTitle: route.header.title, - Component: APP_CORE_ROUTE_COMPONENTS[id], + Component: APP_CORE_SCREENS[id], }; }, ); -export const APP_CORE_ROUTES = Object.fromEntries( - APP_CORE_ROUTE_MANIFEST.map((route) => [route.id, route]), -) as Readonly>; +function routeManifestEntry(id: AppCoreRouteId): AppCoreRouteManifestEntry { + const entry = APP_CORE_ROUTE_MANIFEST.find((route) => route.id === id); + if (!entry) throw new Error(`Shared route manifest is missing ${id}.`); + return entry; +} + +export const APP_CORE_ROUTES = { + skills: routeManifestEntry("skills"), + projects: routeManifestEntry("projects"), + collaboration: routeManifestEntry("collaboration"), +} satisfies Readonly>; export type AppCoreRouteReplacements = Partial>; @@ -90,15 +95,6 @@ export interface AppCoreRouteComposition { readonly replace?: AppCoreRouteReplacements; } -export type AppCoreRecipientRouteReplacements = Partial< - Record ->; - -export interface AppCoreRecipientRouteComposition { - /** Recipient screens that this host intentionally replaces. */ - readonly replace?: AppCoreRecipientRouteReplacements; -} - export function resolveAppCoreRouteManifest( composition: AppCoreRouteComposition = {}, ): readonly AppCoreRouteManifestEntry[] { @@ -109,12 +105,3 @@ export function resolveAppCoreRouteManifest( Component: composition.replace?.[route.id] ?? route.Component, })); } - -export function resolveAppCoreRecipientRouteManifest( - composition: AppCoreRecipientRouteComposition = {}, -): readonly AppCoreRecipientRouteManifestEntry[] { - return APP_CORE_RECIPIENT_ROUTE_MANIFEST.map((route) => ({ - ...route, - Component: composition.replace?.[route.id] ?? route.Component, - })); -} diff --git a/packages/app-core/src/route-factory.tsx b/packages/app-core/src/route-factory.tsx deleted file mode 100644 index 8f4c1792..00000000 --- a/packages/app-core/src/route-factory.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { - createRoute, - type AnyRootRoute, - type AnyRoute, - type RouteComponent, -} from "@tanstack/react-router"; - -import { - APP_CORE_RECIPIENT_ROUTE_IDS, - APP_CORE_RECIPIENT_ROUTE_REGISTRY, - APP_CORE_ROUTE_IDS, - APP_CORE_ROUTE_REGISTRY, - type AppCoreRecipientRouteId, - type AppCoreRouteId, -} from "./shell"; - -export type AppCoreTanStackRouteKey = `${AppCoreRouteId}Route`; -export type AppCoreRecipientTanStackRouteKey = `${AppCoreRecipientRouteId}Route`; - -export type AppCoreRouteComponents = Readonly>; -export type AppCoreRecipientRouteComponents = Readonly< - Record ->; - -export interface AppCoreRouteFactoryOptions { - readonly exclude?: readonly AppCoreRouteId[]; -} - -export interface AppCoreRecipientRouteFactoryOptions { - readonly exclude?: readonly AppCoreRecipientRouteId[]; -} - -function routeKey(id: AppCoreRouteId): AppCoreTanStackRouteKey { - return `${id}Route`; -} - -function recipientRouteKey(id: AppCoreRecipientRouteId): AppCoreRecipientTanStackRouteKey { - return `${id}Route`; -} - -function tanStackRecipientPath(path: string): string { - return path.replace(/:([A-Za-z][A-Za-z0-9]*)/g, (_match, parameter: string) => `$${parameter}`); -} - -/** - * Creates every canonical shared route from an exhaustive host component map. - * - * Code-splitting hosts pass TanStack lazy route components here so importing - * this factory never imports the eager shared screen manifest. - */ -export function createAppCoreRoutesFromComponents( - rootRoute: TRootRoute, - components: AppCoreRouteComponents, - options: AppCoreRouteFactoryOptions = {}, -) { - const excluded = new Set(options.exclude); - const routes: Partial> = {}; - for (const id of APP_CORE_ROUTE_IDS) { - if (excluded.has(id)) continue; - const route = APP_CORE_ROUTE_REGISTRY[id]; - routes[routeKey(id)] = createRoute({ - getParentRoute: () => rootRoute, - path: route.path, - component: components[id], - }); - } - return routes; -} - -/** Creates canonical non-shell recipient routes from exhaustive host components. */ -export function createAppCoreRecipientRoutesFromComponents( - rootRoute: TRootRoute, - components: AppCoreRecipientRouteComponents, - options: AppCoreRecipientRouteFactoryOptions = {}, -) { - const excluded = new Set(options.exclude); - const routes: Partial> = {}; - for (const id of APP_CORE_RECIPIENT_ROUTE_IDS) { - if (excluded.has(id)) continue; - const route = APP_CORE_RECIPIENT_ROUTE_REGISTRY[id]; - routes[recipientRouteKey(id)] = createRoute({ - getParentRoute: () => rootRoute, - path: tanStackRecipientPath(route.path), - component: components[id], - }); - } - return routes; -} diff --git a/packages/app-core/src/routes.tsx b/packages/app-core/src/routes.tsx index cc6d6fbf..c22c29ea 100644 --- a/packages/app-core/src/routes.tsx +++ b/packages/app-core/src/routes.tsx @@ -1,25 +1,37 @@ -import type { AnyRootRoute, AnyRoute } from "@tanstack/react-router"; +import { createRoute, type AnyRootRoute, type AnyRoute } from "@tanstack/react-router"; import { - APP_CORE_RECIPIENT_ROUTE_COMPONENTS, - APP_CORE_ROUTE_COMPONENTS, - type AppCoreRecipientRouteComposition, + APP_CORE_RECIPIENT_ROUTE_MANIFEST, + resolveAppCoreRouteManifest, type AppCoreRouteComposition, } from "./manifest"; -import { - createAppCoreRecipientRoutesFromComponents, - createAppCoreRoutesFromComponents, -} from "./route-factory"; +import type { AppCoreRecipientRouteId, AppCoreRouteId } from "./shell"; + +export type AppCoreTanStackRouteKey = `${AppCoreRouteId}Route`; +export type AppCoreRecipientTanStackRouteKey = `${AppCoreRecipientRouteId}Route`; + +function routeKey(id: AppCoreRouteId): AppCoreTanStackRouteKey { + return `${id}Route`; +} + +function tanStackRecipientPath(path: string): string { + return path.replace(/:([A-Za-z][A-Za-z0-9]*)/g, (_match, parameter: string) => `$${parameter}`); +} /** Canonical non-shell recipient routes; hosts opt in explicitly. */ export function createAppCoreRecipientRoutes( rootRoute: TRootRoute, - composition: AppCoreRecipientRouteComposition = {}, ) { - return createAppCoreRecipientRoutesFromComponents(rootRoute, { - ...APP_CORE_RECIPIENT_ROUTE_COMPONENTS, - ...composition.replace, - }); + 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>; } /** @@ -33,14 +45,16 @@ export function createAppCoreRoutes( rootRoute: TRootRoute, composition: AppCoreRouteComposition = {}, ) { - return createAppCoreRoutesFromComponents( - rootRoute, - { - ...APP_CORE_ROUTE_COMPONENTS, - ...composition.replace, - }, - { exclude: composition.exclude }, - ); + return Object.fromEntries( + resolveAppCoreRouteManifest(composition).map((route) => [ + routeKey(route.id), + createRoute({ + getParentRoute: () => rootRoute, + path: route.path, + component: route.Component, + }), + ]), + ) as Partial>; } /** diff --git a/packages/app-core/src/shell.ts b/packages/app-core/src/shell.ts index 4af9e712..5b25773d 100644 --- a/packages/app-core/src/shell.ts +++ b/packages/app-core/src/shell.ts @@ -51,6 +51,20 @@ export const APP_CORE_ROUTE_REGISTRY = { mode: "prefix", }, }, + collaboration: { + id: "collaboration", + path: "/collaboration", + navigation: { + label: "Team", + tooltip: "Manage workspace members and sharing", + }, + header: { + title: "Workspace team", + }, + match: { + mode: "prefix", + }, + }, } as const satisfies Record; export const APP_CORE_RECIPIENT_ROUTE_REGISTRY = { @@ -70,10 +84,6 @@ export type AppCoreRecipientRouteId = keyof typeof APP_CORE_RECIPIENT_ROUTE_REGI export type AppCoreRouteId = keyof typeof APP_CORE_ROUTE_REGISTRY; -export const APP_CORE_RECIPIENT_ROUTE_IDS = Object.keys( - APP_CORE_RECIPIENT_ROUTE_REGISTRY, -) as AppCoreRecipientRouteId[]; - export interface AppCoreNavigationEntry { readonly id: AppCoreRouteId; readonly path: `/${string}`; @@ -81,7 +91,11 @@ export interface AppCoreNavigationEntry { readonly tooltip: string; } -export const APP_CORE_ROUTE_IDS = Object.keys(APP_CORE_ROUTE_REGISTRY) as AppCoreRouteId[]; +export const APP_CORE_ROUTE_IDS = [ + "skills", + "projects", + "collaboration", +] as const satisfies readonly AppCoreRouteId[]; export const APP_CORE_SHELL_NAVIGATION: readonly AppCoreNavigationEntry[] = APP_CORE_ROUTE_IDS.map( (id) => { diff --git a/packages/app-core/tests/composition.test.tsx b/packages/app-core/tests/composition.test.tsx index 0a0aad80..83835623 100644 --- a/packages/app-core/tests/composition.test.tsx +++ b/packages/app-core/tests/composition.test.tsx @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { ProjectsScreen } from "@selftune/dashboard-core/screens/projects"; import { RecipientShareScreen } from "@selftune/dashboard-core/screens/recipient-shares"; import { SkillsLibraryScreen } from "@selftune/dashboard-core/screens/skills"; +import { TeamCollaborationScreen } from "@selftune/dashboard-core/screens/team-collaboration"; import { APP_CORE_ROUTE_MANIFEST, @@ -15,9 +16,7 @@ import { APP_CORE_ROUTES, APP_CORE_SHELL_NAVIGATION, appendAppHostRoutes, - createAppCoreRecipientRoutesFromComponents, createAppCoreRoutes, - createAppCoreRoutesFromComponents, createAppCoreRecipientRoutes, matchAppCoreShellRoute, EvidenceBodyEvolutionReviewSurface, @@ -28,18 +27,11 @@ function ReplacementSkillsScreen() { return
Replacement
; } -function ReplacementProjectsScreen() { - return
Replacement projects
; -} - -function ReplacementRecipientScreen() { - return
Replacement recipient
; -} - describe("app-core route composition", () => { it("uses the existing shared dashboard-core screens as the canonical source", () => { expect(APP_CORE_ROUTES.skills.Component).toBe(SkillsLibraryScreen); expect(APP_CORE_ROUTES.projects.Component).toBe(ProjectsScreen); + expect(APP_CORE_ROUTES.collaboration.Component).toBe(TeamCollaborationScreen); expect(APP_CORE_ROUTE_MANIFEST).toEqual([ { id: "skills", @@ -57,12 +49,20 @@ describe("app-core route composition", () => { headerTitle: "Skill Sets", Component: ProjectsScreen, }, + { + id: "collaboration", + path: "/collaboration", + label: "Team", + tooltip: "Manage workspace members and sharing", + headerTitle: "Workspace team", + Component: TeamCollaborationScreen, + }, ]); }); it("requires route divergence to be declared through exclude and replace", () => { const resolved = resolveAppCoreRouteManifest({ - exclude: ["projects"], + exclude: ["projects", "collaboration"], replace: { skills: ReplacementSkillsScreen }, }); @@ -82,6 +82,7 @@ describe("app-core route composition", () => { ); expect(matchAppCoreShellRoute("/skills/example")?.header.title).toBe("Skills"); expect(matchAppCoreShellRoute("/projects")?.header.title).toBe("Skill Sets"); + expect(matchAppCoreShellRoute("/collaboration")?.header.title).toBe("Workspace team"); expect(matchAppCoreShellRoute("/settings")).toBeNull(); }); @@ -97,35 +98,6 @@ describe("app-core route composition", () => { } }); - it("mounts exhaustive host-provided components through canonical route factories", () => { - const rootRoute = createRootRoute(); - const routes = createAppCoreRoutesFromComponents(rootRoute, { - skills: ReplacementSkillsScreen, - projects: ReplacementProjectsScreen, - }); - const recipientRoutes = createAppCoreRecipientRoutesFromComponents(rootRoute, { - publicRecipientShare: ReplacementRecipientScreen, - claimedRecipientShare: ReplacementRecipientScreen, - }); - - expect(routes.skillsRoute?.options).toMatchObject({ - path: "/skills", - component: ReplacementSkillsScreen, - }); - expect(routes.projectsRoute?.options).toMatchObject({ - path: "/projects", - component: ReplacementProjectsScreen, - }); - expect(recipientRoutes.publicRecipientShareRoute?.options).toMatchObject({ - path: "/share/$claimToken", - component: ReplacementRecipientScreen, - }); - expect(recipientRoutes.claimedRecipientShareRoute?.options).toMatchObject({ - path: "/inbox/shares/$invitationId", - component: ReplacementRecipientScreen, - }); - }); - it("keeps recipient routes canonical but outside product-shell navigation", () => { expect(APP_CORE_RECIPIENT_ROUTE_MANIFEST).toEqual([ { @@ -145,21 +117,17 @@ describe("app-core route composition", () => { expect(APP_CORE_RECIPIENT_ROUTE_REGISTRY.publicRecipientShare.visibility).toBe("public"); const rootRoute = createRootRoute(); - const routes = createAppCoreRecipientRoutes(rootRoute, { - replace: { publicRecipientShare: ReplacementRecipientScreen }, - }); + const routes = createAppCoreRecipientRoutes(rootRoute); expect( routes.publicRecipientShareRoute && "path" in routes.publicRecipientShareRoute.options ? routes.publicRecipientShareRoute.options.path : null, ).toBe("/share/$claimToken"); - expect(routes.publicRecipientShareRoute?.options.component).toBe(ReplacementRecipientScreen); expect( routes.claimedRecipientShareRoute && "path" in routes.claimedRecipientShareRoute.options ? routes.claimedRecipientShareRoute.options.path : null, ).toBe("/inbox/shares/$invitationId"); - expect(routes.claimedRecipientShareRoute?.options.component).toBe(RecipientShareScreen); }); it("appends host routes while rejecting implicit shared-route overrides", () => { diff --git a/packages/app-core/tests/semantic-token-guard.test.ts b/packages/app-core/tests/semantic-token-guard.test.ts index 6306737a..bbeef2af 100644 --- a/packages/app-core/tests/semantic-token-guard.test.ts +++ b/packages/app-core/tests/semantic-token-guard.test.ts @@ -1,24 +1,15 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); -const LOCAL_DASHBOARD_SOURCE = [ - "oss/selftune/apps/local-dashboard/src", - "apps/local-dashboard/src", -].find((directory) => existsSync(join(REPOSITORY_ROOT, directory))); - -if (!LOCAL_DASHBOARD_SOURCE) { - throw new Error("Could not locate the local dashboard source for the semantic token guard"); -} - const PRODUCT_SOURCE_DIRECTORIES = [ "packages/app-core", "packages/dashboard-core", "packages/ui/src", - ...(existsSync(join(REPOSITORY_ROOT, "apps/cloud/src")) ? ["apps/cloud/src"] : []), - LOCAL_DASHBOARD_SOURCE, + "apps/cloud/src", + "oss/selftune/apps/local-dashboard/src", ] as const; const SOURCE_FILE_PATTERN = /\.(?:ts|tsx)$/; diff --git a/packages/app-core/tsconfig.json b/packages/app-core/tsconfig.json index caeae18f..95eb44d7 100644 --- a/packages/app-core/tsconfig.json +++ b/packages/app-core/tsconfig.json @@ -9,6 +9,7 @@ "allowImportingTsExtensions": true, "noEmit": true, "skipLibCheck": true, + "types": ["node"], "lib": ["ES2023", "DOM", "DOM.Iterable"] }, "include": ["index.ts", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] diff --git a/packages/app-core/vitest.config.ts b/packages/app-core/vitest.config.ts new file mode 100644 index 00000000..222bc6f0 --- /dev/null +++ b/packages/app-core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +/** Package tests are node-only and must not initialize the root Storybook/Next plugin. */ +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 103059bd..0dfb25a5 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -13,6 +13,7 @@ "./library-selection": "./src/library-selection.ts", "./services": "./src/services/index.ts", "./programs": "./src/programs/index.ts", + "./review/portable": "./src/review/portable.ts", "./layers/memory": "./src/layers/memory.ts", "./layers/remote-memory": "./src/layers/remote-memory.ts", "./layers/remote-http": "./src/layers/remote-http.ts" diff --git a/packages/control-plane/src/domain/hosted-state.ts b/packages/control-plane/src/domain/hosted-state.ts new file mode 100644 index 00000000..4b266bd5 --- /dev/null +++ b/packages/control-plane/src/domain/hosted-state.ts @@ -0,0 +1,69 @@ +import * as Schema from "effect/Schema"; + +export const HostedDesktopState = Schema.Struct({ + workspaceId: Schema.String, + plan: Schema.Literals(["free", "pro", "team"]), + status: Schema.Literals(["none", "trialing", "active", "past_due", "canceled", "unpaid"]), + currentPeriodEnd: Schema.NullOr(Schema.Number), +}); +export type HostedDesktopState = typeof HostedDesktopState.Type; + +export const HostedManifestSkill = Schema.Struct({ + identity: Schema.String, + revision_hash: Schema.String, + scope: Schema.String, + connections: Schema.Array(Schema.String), + update_status: Schema.Literals(["current", "available", "unknown"]), + usage_status: Schema.Literals(["recent", "stale", "none"]), +}); +export type HostedManifestSkill = typeof HostedManifestSkill.Type; + +export const HostedManifestRequest = Schema.Struct({ + revision: Schema.String, + device_name: Schema.String, + platform: Schema.String, + skills: Schema.Array(HostedManifestSkill), +}); +export type HostedManifestRequest = typeof HostedManifestRequest.Type; + +export const HostedManifestReceipt = Schema.Struct({ + uploaded: Schema.Number, + unchanged: Schema.Number, +}); +export type HostedManifestReceipt = typeof HostedManifestReceipt.Type; + +export const HostedContributorSignal = Schema.Struct({ + version: Schema.Literal(1), + signal_type: Schema.Literal("skill_session"), + source_key: Schema.String, + skill_name: Schema.optional(Schema.String), + relay_destination: Schema.String, + skill_hash: Schema.String, + user_cohort: Schema.String, + signals: Schema.Struct({ + triggered: Schema.optional(Schema.Boolean), + invocation_type: Schema.optional( + Schema.Literals(["explicit", "implicit", "contextual", "missed"]), + ), + execution_grade: Schema.optional(Schema.Literals(["A", "B", "C", "F"])), + query_bucket: Schema.optional(Schema.String), + miss_detected: Schema.optional(Schema.Boolean), + }), + timestamp_bucket: Schema.String, + client_version: Schema.String, +}); +export type HostedContributorSignal = typeof HostedContributorSignal.Type; + +export const HostedContributorAggregate = Schema.Struct({ + observations: Schema.Number, + cohorts: Schema.Number, + triggered: Schema.Number, + missed: Schema.Number, + grades: Schema.Struct({ + A: Schema.Number, + B: Schema.Number, + C: Schema.Number, + F: Schema.Number, + }), +}); +export type HostedContributorAggregate = typeof HostedContributorAggregate.Type; diff --git a/packages/control-plane/src/domain/index.ts b/packages/control-plane/src/domain/index.ts index dc8827f0..0b82f94f 100644 --- a/packages/control-plane/src/domain/index.ts +++ b/packages/control-plane/src/domain/index.ts @@ -1,9 +1,14 @@ export * from "./errors"; +export * from "./hosted-state"; export * from "./evaluation"; export * from "./library"; export * from "./package"; export * from "./package-bundle"; export * from "./portable-skill-set"; +export * from "./skill-set-pack"; +export * from "./plugin-capability-bundle"; +export * from "./skill-set-source-composer"; +export * from "./portable-plugin-export"; export * from "./remote-errors"; export * from "./remote-library"; export * from "./synthesis"; diff --git a/packages/control-plane/src/domain/package-bundle.ts b/packages/control-plane/src/domain/package-bundle.ts index 7c7b7719..8f95f3ff 100644 --- a/packages/control-plane/src/domain/package-bundle.ts +++ b/packages/control-plane/src/domain/package-bundle.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; @@ -205,18 +206,49 @@ const PortablePackageBundleInputSchema = Schema.Struct({ releaseAuthority: Schema.optionalKey(PortablePackageReleaseAuthority), }); +const PackageBundleCardinality = Schema.Struct({ + files: Schema.Array(Schema.Unknown), +}); +const PackageBundleReleaseAuthorityPreflight = Schema.Struct({ + releaseAuthority: Schema.Struct({ + evaluation: Schema.Unknown, + }), +}); +const TraversableContainer = Schema.Union([ + Schema.Array(Schema.Unknown), + Schema.Record(Schema.String, Schema.Unknown), +]); +const JsonObject = Schema.Record(Schema.String, Schema.Json); +const decodePackageBundleCardinality = Schema.decodeUnknownOption(PackageBundleCardinality); +const decodePackageBundleReleaseAuthorityPreflight = Schema.decodeUnknownOption( + PackageBundleReleaseAuthorityPreflight, +); +const isTraversableContainer = Schema.is(TraversableContainer); +const isJsonArray = Schema.is(Schema.Array(Schema.Json)); +const isJsonObject = Schema.is(JsonObject); +const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +interface PortablePackageBundleErrorFields { + reason: PortablePackageBundleErrorReason; + message: string; + path?: string; + detail?: string; +} + const invalid = ( reason: PortablePackageBundleErrorReason, message: string, path?: string, detail?: string, -) => - PortablePackageBundleError.make({ +) => { + const fields: PortablePackageBundleErrorFields = { reason, message: bounded(message, MAXIMUM_DIAGNOSTIC_MESSAGE_CHARACTERS), - ...(path ? { path: bounded(path, MAXIMUM_DIAGNOSTIC_PATH_CHARACTERS) } : {}), - ...(detail ? { detail: bounded(detail, MAXIMUM_DIAGNOSTIC_DETAIL_CHARACTERS) } : {}), - }); + }; + if (path) fields.path = bounded(path, MAXIMUM_DIAGNOSTIC_PATH_CHARACTERS); + if (detail) fields.detail = bounded(detail, MAXIMUM_DIAGNOSTIC_DETAIL_CHARACTERS); + return PortablePackageBundleError.make(fields); +}; const schemaIssueFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => { @@ -242,25 +274,31 @@ function bounded(value: string, maximumCharacters: number): string { return value.length <= maximumCharacters ? value : `${value.slice(0, maximumCharacters - 1)}…`; } -function schemaDiagnostic(error: Schema.SchemaError): { - readonly path?: string; - readonly detail?: string; -} { +interface SchemaDiagnostic { + path?: string; + detail?: string; +} + +const KeyedPathSegment = Schema.Struct({ key: Schema.PropertyKey }); +const isKeyedPathSegment = Schema.is(KeyedPathSegment); + +function pathSegmentKey(segment: PropertyKey | typeof KeyedPathSegment.Type): PropertyKey { + return isKeyedPathSegment(segment) ? segment.key : segment; +} + +function schemaDiagnostic(error: Schema.SchemaError): SchemaDiagnostic { const issues = schemaIssueFormatter(error.issue).issues; const issue = issues.find((candidate) => - candidate.path?.some( - (segment) => (typeof segment === "object" ? segment.key : segment) === "files", - ), + candidate.path?.some((segment) => pathSegmentKey(segment) === "files"), ) ?? issues[0]; if (!issue) return {}; - const path = issue.path - ?.map((segment) => String(typeof segment === "object" ? segment.key : segment)) - .join("."); - return { - ...(path ? { path: bounded(path, MAXIMUM_DIAGNOSTIC_PATH_CHARACTERS) } : {}), + const path = issue.path?.map((segment) => String(pathSegmentKey(segment))).join("."); + const diagnostic: SchemaDiagnostic = { detail: bounded(issue.message, MAXIMUM_DIAGNOSTIC_DETAIL_CHARACTERS), }; + if (path) diagnostic.path = bounded(path, MAXIMUM_DIAGNOSTIC_PATH_CHARACTERS); + return diagnostic; } function invalidSchema(error: Schema.SchemaError, message: string) { @@ -396,13 +434,9 @@ function validateRawFileSizes( return Effect.void; } -function isJsonArray(value: Schema.Json): value is Schema.JsonArray { - return Array.isArray(value); -} - function canonicalizeJson(value: Schema.Json): Schema.Json { if (isJsonArray(value)) return value.map(canonicalizeJson); - if (value === null || typeof value !== "object") return value; + if (!isJsonObject(value)) return value; const canonical: Array = []; // oxlint-disable-next-line unicorn/no-array-sort -- sorting a new key array cannot mutate input. for (const key of Object.keys(value).sort()) { @@ -432,81 +466,56 @@ function canonicalizeReleaseAuthority( }); } -function hasTooManyFiles(input: unknown, profile: PortablePackageBundleProfile): boolean { - return ( - typeof input === "object" && - input !== null && - "files" in input && - Array.isArray(input.files) && - input.files.length > profile.maximumFileCount - ); +function hasTooManyFiles(input: Input, profile: PortablePackageBundleProfile): boolean { + const cardinality = decodePackageBundleCardinality(input); + return Option.isSome(cardinality) && cardinality.value.files.length > profile.maximumFileCount; } -function preflightReleaseAuthorityEvaluation(input: unknown) { - return Effect.try({ - try: () => { - if (typeof input !== "object" || input === null) return; - const releaseAuthority = Reflect.get(input, "releaseAuthority"); - if (typeof releaseAuthority !== "object" || releaseAuthority === null) return; - const evaluation = Reflect.get(releaseAuthority, "evaluation"); - if (typeof evaluation !== "object" || evaluation === null) return; - - type Frame = { - readonly value: object; - readonly depth: number; - readonly exiting: boolean; - }; - const active = new WeakSet(); - const stack: Array = [{ value: evaluation, depth: 1, exiting: false }]; - while (stack.length > 0) { - const frame = stack.pop(); - if (!frame) continue; - if (frame.exiting) { - active.delete(frame.value); - continue; - } - if (frame.depth > MAXIMUM_RELEASE_AUTHORITY_EVALUATION_DEPTH) { - throw new Error("release authority evaluation exceeded the JSON depth limit"); - } - if (active.has(frame.value)) { - throw new Error("release authority evaluation contains a cycle"); - } - active.add(frame.value); - stack.push({ ...frame, exiting: true }); - - if (Array.isArray(frame.value)) { - for (let index = frame.value.length - 1; index >= 0; index -= 1) { - const child = frame.value[index]; - if (typeof child === "object" && child !== null) { - stack.push({ value: child, depth: frame.depth + 1, exiting: false }); - } - } - } else { - const keys = Object.keys(frame.value); - for (let index = keys.length - 1; index >= 0; index -= 1) { - const key = keys[index]; - if (key === undefined) continue; - const child = Reflect.get(frame.value, key); - if (typeof child === "object" && child !== null) { - stack.push({ value: child, depth: frame.depth + 1, exiting: false }); - } - } +const preflightReleaseAuthorityEvaluation = Effect.fn("preflightReleaseAuthorityEvaluation")( + function* (input: Input) { + const preflight = decodePackageBundleReleaseAuthorityPreflight(input); + if (Option.isNone(preflight)) return; + const evaluation = preflight.value.releaseAuthority.evaluation; + if (!isTraversableContainer(evaluation)) return; + + type Frame = { + readonly value: typeof TraversableContainer.Type; + readonly depth: number; + readonly exiting: boolean; + }; + const active = new WeakSet(); + const stack: Array = [{ value: evaluation, depth: 1, exiting: false }]; + while (stack.length > 0) { + const frame = stack.pop(); + if (!frame) continue; + if (frame.exiting) { + active.delete(frame.value); + continue; + } + if (frame.depth > MAXIMUM_RELEASE_AUTHORITY_EVALUATION_DEPTH || active.has(frame.value)) { + return yield* invalid( + "invalid_package", + `Release authority evaluation exceeds ${MAXIMUM_RELEASE_AUTHORITY_EVALUATION_DEPTH} JSON levels or contains a cycle`, + "releaseAuthority.evaluation", + ); + } + active.add(frame.value); + stack.push({ ...frame, exiting: true }); + + const children = Array.isArray(frame.value) ? frame.value : Object.values(frame.value); + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]; + if (isTraversableContainer(child)) { + stack.push({ value: child, depth: frame.depth + 1, exiting: false }); } } - }, - catch: () => - invalid( - "invalid_package", - `Release authority evaluation exceeds ${MAXIMUM_RELEASE_AUTHORITY_EVALUATION_DEPTH} JSON levels or contains a cycle`, - "releaseAuthority.evaluation", - ), - }); -} + } + }, +); -const decodeEncodedShape = Effect.fn("decodeEncodedPortablePackageShape")(function* ( - input: unknown, - profile: PortablePackageBundleProfile, -) { +const decodeEncodedPackageBundle = Effect.fn("decodeEncodedPortablePackageBundle")(function* < + Input, +>(input: Input, profile: PortablePackageBundleProfile) { yield* preflightReleaseAuthorityEvaluation(input); if (hasTooManyFiles(input, profile)) { return yield* invalid( @@ -525,11 +534,10 @@ const decodeEncodedShape = Effect.fn("decodeEncodedPortablePackageShape")(functi ); }); -const decodePortablePackageBundleData = Effect.fn("decodePortablePackageBundleData")(function* ( - input: unknown, - profile: PortablePackageBundleProfile, -) { - const encoded = yield* decodeEncodedShape(input, profile); +const decodePortablePackageBundleData = Effect.fn("decodePortablePackageBundleData")(function* < + Input, +>(input: Input, profile: PortablePackageBundleProfile) { + const encoded = yield* decodeEncodedPackageBundle(input, profile); yield* validateManifestAndDuplicates(encoded.files); yield* validateCanonicalFileOrder(encoded.version, encoded.files); yield* validateDecodedSizes(encoded.files, profile); @@ -546,11 +554,13 @@ const decodePortablePackageBundleData = Effect.fn("decodePortablePackageBundleDa Effect.map((content) => ({ path: file.path, content })), ), ); - return { + const bundle: PortablePackageBundle = { version: encoded.version, files, - ...(encoded.releaseAuthority ? { releaseAuthority: encoded.releaseAuthority } : {}), - } satisfies PortablePackageBundle; + }; + return encoded.releaseAuthority + ? { ...bundle, releaseAuthority: encoded.releaseAuthority } + : bundle; }); export const decodePortablePackageBundle = Effect.fn("decodePortablePackageBundle")(function* ( @@ -568,10 +578,9 @@ export const decodePortablePackageBundle = Effect.fn("decodePortablePackageBundl try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), catch: () => invalid("invalid_utf8", "Package bundle is not valid UTF-8"), }); - const parsed: unknown = yield* Effect.try({ - try: () => JSON.parse(text), - catch: () => invalid("invalid_json", "Package bundle is not valid JSON"), - }); + const parsed = yield* decodeJsonText(text).pipe( + Effect.mapError(() => invalid("invalid_json", "Package bundle is not valid JSON")), + ); return yield* decodePortablePackageBundleData(parsed, profile); }); @@ -592,11 +601,11 @@ export const decodeCanonicalPortablePackageBundleV2 = Effect.fn( "Canonical distribution packages must use package bundle version 2", ); } + const canonicalInput: PortablePackageBundleInput = { files: decoded.files }; const canonicalBytes = yield* encodePortablePackageBundle( - { - files: decoded.files, - ...(decoded.releaseAuthority ? { releaseAuthority: decoded.releaseAuthority } : {}), - }, + decoded.releaseAuthority + ? { ...canonicalInput, releaseAuthority: decoded.releaseAuthority } + : canonicalInput, profile, ); if ( @@ -624,8 +633,8 @@ function comparePaths(left: PortablePackageFile, right: PortablePackageFile): nu } export const encodePortablePackageBundleUnknown = Effect.fn("encodePortablePackageBundle")( - function* ( - input: unknown, + function* ( + input: Input, profile: PortablePackageBundleProfile = DISTRIBUTION_PACKAGE_BUNDLE_PROFILE, ) { yield* preflightReleaseAuthorityEvaluation(input); @@ -651,17 +660,18 @@ export const encodePortablePackageBundleUnknown = Effect.fn("encodePortablePacka path: file.path, contentBase64: Encoding.encodeBase64(file.content), })); - const encoded = { + const encodedBundle = { version: CANONICAL_PACKAGE_BUNDLE_VERSION, files: encodedFiles, - ...(bundle.releaseAuthority - ? { - releaseAuthority: canonicalizeReleaseAuthority(bundle.releaseAuthority), - } - : {}), }; + const encoded = bundle.releaseAuthority + ? { + ...encodedBundle, + releaseAuthority: canonicalizeReleaseAuthority(bundle.releaseAuthority), + } + : encodedBundle; - yield* decodeEncodedShape(encoded, profile); + yield* decodeEncodedPackageBundle(encoded, profile); yield* validateManifestAndDuplicates(encodedFiles); yield* validateDecodedSizes(encodedFiles, profile); diff --git a/packages/control-plane/src/domain/plugin-capability-bundle.ts b/packages/control-plane/src/domain/plugin-capability-bundle.ts new file mode 100644 index 00000000..69c5d402 --- /dev/null +++ b/packages/control-plane/src/domain/plugin-capability-bundle.ts @@ -0,0 +1,241 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const PLUGIN_CAPABILITY_BUNDLE_FORMAT = "selftune-plugin-capability-bundle-v1" as const; +export const PLUGIN_CAPABILITY_BUNDLE_VERSION = 1 as const; +export const AGENT_PLUGINS_V1_MCP_SCHEMA = + "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" as const; + +const Sha256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const Identifier = Schema.String.check( + Schema.isPattern(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/), + Schema.isMaxLength(64), +); +const StringRecord = Schema.Record(Schema.String, Schema.String); + +export class PluginStdioMcpServer extends Schema.TaggedClass()("stdio", { + command: Schema.String.check(Schema.isMinLength(1)), + args: Schema.optionalKey(Schema.Array(Schema.String)), + env: Schema.optionalKey(StringRecord), + cwd: Schema.optionalKey(Schema.String), +}) {} + +export class PluginStreamableHttpMcpServer extends Schema.TaggedClass()( + "streamable-http", + { + url: Schema.String.check(Schema.isMinLength(1)), + headers: Schema.optionalKey(StringRecord), + }, +) {} + +export class PluginSseMcpServer extends Schema.TaggedClass()("sse", { + url: Schema.String.check(Schema.isMinLength(1)), + headers: Schema.optionalKey(StringRecord), +}) {} + +export const PluginMcpServer = Schema.Union([ + PluginStdioMcpServer, + PluginStreamableHttpMcpServer, + PluginSseMcpServer, +]); +export type PluginMcpServer = typeof PluginMcpServer.Type; + +export const PluginCapabilityRisk = Schema.Literals([ + "executable_commands", + "remote_network", + "credentials", + "persistent_plugin_data", +]); +export type PluginCapabilityRisk = typeof PluginCapabilityRisk.Type; + +export class PluginCapabilityReview extends Schema.Class( + "PluginCapabilityReview", +)({ + reviewedAt: Schema.DateTimeUtcFromString, + reviewedBy: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)), + acceptedRisks: Schema.Array(PluginCapabilityRisk), +}) {} + +const PluginCapabilityBundleInput = Schema.Struct({ + bundleId: Identifier, + skillSetId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128)), + skillSetRevisionSha256: Sha256, + mcpServers: Schema.Record(Identifier, PluginMcpServer), + review: PluginCapabilityReview, +}); + +export class PluginCapabilityBundle extends Schema.Class( + "PluginCapabilityBundle", +)({ + format: Schema.Literal(PLUGIN_CAPABILITY_BUNDLE_FORMAT), + version: Schema.Literal(PLUGIN_CAPABILITY_BUNDLE_VERSION), + bundleId: Identifier, + skillSetId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128)), + skillSetRevisionSha256: Sha256, + mcpServers: Schema.Record(Identifier, PluginMcpServer), + review: PluginCapabilityReview, +}) {} + +export const PluginCapabilityBundleErrorReason = Schema.Literals([ + "invalid_bundle", + "reserved_environment_variable", + "invalid_working_directory", + "insecure_remote_url", + "missing_risk_acceptance", +]); + +export class PluginCapabilityBundleError extends Schema.TaggedErrorClass()( + "PluginCapabilityBundleError", + { + reason: PluginCapabilityBundleErrorReason, + message: Schema.String.check(Schema.isMaxLength(320)), + }, +) {} + +function invalid( + reason: typeof PluginCapabilityBundleErrorReason.Type, + message: string, +): PluginCapabilityBundleError { + return PluginCapabilityBundleError.make({ reason, message: message.slice(0, 320) }); +} + +function values(server: PluginMcpServer): ReadonlyArray { + if (server._tag === "stdio") { + return [...(server.args ?? []), ...Object.values(server.env ?? {}), server.cwd ?? ""]; + } + return [server.url, ...Object.values(server.headers ?? {})]; +} + +function usesCredentialPlaceholder(server: PluginMcpServer): boolean { + return values(server).some((value) => + [...value.matchAll(/\$\{([A-Z][A-Z0-9_]*)\}/g)].some( + (match) => match[1] !== "PLUGIN_ROOT" && match[1] !== "PLUGIN_DATA", + ), + ); +} + +function requiredRisks(servers: ReadonlyArray): Set { + const risks = new Set(); + for (const server of servers) { + if (server._tag === "stdio") risks.add("executable_commands"); + else risks.add("remote_network"); + if (usesCredentialPlaceholder(server)) risks.add("credentials"); + if (values(server).some((value) => value.includes("${PLUGIN_DATA}"))) { + risks.add("persistent_plugin_data"); + } + } + return risks; +} + +function validateServer(server: PluginMcpServer): Effect.Effect { + if (server._tag === "stdio") { + if (server.env && ("PLUGIN_ROOT" in server.env || "PLUGIN_DATA" in server.env)) { + return Effect.fail( + invalid( + "reserved_environment_variable", + "MCP servers cannot override PLUGIN_ROOT or PLUGIN_DATA", + ), + ); + } + if (server.cwd) { + const segments = server.cwd + .replace(/^\.\//, "") + .replace(/^\$\{PLUGIN_(?:ROOT|DATA)\}\/?/, "") + .split("/"); + const hasSafeRoot = + server.cwd.startsWith("./") || /^\$\{PLUGIN_(?:ROOT|DATA)\}(?:\/|$)/.test(server.cwd); + const escapesRoot = + server.cwd.includes("\\") || + segments.some((segment) => segment === ".." || segment === "."); + if (hasSafeRoot && !escapesRoot) return Effect.void; + return Effect.fail( + invalid( + "invalid_working_directory", + "MCP cwd must be plugin-relative or rooted at PLUGIN_ROOT or PLUGIN_DATA", + ), + ); + } + return Effect.void; + } + let url: URL; + try { + url = new URL(server.url); + } catch { + return Effect.fail(invalid("insecure_remote_url", "MCP remote URL is invalid")); + } + const localHttp = + url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1"); + return url.protocol === "https:" || localHttp + ? Effect.void + : Effect.fail( + invalid("insecure_remote_url", "Remote MCP servers must use HTTPS unless they are local"), + ); +} + +export const makePluginCapabilityBundleUnknown = Effect.fn("PluginCapabilityBundle.make")( + function* (input: Input) { + const decoded = yield* Schema.decodeUnknownEffect(PluginCapabilityBundleInput)(input, { + errors: "all", + onExcessProperty: "error", + }).pipe( + Effect.mapError((cause) => + invalid("invalid_bundle", `Invalid plugin capability bundle input: ${cause.message}`), + ), + ); + const servers = Object.values(decoded.mcpServers); + if (servers.length === 0) { + return yield* invalid("invalid_bundle", "A capability bundle requires at least one server"); + } + yield* Effect.forEach(servers, validateServer, { concurrency: 1, discard: true }); + const accepted = new Set(decoded.review.acceptedRisks); + const missing = [...requiredRisks(servers)].filter((risk) => !accepted.has(risk)); + if (missing.length > 0) { + return yield* invalid( + "missing_risk_acceptance", + `Capability review must accept: ${missing.join(", ")}`, + ); + } + return PluginCapabilityBundle.make({ + format: PLUGIN_CAPABILITY_BUNDLE_FORMAT, + version: PLUGIN_CAPABILITY_BUNDLE_VERSION, + ...decoded, + }); + }, +); + +export type PluginCapabilityBundleInput = typeof PluginCapabilityBundleInput.Encoded; +export const makePluginCapabilityBundle: ( + input: PluginCapabilityBundleInput, +) => Effect.Effect = + makePluginCapabilityBundleUnknown; + +export function projectPluginCapabilityFiles( + bundle: PluginCapabilityBundle, + target: "claude" | "openai" | "agent-plugins-v1", +): ReadonlyArray<{ readonly relativePath: string; readonly content: Uint8Array }> { + const content = + target === "agent-plugins-v1" + ? { + $schema: AGENT_PLUGINS_V1_MCP_SCHEMA, + mcpServers: Object.fromEntries( + Object.entries(bundle.mcpServers).map(([name, server]) => { + const { _tag, ...configuration } = server; + return [name, { type: _tag, ...configuration }]; + }), + ), + } + : { + mcpServers: Object.fromEntries( + Object.entries(bundle.mcpServers).map(([name, server]) => { + const { _tag, ...configuration } = server; + return [name, { type: _tag === "streamable-http" ? "http" : _tag, ...configuration }]; + }), + ), + }; + return [ + { + relativePath: target === "agent-plugins-v1" ? "mcp.json" : ".mcp.json", + content: new TextEncoder().encode(`${JSON.stringify(content, null, 2)}\n`), + }, + ]; +} diff --git a/packages/control-plane/src/domain/portable-plugin-export.ts b/packages/control-plane/src/domain/portable-plugin-export.ts new file mode 100644 index 00000000..44baae35 --- /dev/null +++ b/packages/control-plane/src/domain/portable-plugin-export.ts @@ -0,0 +1,198 @@ +import * as Schema from "effect/Schema"; + +export type PortablePluginExportTarget = "claude" | "openai" | "agent-plugins-v1" | "dual" | "all"; + +export interface PortablePluginExportFile { + readonly path: string; + readonly content: Uint8Array; +} + +export interface PortablePluginExportSkill { + readonly name: string; + readonly files: ReadonlyArray; +} + +export interface PortablePluginProjection { + readonly pluginName: string; + readonly files: ReadonlyArray; +} + +export class PortablePluginExportError extends Schema.TaggedErrorClass()( + "PortablePluginExportError", + { message: Schema.String }, +) {} + +function invalidPluginExport(message: string): PortablePluginExportError { + return PortablePluginExportError.make({ message }); +} + +const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + +function slug(value: string): string { + return ( + value + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64) + .replace(/-+$/g, "") || "selftune-plugin" + ); +} + +function safePath(path: string): string { + const normalized = path.replaceAll("\\", "/"); + const parts = normalized.split("/"); + if ( + !normalized || + normalized.startsWith("/") || + normalized.includes("\0") || + parts.some((part) => !part || part === "." || part === "..") + ) { + throw invalidPluginExport(`Plugin contains an unsafe path: ${path}`); + } + return normalized; +} + +function json(path: string, value: Value): PortablePluginExportFile { + return { path, content: new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`) }; +} + +export function projectPortablePluginFiles(input: { + readonly target: PortablePluginExportTarget; + readonly name: string; + readonly description: string; + readonly skillSetId: string; + readonly skillSetRevisionSha256: string; + readonly skills: ReadonlyArray; +}): PortablePluginProjection { + if (input.skills.length === 0) { + throw invalidPluginExport("Plugin export requires at least one skill."); + } + const pluginName = slug(input.name); + const common = { name: pluginName, description: input.description }; + const files: PortablePluginExportFile[] = []; + if (input.target === "claude" || input.target === "dual" || input.target === "all") { + files.push(json(".claude-plugin/plugin.json", common)); + } + if (input.target === "openai" || input.target === "dual" || input.target === "all") { + files.push(json(".codex-plugin/plugin.json", { ...common, skills: "./skills/" })); + } + if (input.target === "agent-plugins-v1" || input.target === "all") { + files.push( + json("plugin.json", { + $schema: PLUGIN_SCHEMA, + ...common, + extensions: { + "dev.selftune": { + skillSetId: input.skillSetId, + skillSetRevisionSha256: input.skillSetRevisionSha256, + }, + }, + }), + ); + } + const names = new Set(); + for (const skill of input.skills) { + const name = slug(skill.name); + if (names.has(name)) { + throw invalidPluginExport(`Plugin contains duplicate skill name: ${name}`); + } + names.add(name); + if (!skill.files.some((file) => safePath(file.path).toLowerCase() === "skill.md")) { + throw invalidPluginExport( + `Plugin skill "${skill.name}" does not contain a root SKILL.md file.`, + ); + } + for (const file of skill.files) { + const path = safePath(file.path); + files.push({ + path: `skills/${name}/${path.toLowerCase() === "skill.md" ? "SKILL.md" : path}`, + content: file.content, + }); + } + } + return { pluginName, files }; +} + +const CRC32 = new Uint32Array( + Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + return value >>> 0; + }), +); + +function checksum(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC32[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +export function createPortablePluginZip( + files: ReadonlyArray, +): Uint8Array { + if (files.length === 0) throw invalidPluginExport("Plugin ZIP requires at least one file."); + const local: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + const encoder = new TextEncoder(); + const seen = new Set(); + const ordered = files.map((file) => ({ ...file, path: safePath(file.path) })); + ordered.sort((left, right) => left.path.localeCompare(right.path)); + for (const file of ordered) { + const key = file.path.toLowerCase(); + if (seen.has(key)) { + throw invalidPluginExport(`Plugin ZIP contains duplicate path: ${file.path}`); + } + seen.add(key); + const name = encoder.encode(safePath(file.path)); + const header = new Uint8Array(30); + const view = new DataView(header.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0x0800, true); + view.setUint16(12, 33, true); + view.setUint32(14, checksum(file.content), true); + view.setUint32(18, file.content.byteLength, true); + view.setUint32(22, file.content.byteLength, true); + view.setUint16(26, name.byteLength, true); + local.push(header, name, file.content); + const entry = new Uint8Array(46); + const entryView = new DataView(entry.buffer); + entryView.setUint32(0, 0x02014b50, true); + entryView.setUint16(4, 20, true); + entryView.setUint16(6, 20, true); + entryView.setUint16(8, 0x0800, true); + entryView.setUint16(14, 33, true); + entryView.setUint32(16, checksum(file.content), true); + entryView.setUint32(20, file.content.byteLength, true); + entryView.setUint32(24, file.content.byteLength, true); + entryView.setUint16(28, name.byteLength, true); + entryView.setUint32(42, offset, true); + central.push(entry, name); + offset += header.byteLength + name.byteLength + file.content.byteLength; + } + const centralBytes = concat(central); + const end = new Uint8Array(22); + const endView = new DataView(end.buffer); + endView.setUint32(0, 0x06054b50, true); + endView.setUint16(8, ordered.length, true); + endView.setUint16(10, ordered.length, true); + endView.setUint32(12, centralBytes.byteLength, true); + endView.setUint32(16, offset, true); + return concat([...local, centralBytes, end]); +} + +function concat(parts: ReadonlyArray): Uint8Array { + const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.byteLength; + } + return output; +} diff --git a/packages/control-plane/src/domain/portable-skill-set.ts b/packages/control-plane/src/domain/portable-skill-set.ts index 9c221689..5c011f06 100644 --- a/packages/control-plane/src/domain/portable-skill-set.ts +++ b/packages/control-plane/src/domain/portable-skill-set.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { @@ -128,6 +129,13 @@ const PortableSkillSetEnvelopeInput = Schema.Struct({ sourceManifestBytes: Schema.Uint8Array, components: Schema.Array(PortableSkillSetEnvelopeComponentInput), }); +const PortableSkillSetEnvelopeCardinality = Schema.Struct({ + components: Schema.Array(Schema.Unknown), +}); +const decodePortableSkillSetEnvelopeCardinality = Schema.decodeUnknownOption( + PortableSkillSetEnvelopeCardinality, +); +const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); export class PortableSkillSetEnvelopeComponent extends Schema.Class( "PortableSkillSetEnvelopeComponent", @@ -173,7 +181,7 @@ function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } -function canonicalBytes(value: unknown): Uint8Array { +function canonicalBytes(value: Value): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)); } @@ -280,7 +288,7 @@ function buildManifest(input: typeof CanonicalSkillSetSourceManifestInput.Type) export const encodeCanonicalSkillSetSourceManifestUnknown = Effect.fn( "encodeCanonicalSkillSetSourceManifest", -)(function* (input: unknown) { +)(function* (input: Input) { const decoded = yield* Schema.decodeUnknownEffect(CanonicalSkillSetSourceManifestInput)(input, { errors: "all", onExcessProperty: "error", @@ -323,10 +331,9 @@ export const decodeCanonicalSkillSetSourceManifest = Effect.fn( try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), catch: () => invalid("invalid_utf8", "Skill Set source manifest is not valid UTF-8"), }); - const parsed = yield* Effect.try({ - try: (): unknown => JSON.parse(text), - catch: () => invalid("invalid_json", "Skill Set source manifest is not valid JSON"), - }); + const parsed = yield* decodeJsonText(text).pipe( + Effect.mapError(() => invalid("invalid_json", "Skill Set source manifest is not valid JSON")), + ); const manifest = yield* Schema.decodeUnknownEffect(CanonicalSkillSetSourceManifest)(parsed, { errors: "all", onExcessProperty: "error", @@ -357,13 +364,10 @@ export const decodeCanonicalSkillSetSourceManifest = Effect.fn( } satisfies CanonicalSkillSetSourceManifestEncoding; }); -function hasTooManyEnvelopeComponents(input: unknown): boolean { +function hasTooManyEnvelopeComponents(input: Input): boolean { + const cardinality = decodePortableSkillSetEnvelopeCardinality(input); return ( - typeof input === "object" && - input !== null && - "components" in input && - Array.isArray(input.components) && - input.components.length > MAXIMUM_SKILL_SET_COMPONENTS + Option.isSome(cardinality) && cardinality.value.components.length > MAXIMUM_SKILL_SET_COMPONENTS ); } @@ -385,10 +389,11 @@ const decodeCanonicalV2Package = Effect.fn("decodeCanonicalV2SkillSetPackage")(f try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), catch: () => invalid("invalid_component_package", "Skill Set component is not valid UTF-8"), }); - const parsedUnknown = yield* Effect.try({ - try: (): unknown => JSON.parse(text), - catch: () => invalid("invalid_component_package", "Skill Set component is not valid JSON"), - }); + const parsedUnknown = yield* decodeJsonText(text).pipe( + Effect.mapError(() => + invalid("invalid_component_package", "Skill Set component is not valid JSON"), + ), + ); const parsed = yield* Schema.decodeUnknownEffect(Schema.Json)(parsedUnknown, { errors: "all", }).pipe( @@ -555,7 +560,7 @@ function validateAggregateLimits( } export const encodePortableSkillSetEnvelopeUnknown = Effect.fn("encodePortableSkillSetEnvelope")( - function* (input: unknown) { + function* (input: Input) { if (hasTooManyEnvelopeComponents(input)) { return yield* invalid( "component_count_exceeded", @@ -655,10 +660,11 @@ export const decodePortableSkillSetEnvelope = Effect.fn("decodePortableSkillSetE try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), catch: () => invalid("invalid_utf8", "Portable Skill Set envelope is not valid UTF-8"), }); - const parsed = yield* Effect.try({ - try: (): unknown => JSON.parse(text), - catch: () => invalid("invalid_json", "Portable Skill Set envelope is not valid JSON"), - }); + const parsed = yield* decodeJsonText(text).pipe( + Effect.mapError(() => + invalid("invalid_json", "Portable Skill Set envelope is not valid JSON"), + ), + ); if (hasTooManyEnvelopeComponents(parsed)) { return yield* invalid( "component_count_exceeded", diff --git a/packages/control-plane/src/domain/skill-set-pack.ts b/packages/control-plane/src/domain/skill-set-pack.ts new file mode 100644 index 00000000..999fb11b --- /dev/null +++ b/packages/control-plane/src/domain/skill-set-pack.ts @@ -0,0 +1,187 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const SKILL_SET_PACK_PROTOCOL = "selftune.skill-set-pack.v1" as const; +export const SKILL_SET_PACK_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +export const SKILL_SET_PACK_DESKTOP_HOST = "pack" as const; + +const Sha256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const PackToken = Schema.String.check(Schema.isPattern(SKILL_SET_PACK_TOKEN_PATTERN)); + +export const SkillSetPackMode = Schema.Literals(["reusable_unlisted", "private_single_claim"]); +export type SkillSetPackMode = typeof SkillSetPackMode.Type; + +export class SkillSetPackPreview extends Schema.Class("SkillSetPackPreview")({ + protocol: Schema.Literal(SKILL_SET_PACK_PROTOCOL), + packId: Schema.String, + artifactId: Schema.String, + name: Schema.String, + description: Schema.String, + skillSetRevisionSha256: Sha256, + objectSha256: Sha256, + mode: SkillSetPackMode, + expiresAt: Schema.String, + requiresSignIn: Schema.Boolean, + components: Schema.Array( + Schema.Struct({ + logicalSkillId: Schema.String, + licenseExpression: Schema.String, + }), + ), +}) {} + +export const SkillSetPackStatus = Schema.Literals(["active", "claimed", "expired", "revoked"]); +export type SkillSetPackStatus = typeof SkillSetPackStatus.Type; + +export class SkillSetPackManagementItem extends Schema.Class( + "SkillSetPackManagementItem", +)({ + packId: Schema.String, + artifactId: Schema.String, + name: Schema.String, + description: Schema.String, + mode: SkillSetPackMode, + status: SkillSetPackStatus, + packUrl: Schema.NullOr(Schema.String), + expiresAt: Schema.String, + createdAt: Schema.String, + claimedAt: Schema.NullOr(Schema.String), + revokedAt: Schema.NullOr(Schema.String), + skillSetRevisionSha256: Sha256, + objectSha256: Sha256, + componentCount: Schema.Number, +}) {} + +export class SkillSetPackManagementList extends Schema.Class( + "SkillSetPackManagementList", +)({ + packs: Schema.Array(SkillSetPackManagementItem), +}) {} + +export class SkillSetPackUrlError extends Schema.TaggedErrorClass()( + "SkillSetPackUrlError", + { message: Schema.String }, +) {} + +function invalidUrl(message: string): SkillSetPackUrlError { + return SkillSetPackUrlError.make({ message }); +} + +/** + * Accepts only the branded `/p/<256-bit-base64url-token>` surface. The caller + * remains responsible for deciding which origins are trusted before fetching. + */ +export const parseSkillSetPackUrl = Effect.fn("SkillSetPack.parseUrl")(function* (value: string) { + const url = yield* Effect.try({ + try: () => new URL(value), + catch: () => invalidUrl("Enter a valid absolute Skill Set Pack URL."), + }); + if (url.username || url.password || url.search || url.hash) { + return yield* invalidUrl( + "Pack URLs cannot contain credentials, query parameters, or fragments.", + ); + } + const match = /^\/p\/([A-Za-z0-9_-]{43})\/?$/.exec(url.pathname); + if (!match) return yield* invalidUrl("Pack URLs must use /p/."); + const token = yield* Schema.decodeUnknownEffect(PackToken)(match[1]).pipe( + Effect.mapError(() => invalidUrl("The Pack identifier is invalid.")), + ); + return { + url, + token, + previewUrl: new URL(`/api/v1/public/packs/${token}`, url.origin), + contentUrl: new URL(`/api/v1/public/packs/${token}/content`, url.origin), + }; +}); + +const BASE64URL_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" as const; + +function encodeAsciiBase64Url(value: string): string { + let output = ""; + for (let index = 0; index < value.length; index += 3) { + const first = value.charCodeAt(index); + const second = index + 1 < value.length ? value.charCodeAt(index + 1) : 0; + const third = index + 2 < value.length ? value.charCodeAt(index + 2) : 0; + if (first > 0x7f || second > 0x7f || third > 0x7f) { + throw invalidUrl("Pack origins must contain only ASCII characters."); + } + const combined = (first << 16) | (second << 8) | third; + output += BASE64URL_ALPHABET[(combined >>> 18) & 63]; + output += BASE64URL_ALPHABET[(combined >>> 12) & 63]; + if (index + 1 < value.length) output += BASE64URL_ALPHABET[(combined >>> 6) & 63]; + if (index + 2 < value.length) output += BASE64URL_ALPHABET[combined & 63]; + } + return output; +} + +/** The desktop handoff contains only a normalized origin and the opaque Pack token. */ +export function skillSetPackDesktopUrl(packUrl: string): string { + const url = new URL(packUrl); + const token = /^\/p\/([A-Za-z0-9_-]{43})$/.exec(url.pathname)?.[1]; + if ( + !token || + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.search || + url.hash + ) { + throw invalidUrl("Cannot create a Desktop handoff for an invalid Pack URL."); + } + return `selftune://${SKILL_SET_PACK_DESKTOP_HOST}/${encodeAsciiBase64Url(url.origin)}/${token}`; +} + +function htmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function compactRevision(value: string): string { + return `${value.slice(0, 10)}…${value.slice(-8)}`; +} + +function readableExpiry(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return `${new Intl.DateTimeFormat("en", { + dateStyle: "long", + timeStyle: "short", + timeZone: "UTC", + }).format(date)} UTC`; +} + +/** Shared no-build landing page used by Cloud and one-container self-host. */ +export function renderSkillSetPackLandingPage(input: { + readonly packUrl: string; + readonly preview: SkillSetPackPreview; +}): string { + const preview = input.preview; + const safeName = htmlEscape(preview.name); + const safeDescription = htmlEscape(preview.description); + const safePackUrl = htmlEscape(input.packUrl); + const safeDesktopUrl = htmlEscape(skillSetPackDesktopUrl(input.packUrl)); + const expiry = htmlEscape(preview.expiresAt); + const expiryLabel = htmlEscape(readableExpiry(preview.expiresAt)); + const accessLabel = + preview.mode === "private_single_claim" ? "Single-use private link" : "Reusable unlisted link"; + const componentRows = preview.components + .map( + (component) => + `
  • ${htmlEscape(component.logicalSkillId)}${htmlEscape(component.licenseExpression)}
  • `, + ) + .join(""); + return ` + + + +${safeName} · SelfTune Pack
    SSelfTune Pack

    Verified Skill Set revision

    ${safeName}.

    ${safeDescription || "A portable collection of skills, pinned to one immutable revision."}

    Desktop opens a review first. No files are installed until you confirm the included skills and license terms.

    Review before import

    What is inside

    Each component carries explicit license metadata and is verified against the sealed Pack object.

      ${componentRows}
    `; +} diff --git a/packages/control-plane/src/domain/skill-set-source-composer.ts b/packages/control-plane/src/domain/skill-set-source-composer.ts new file mode 100644 index 00000000..b5fcddc2 --- /dev/null +++ b/packages/control-plane/src/domain/skill-set-source-composer.ts @@ -0,0 +1,150 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const SKILL_SET_SOURCE_COMPOSER_PREVIEW_FORMAT = + "selftune-skill-set-source-composer-preview-v1" as const; + +const Sha256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); +const SourceKind = Schema.Literals(["skills-sh", "github", "folder", "archive"]); +const Harness = Schema.Literals(["claude_code", "codex", "opencode", "openclaw", "pi"]); + +export class ResolvedSkillSetComposerSource extends Schema.Class( + "ResolvedSkillSetComposerSource", +)({ + sourceId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)), + kind: SourceKind, + source: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(2_000)), + logicalSkillId: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/)), + displayName: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)), + sourceRevisionSha256: Sha256, + sourcePackageObjectSha256: Sha256, + installSpec: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1))), +}) {} + +const ComposerSourceInput = Schema.Union([ + Schema.Struct({ + sourceId: Schema.String, + kind: SourceKind, + source: Schema.String, + status: Schema.Literal("resolved"), + resolution: ResolvedSkillSetComposerSource, + }), + Schema.Struct({ + sourceId: Schema.String, + kind: SourceKind, + source: Schema.String, + status: Schema.Literals(["unresolved", "error"]), + message: Schema.String, + }), +]); + +const ComposerInput = Schema.Struct({ + name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)), + description: Schema.String.check(Schema.isMaxLength(4_000)), + harnesses: Schema.Array(Harness), + sources: Schema.Array(ComposerSourceInput), +}); + +export class SkillSetSourceComposerPreview extends Schema.Class( + "SkillSetSourceComposerPreview", +)({ + format: Schema.Literal(SKILL_SET_SOURCE_COMPOSER_PREVIEW_FORMAT), + name: Schema.String, + description: Schema.String, + harnesses: Schema.Array(Harness), + components: Schema.Array(ResolvedSkillSetComposerSource), + directInstall: Schema.Literal(false), + portableImportCommand: Schema.Literal( + "selftune sets import --manifest ", + ), +}) {} + +export class SkillSetSourceComposerError extends Schema.TaggedErrorClass()( + "SkillSetSourceComposerError", + { + reason: Schema.Literals([ + "invalid_input", + "unresolved_source", + "duplicate_component", + "source_binding_mismatch", + ]), + message: Schema.String.check(Schema.isMaxLength(320)), + }, +) {} + +function invalid( + reason: typeof SkillSetSourceComposerError.fields.reason.Type, + message: string, +): SkillSetSourceComposerError { + return SkillSetSourceComposerError.make({ reason, message: message.slice(0, 320) }); +} + +export const composeSkillSetSourcePreviewUnknown = Effect.fn("SkillSetSourceComposer.preview")( + function* (input: Input) { + const decoded = yield* Schema.decodeUnknownEffect(ComposerInput)(input, { + errors: "all", + onExcessProperty: "error", + }).pipe( + Effect.mapError((cause) => + invalid("invalid_input", `Invalid Skill Set source composition: ${cause.message}`), + ), + ); + if (decoded.sources.length === 0 || decoded.harnesses.length === 0) { + return yield* invalid( + "invalid_input", + "Source composition requires at least one source and one harness", + ); + } + const blocked = decoded.sources.find((source) => source.status !== "resolved"); + if (blocked) { + return yield* invalid( + "unresolved_source", + `${blocked.kind} source ${blocked.sourceId} is not pinned: ${blocked.message}`, + ); + } + const resolved = decoded.sources.flatMap((source) => + source.status === "resolved" ? [source.resolution] : [], + ); + const logicalIds = new Set(); + const packageHashes = new Set(); + for (const [index, source] of resolved.entries()) { + const requested = decoded.sources[index]; + if ( + source.sourceId !== requested?.sourceId || + source.kind !== requested.kind || + source.source !== requested.source + ) { + return yield* invalid( + "source_binding_mismatch", + `Resolved source ${source.sourceId} does not match its requested source identity`, + ); + } + if ( + logicalIds.has(source.logicalSkillId) || + packageHashes.has(source.sourcePackageObjectSha256) + ) { + return yield* invalid( + "duplicate_component", + "Every composed skill must have a unique logical id and package object hash", + ); + } + logicalIds.add(source.logicalSkillId); + packageHashes.add(source.sourcePackageObjectSha256); + } + return SkillSetSourceComposerPreview.make({ + format: SKILL_SET_SOURCE_COMPOSER_PREVIEW_FORMAT, + name: decoded.name, + description: decoded.description, + harnesses: decoded.harnesses, + components: resolved, + directInstall: false, + portableImportCommand: "selftune sets import --manifest ", + }); + }, +); + +export type SkillSetSourceComposerInput = typeof ComposerInput.Type; +export const composeSkillSetSourcePreview: ( + input: SkillSetSourceComposerInput, +) => Effect.Effect = + composeSkillSetSourcePreviewUnknown; diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index 27e0ea2b..79c8b170 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -8,3 +8,4 @@ export * from "./programs"; export * from "./reconcile"; export * from "./science"; export * from "./services"; +export * from "./review/portable"; diff --git a/packages/control-plane/src/layers/remote-http.ts b/packages/control-plane/src/layers/remote-http.ts index fce62c2c..f44a8e2c 100644 --- a/packages/control-plane/src/layers/remote-http.ts +++ b/packages/control-plane/src/layers/remote-http.ts @@ -86,6 +86,12 @@ const WireDiagnostics = Schema.Struct({ orphaned_objects: Schema.Array(Schema.String), }); +interface WireSnapshotCommit { + readonly schema_version: "selftune.remote-library.snapshot.v1"; + readonly expected_parent_id: string | null; + readonly artifacts: Array; +} + function unavailable(operation: string, cause: unknown): RemoteLibraryUnavailable { return new RemoteLibraryUnavailable({ operation, @@ -144,7 +150,7 @@ function metadataString( key: string, ): string | null | undefined { const value = metadata[key]; - return value === null || typeof value === "string" ? value : undefined; + return value === null || Schema.is(Schema.String)(value) ? value : undefined; } function toWireArtifact(artifact: RemoteArtifact): typeof WireArtifact.Type { @@ -180,11 +186,7 @@ function fromWireSnapshot(snapshot: typeof WireSnapshot.Type): RemoteSnapshot { }); } -function toWireSnapshot(snapshot: RemoteSnapshot): { - schema_version: "selftune.remote-library.snapshot.v1"; - expected_parent_id: string | null; - artifacts: Array; -} { +function toWireSnapshot(snapshot: RemoteSnapshot): WireSnapshotCommit { return { schema_version: "selftune.remote-library.snapshot.v1", expected_parent_id: snapshot.parentSnapshotId, @@ -213,15 +215,15 @@ export function RemoteLibraryHttp(options: RemoteLibraryHttpOptions) { catch: (cause) => unavailable(operation, cause), }); - const readJson = (operation: string, response: Response) => + const readText = (operation: string, response: Response) => Effect.tryPromise({ - try: (): Promise => response.json(), + try: () => response.text(), catch: (cause) => unavailable(operation, cause), }); const decode = (operation: string, schema: S, response: Response) => - readJson(operation, response).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(schema)), + readText(operation, response).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(schema))), Effect.mapError((cause) => cause instanceof RemoteLibraryUnavailable ? cause : unavailable(operation, cause), ), diff --git a/packages/control-plane/src/library-selection.ts b/packages/control-plane/src/library-selection.ts index 5e77aeac..45546e9a 100644 --- a/packages/control-plane/src/library-selection.ts +++ b/packages/control-plane/src/library-selection.ts @@ -11,13 +11,13 @@ export interface LibraryRevisionChoice { readonly location: LibraryLocation; } -const SOURCE_PRIORITY: Record = { +const SOURCE_PRIORITY = { installed: 5, cached: 4, remote: 3, draft: 2, archived: 1, -}; +} satisfies Record; function compareLocations(left: LibraryLocation, right: LibraryLocation): number { if (left.active !== right.active) return left.active ? -1 : 1; diff --git a/packages/control-plane/src/review/adapters.ts b/packages/control-plane/src/review/adapters.ts new file mode 100644 index 00000000..8c47d6f0 --- /dev/null +++ b/packages/control-plane/src/review/adapters.ts @@ -0,0 +1,196 @@ +import type { RunReviewState, RunReviewView } from "./run-package"; + +interface LocalSourceMergeTarget { + readonly target_path: string; + readonly summary: string; + readonly merged_diff: string; + readonly conflict_files: readonly string[]; +} + +export interface LocalSourceMergeReviewInput { + readonly approval_id: string; + readonly status: string; + readonly skill_name: string; + readonly source: string; + readonly harness_id: string; + readonly model: string | null; + readonly installed_hash: string; + readonly latest_hash: string; + readonly created_at: string; + readonly updated_at: string; + readonly expires_at: string; + readonly receipt: { readonly status: string; readonly receipt_id: string } | null; + readonly failure: { readonly code: string; readonly message: string } | null; + readonly targets: readonly LocalSourceMergeTarget[]; +} + +function localState(status: string): RunReviewState { + if (status === "approved") return "applied"; + if (status === "declined") return "declined"; + if (status === "stale") return "stale"; + if (status === "expired") return "expired"; + if (status === "failed") return "failed"; + return "pending"; +} + +export function adaptLocalSourceMerge(input: LocalSourceMergeReviewInput): RunReviewView { + const state = localState(input.status); + const diffText = input.targets + .map((target) => target.merged_diff) + .filter(Boolean) + .join("\n"); + const outcomeSummary = + state === "applied" + ? `Applied merged source revision ${input.latest_hash}.` + : (input.failure?.message ?? + (state === "declined" + ? "The reviewed merge was declined; installed files were left unchanged." + : "The staged merge has not changed installed files.")); + return { + runId: input.approval_id, + producer: "local_source_merge", + intent: { + title: `Merge ${input.skill_name} source update`, + summary: `Review the staged merge from ${input.source} before updating the installed package.`, + }, + evidence: [ + { label: "Connection", value: input.harness_id }, + { label: "Model", value: input.model ?? "Default model" }, + { label: "Installed revision", value: input.installed_hash }, + { label: "Source revision", value: input.latest_hash }, + ], + candidate: { + summary: input.targets.map((target) => target.summary).join(" "), + diffText: diffText || null, + }, + decision: { + state, + summary: + state === "pending" + ? `Awaiting explicit approval before ${input.expires_at}.` + : outcomeSummary, + }, + validation: { + state: state === "pending" ? "pending" : state === "applied" ? "passed" : state, + summary: + state === "applied" + ? "Source, installed files, and staged candidate fingerprints matched at apply time." + : "Fingerprints will be checked again immediately before apply.", + }, + outcome: { state, summary: outcomeSummary }, + createdAt: input.created_at, + updatedAt: input.updated_at, + }; +} + +interface CloudRunInput { + readonly id: string; + readonly status: string; + readonly phase: string | null; + readonly applyTarget: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly completedAt: string | null; +} + +interface CloudCandidateInput { + readonly id: string; + readonly status: string; + readonly mutationSurface: string | null; + readonly diffText: string | null; + readonly currentSkillScore: number | null; + readonly candidateSkillScore: number | null; + readonly noSkillScore: number | null; + readonly improvementPct: number | null; + readonly archiveUrl: string | null; +} + +export interface CloudImproveReviewInput { + readonly run: CloudRunInput; + readonly winner: CloudCandidateInput | null; + readonly latestApplyAttempt: { readonly success: boolean; readonly prUrl: string | null } | null; + readonly skillName: string; +} + +function score(value: number | null): string { + return value === null ? "Not measured" : value.toFixed(3); +} + +function candidateReview( + summary: string, + winner: CloudCandidateInput | null, +): RunReviewView["candidate"] { + if (winner?.archiveUrl) { + return { + summary, + diffText: winner.diffText, + artifact: { label: "Download candidate package", href: winner.archiveUrl }, + }; + } + return { summary, diffText: winner?.diffText ?? null }; +} + +export function adaptCloudImproveRun(input: CloudImproveReviewInput): RunReviewView { + const { run, winner, latestApplyAttempt } = input; + const applied = latestApplyAttempt?.success === true; + const failedApply = latestApplyAttempt?.success === false; + const hasWinner = winner !== null; + const outcomeState: RunReviewState = applied + ? "applied" + : failedApply + ? "failed" + : run.status === "no_winner" + ? "no_change" + : run.status.startsWith("failed") + ? "failed" + : "pending"; + const validationState: RunReviewState = hasWinner ? "passed" : "blocked"; + const surface = winner?.mutationSurface ?? "skill content"; + const summary = winner + ? `Winning ${surface} candidate improved the score from ${score(winner.currentSkillScore)} to ${score(winner.candidateSkillScore)}.` + : "No candidate passed the hosted improve comparison."; + return { + runId: run.id, + producer: "cloud_improve", + intent: { + title: `Improve ${input.skillName}`, + summary: `Evaluate candidate changes and apply the winning package to ${run.applyTarget}.`, + }, + evidence: winner + ? [ + { label: "Current score", value: score(winner.currentSkillScore) }, + { label: "Candidate score", value: score(winner.candidateSkillScore) }, + { label: "No-skill score", value: score(winner.noSkillScore) }, + { + label: "Improvement", + value: winner.improvementPct === null ? "Not measured" : `${winner.improvementPct}%`, + }, + ] + : [], + candidate: candidateReview(summary, winner), + decision: { + state: applied ? "applied" : hasWinner ? "pending" : outcomeState, + summary: applied + ? "The winning candidate was applied." + : hasWinner + ? "The winning candidate is ready for human review." + : summary, + }, + validation: { + state: validationState, + summary: hasWinner + ? "The candidate beat the current skill and no-skill comparison." + : "No candidate cleared the hosted validation gate.", + }, + outcome: { + state: outcomeState, + summary: applied + ? summary + : failedApply + ? "The latest apply attempt failed; the candidate remains reviewable." + : summary, + }, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + }; +} diff --git a/packages/control-plane/src/review/evaluation-submission.ts b/packages/control-plane/src/review/evaluation-submission.ts new file mode 100644 index 00000000..9aae126f --- /dev/null +++ b/packages/control-plane/src/review/evaluation-submission.ts @@ -0,0 +1,246 @@ +import * as Schema from "effect/Schema"; + +export const EvaluationLaneSchema = Schema.Literals([ + "structural_validation", + "trigger_routing", + "outcome_task", +]); +export type EvaluationLane = typeof EvaluationLaneSchema.Type; +export const EvidenceRoleSchema = Schema.Literals(["calibration", "holdout"]); +export type EvidenceRole = typeof EvidenceRoleSchema.Type; +export const MutationSurfaceSchema = Schema.Literals([ + "body", + "routing", + "description", + "structure", +]); +export type MutationSurface = typeof MutationSurfaceSchema.Type; + +const TraceEvidenceEntrySchema = Schema.Struct({ + role: EvidenceRoleSchema, + query: Schema.String, + should_trigger: Schema.Boolean, + source_reference: Schema.String, + redacted_excerpt: Schema.optional(Schema.String), +}); +export type TraceEvidenceEntry = typeof TraceEvidenceEntrySchema.Type; + +export const EvaluationSubmissionV1Schema = Schema.Struct({ + schema_version: Schema.Literal(1), + submission_id: Schema.String, + idempotency_key: Schema.String, + baseline: Schema.Struct({ + cloud_source_id: Schema.String, + cloud_snapshot_id: Schema.String, + skill_id: Schema.String, + skill_name: Schema.String, + skill_revision: Schema.String, + }), + hypothesis: Schema.Struct({ + pattern_id: Schema.String, + kind: Schema.Literal("repeated_correlated_errors"), + summary: Schema.String, + }), + candidate: Schema.Struct({ + proposal_id: Schema.String, + mutation_surface: MutationSurfaceSchema, + target_revision: Schema.String, + proposed_body: Schema.String, + rationale: Schema.String, + }), + evaluation: Schema.Struct({ + cloud_eval_suite_id: Schema.String, + manifest_digest: Schema.String, + lane: EvaluationLaneSchema, + max_repetitions: Schema.Number, + verification_only: Schema.Boolean, + }), + evidence: Schema.Struct({ + cohort_fingerprint: Schema.String, + selected_trace_count: Schema.Number, + entries: Schema.Array(TraceEvidenceEntrySchema), + }), +}); +export type EvaluationSubmissionV1 = typeof EvaluationSubmissionV1Schema.Type; + +const MAX_TRACE_EVIDENCE_ENTRIES = 14; +const MAX_SELECTED_TRACE_COUNT = 10_000; +const fingerprintPattern = /^sha256:[a-f0-9]{64}$/; +const sensitiveTextPattern = + /(?:bearer\s+|\b(?:api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*)[^\s,;]+/i; +const privateKeyPattern = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/i; +const localPathPattern = /(?:^|[\s"'`])(?:\/(?:[^\s"'`]+)|[a-zA-Z]:\\[^\s"'`]+)/; + +function validateSafeText(value: string, key: string, maximumLength: number): void { + if (value.length === 0 || value.length > maximumLength) { + throw new TypeError(`${key} must be a non-empty string up to ${maximumLength} characters.`); + } + if ( + sensitiveTextPattern.test(value) || + privateKeyPattern.test(value) || + localPathPattern.test(value) + ) { + throw new TypeError(`${key} must not contain a secret or absolute local path.`); + } +} + +function validateInteger(value: number, key: string, minimum: number, maximum: number): void { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new TypeError(`${key} must be an integer from ${minimum} to ${maximum}.`); + } +} + +function validateSemantics(submission: EvaluationSubmissionV1): EvaluationSubmissionV1 { + const textFields: ReadonlyArray = [ + [submission.submission_id, "submission_id", 128], + [submission.idempotency_key, "idempotency_key", 128], + [submission.baseline.cloud_source_id, "cloud_source_id", 128], + [submission.baseline.cloud_snapshot_id, "cloud_snapshot_id", 128], + [submission.baseline.skill_id, "skill_id", 128], + [submission.baseline.skill_name, "skill_name", 160], + [submission.baseline.skill_revision, "skill_revision", 128], + [submission.hypothesis.pattern_id, "pattern_id", 128], + [submission.hypothesis.summary, "summary", 2_000], + [submission.candidate.proposal_id, "proposal_id", 128], + [submission.candidate.target_revision, "target_revision", 128], + [submission.candidate.proposed_body, "proposed_body", 16_000], + [submission.candidate.rationale, "rationale", 2_000], + [submission.evaluation.cloud_eval_suite_id, "cloud_eval_suite_id", 128], + [submission.evaluation.manifest_digest, "manifest_digest", 71], + [submission.evidence.cohort_fingerprint, "cohort_fingerprint", 71], + ]; + for (const [value, key, maximum] of textFields) validateSafeText(value, key, maximum); + + if (!fingerprintPattern.test(submission.evaluation.manifest_digest)) { + throw new TypeError("evaluation manifest_digest is invalid."); + } + if (!fingerprintPattern.test(submission.evidence.cohort_fingerprint)) { + throw new TypeError("evidence cohort_fingerprint is invalid."); + } + validateInteger(submission.evaluation.max_repetitions, "max_repetitions", 1, 100); + validateInteger( + submission.evidence.selected_trace_count, + "selected_trace_count", + 1, + MAX_SELECTED_TRACE_COUNT, + ); + if ( + submission.evidence.entries.length < 1 || + submission.evidence.entries.length > MAX_TRACE_EVIDENCE_ENTRIES + ) { + throw new TypeError( + `evidence entries must contain 1 to ${MAX_TRACE_EVIDENCE_ENTRIES} entries.`, + ); + } + for (const entry of submission.evidence.entries) { + validateSafeText(entry.query, "query", 2_000); + validateSafeText(entry.source_reference, "source_reference", 512); + if (entry.redacted_excerpt !== undefined) { + validateSafeText(entry.redacted_excerpt, "redacted_excerpt", 1_000); + } + } + if (!submission.evidence.entries.some((entry) => entry.role === "calibration")) { + throw new TypeError("evidence entries must include calibration."); + } + if (!submission.evidence.entries.some((entry) => entry.role === "holdout")) { + throw new TypeError("evidence entries must include holdout."); + } + + const { mutation_surface: surface } = submission.candidate; + const { lane, verification_only: verificationOnly } = submission.evaluation; + if (surface === "body" && (lane !== "outcome_task" || verificationOnly)) { + throw new TypeError( + "body candidates require an outcome_task evaluation that can make a winner claim.", + ); + } + if (surface === "routing" && (lane !== "trigger_routing" || verificationOnly)) { + throw new TypeError( + "routing candidates require a trigger_routing evaluation that can make a winner claim.", + ); + } + if ( + (surface === "description" || surface === "structure") && + (lane !== "structural_validation" || !verificationOnly) + ) { + throw new TypeError( + "description and structure candidates require verification-only structural validation.", + ); + } + return submission; +} + +function redactText(value: string): string { + return value + .replace(privateKeyPattern, "[redacted-private-key]") + .replace(/bearer\s+[^\s,;]+/gi, "[redacted]") + .replace( + /\b(api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*[^\s,;]+/gi, + "[redacted]", + ) + .replace(/(?:^|([\s"'`]))(?:\/(?:[^\s"'`]+)|[a-zA-Z]:\\[^\s"'`]+)/g, "$1[local-path]"); +} + +function redactEvidenceEntry(entry: TraceEvidenceEntry): TraceEvidenceEntry { + const redacted = { + role: entry.role, + query: redactText(entry.query), + should_trigger: entry.should_trigger, + source_reference: redactText(entry.source_reference), + }; + if (entry.redacted_excerpt !== undefined) { + return { ...redacted, redacted_excerpt: redactText(entry.redacted_excerpt) }; + } + return redacted; +} + +export function buildEvaluationSubmission(input: EvaluationSubmissionV1): EvaluationSubmissionV1 { + return parseEvaluationSubmission({ + ...input, + submission_id: redactText(input.submission_id), + idempotency_key: redactText(input.idempotency_key), + baseline: { + cloud_source_id: redactText(input.baseline.cloud_source_id), + cloud_snapshot_id: redactText(input.baseline.cloud_snapshot_id), + skill_id: redactText(input.baseline.skill_id), + skill_name: redactText(input.baseline.skill_name), + skill_revision: redactText(input.baseline.skill_revision), + }, + hypothesis: { + ...input.hypothesis, + pattern_id: redactText(input.hypothesis.pattern_id), + summary: redactText(input.hypothesis.summary), + }, + candidate: { + ...input.candidate, + proposal_id: redactText(input.candidate.proposal_id), + target_revision: redactText(input.candidate.target_revision), + proposed_body: redactText(input.candidate.proposed_body), + rationale: redactText(input.candidate.rationale), + }, + evaluation: { + ...input.evaluation, + cloud_eval_suite_id: redactText(input.evaluation.cloud_eval_suite_id), + }, + evidence: { ...input.evidence, entries: input.evidence.entries.map(redactEvidenceEntry) }, + }); +} + +export function parseEvaluationSubmission( + value: typeof Schema.Unknown.Type, +): EvaluationSubmissionV1 { + try { + const submission = Schema.decodeUnknownSync(EvaluationSubmissionV1Schema)(value, { + onExcessProperty: "error", + }); + return validateSemantics(submission); + } catch (cause) { + const message = String(cause); + if (message.includes('at ["run_id"]')) { + throw new TypeError("Evaluation submission contains an unsupported field: run_id.", { + cause, + }); + } + throw cause; + } +} diff --git a/packages/control-plane/src/review/portable.ts b/packages/control-plane/src/review/portable.ts new file mode 100644 index 00000000..ae1421c8 --- /dev/null +++ b/packages/control-plane/src/review/portable.ts @@ -0,0 +1,3 @@ +export * from "./run-package"; +export * from "./adapters"; +export * from "./evaluation-submission"; diff --git a/packages/control-plane/src/review/run-package.ts b/packages/control-plane/src/review/run-package.ts new file mode 100644 index 00000000..5391917a --- /dev/null +++ b/packages/control-plane/src/review/run-package.ts @@ -0,0 +1,120 @@ +import * as Schema from "effect/Schema"; + +export const RunReviewProducerSchema = Schema.Literals(["local_source_merge", "cloud_improve"]); +export type RunReviewProducer = typeof RunReviewProducerSchema.Type; +export const RunReviewStateSchema = Schema.Literals([ + "pending", + "approved", + "declined", + "passed", + "blocked", + "applied", + "rolled_back", + "stale", + "expired", + "failed", + "no_change", +]); +export type RunReviewState = typeof RunReviewStateSchema.Type; + +const RunReviewEvidenceSchema = Schema.Struct({ label: Schema.String, value: Schema.String }); +export type RunReviewEvidence = typeof RunReviewEvidenceSchema.Type; +const RunReviewSectionSchema = Schema.Struct({ + state: RunReviewStateSchema, + summary: Schema.String, +}); + +export interface RunReviewView { + readonly runId: string; + readonly producer: RunReviewProducer; + readonly intent: { readonly title: string; readonly summary: string }; + readonly evidence: readonly RunReviewEvidence[]; + readonly candidate: { + readonly summary: string; + readonly diffText: string | null; + readonly artifact?: { readonly label: string; readonly href: string }; + }; + readonly decision: typeof RunReviewSectionSchema.Type; + readonly validation: typeof RunReviewSectionSchema.Type; + readonly outcome: typeof RunReviewSectionSchema.Type; + readonly createdAt: string; + readonly updatedAt: string; +} + +export const RunPackageV1Schema = Schema.Struct({ + schema_version: Schema.Literal(1), + run_id: Schema.String, + producer: RunReviewProducerSchema, + intent: Schema.Struct({ title: Schema.String, summary: Schema.String }), + evidence: Schema.Array(RunReviewEvidenceSchema), + candidate: Schema.Struct({ summary: Schema.String, diff_text: Schema.NullOr(Schema.String) }), + decision: RunReviewSectionSchema, + validation: RunReviewSectionSchema, + outcome: RunReviewSectionSchema, + created_at: Schema.String, + updated_at: Schema.String, +}); +export type RunPackageV1 = typeof RunPackageV1Schema.Type; + +export interface RunReviewAgentSummary { + readonly run_id: string; + readonly producer: RunReviewProducer; + readonly intent: string; + readonly decision: RunReviewState; + readonly validation: RunReviewState; + readonly outcome: RunReviewState; + readonly summary: string; +} + +function redactText(value: string): string { + return value + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") + .replace(/\b(api[_-]?key|token|secret|password|signature)=([^\s&]+)/gi, "$1=[redacted]") + .replace(/\/(?:Users|home|tmp|private\/tmp|var\/folders)\/[^\s"'`]+/g, "[local-path]"); +} + +export function buildRunPackage(review: RunReviewView): RunPackageV1 { + return RunPackageV1Schema.make({ + schema_version: 1, + run_id: redactText(review.runId), + producer: review.producer, + intent: { title: redactText(review.intent.title), summary: redactText(review.intent.summary) }, + evidence: review.evidence.map((item) => ({ + label: redactText(item.label), + value: redactText(item.value), + })), + candidate: { + summary: redactText(review.candidate.summary), + diff_text: review.candidate.diffText === null ? null : redactText(review.candidate.diffText), + }, + decision: { state: review.decision.state, summary: redactText(review.decision.summary) }, + validation: { state: review.validation.state, summary: redactText(review.validation.summary) }, + outcome: { state: review.outcome.state, summary: redactText(review.outcome.summary) }, + created_at: review.createdAt, + updated_at: review.updatedAt, + }); +} + +export function parseRunPackage(value: typeof Schema.Unknown.Type): RunPackageV1 { + try { + return Schema.decodeUnknownSync(RunPackageV1Schema)(value, { onExcessProperty: "error" }); + } catch (cause) { + const message = String(cause); + if (message.includes('at ["schema_version"]')) { + throw new TypeError(`Unsupported Run Package schema version: ${message}`, { cause }); + } + throw cause; + } +} + +export function summarizeRunReview(review: RunReviewView): RunReviewAgentSummary { + return { + run_id: review.runId, + producer: review.producer, + intent: review.intent.title, + decision: review.decision.state, + validation: review.validation.state, + outcome: review.outcome.state, + summary: review.outcome.summary, + }; +} diff --git a/packages/control-plane/src/science/synthesis.ts b/packages/control-plane/src/science/synthesis.ts index 08bf92e5..0a23b2a8 100644 --- a/packages/control-plane/src/science/synthesis.ts +++ b/packages/control-plane/src/science/synthesis.ts @@ -72,10 +72,12 @@ function collapseCorrelatedSessions( }); } -function splitSessions(sessionIds: ReadonlyArray): { - supporting: string[]; - heldOut: string[]; -} { +interface SessionSplit { + readonly supporting: string[]; + readonly heldOut: string[]; +} + +function splitSessions(sessionIds: ReadonlyArray): SessionSplit { const ranked = [...sessionIds].sort((left, right) => stableHash(left).localeCompare(stableHash(right)), ); @@ -87,12 +89,14 @@ function splitSessions(sessionIds: ReadonlyArray): { }; } -function partitionEvidence(sessions: ReadonlyArray): { - supportingSessionIds: string[]; - heldOutSessionIds: string[]; - supportingExamples: Array; - heldOutExamples: Array; -} { +interface EvidencePartition { + readonly supportingSessionIds: string[]; + readonly heldOutSessionIds: string[]; + readonly supportingExamples: Array; + readonly heldOutExamples: Array; +} + +function partitionEvidence(sessions: ReadonlyArray): EvidencePartition { const split = splitSessions(sessions.map((session) => session.sessionId)); const byId = new Map(sessions.map((session) => [session.sessionId, session])); const examples = (sessionIds: ReadonlyArray) => diff --git a/packages/control-plane/tests/package-bundle.test.ts b/packages/control-plane/tests/package-bundle.test.ts index 1c67a433..25fa500a 100644 --- a/packages/control-plane/tests/package-bundle.test.ts +++ b/packages/control-plane/tests/package-bundle.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { Effect, Encoding } from "effect"; +import { Effect, Encoding, Schema } from "effect"; import { BACKUP_PACKAGE_BUNDLE_PROFILE, @@ -40,11 +40,7 @@ const packageData = ( }>, version: 1 | 2 = 1, releaseAuthority?: PortablePackageReleaseAuthority, -) => ({ - version, - files, - ...(releaseAuthority ? { releaseAuthority } : {}), -}); +) => (releaseAuthority ? { version, files, releaseAuthority } : { version, files }); const packageBytes = ( files: ReadonlyArray<{ @@ -57,7 +53,7 @@ const packageBytes = ( const file = (path: string, content: Uint8Array | string = "content") => ({ path, contentBase64: Encoding.encodeBase64( - typeof content === "string" ? textEncoder.encode(content) : content, + Schema.is(Schema.String)(content) ? textEncoder.encode(content) : content, ), }); @@ -67,8 +63,10 @@ const expectReason = (effect: Effect.Effect, assert.strictEqual(failure.reason, reason); }); -function nestedEvaluation(depth: number): unknown { - let value: unknown = 1; +type NestedEvaluation = number | { readonly value: NestedEvaluation }; + +function nestedEvaluation(depth: number): NestedEvaluation { + let value: NestedEvaluation = 1; for (let index = 0; index < depth; index += 1) value = { value }; return value; } @@ -210,7 +208,10 @@ describe("portable package bundle", () => { it.effect("rejects cyclic encoder authority input through the typed error channel", () => Effect.gen(function* () { - const cyclic: { self?: unknown } = {}; + interface CyclicEvaluation { + self?: CyclicEvaluation; + } + const cyclic: CyclicEvaluation = {}; cyclic.self = cyclic; yield* expectReason( encodePortablePackageBundleUnknown({ @@ -492,7 +493,7 @@ describe("portable package bundle", () => { "decoded_file_too_large", ); }), - 15_000, + 30_000, ); it.effect("preflights encoder file bytes before base64 materialization", () => diff --git a/packages/control-plane/tests/plugin-capability-bundle.test.ts b/packages/control-plane/tests/plugin-capability-bundle.test.ts new file mode 100644 index 00000000..c182422b --- /dev/null +++ b/packages/control-plane/tests/plugin-capability-bundle.test.ts @@ -0,0 +1,133 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + makePluginCapabilityBundle, + projectPluginCapabilityFiles, +} from "../src/domain/plugin-capability-bundle"; + +const revision = "a".repeat(64); + +describe("plugin capability bundle", () => { + it.effect("requires explicit acceptance for executable and credential risk", () => + Effect.gen(function* () { + const result = yield* Effect.result( + makePluginCapabilityBundle({ + bundleId: "engineering-tools", + skillSetId: "engineering", + skillSetRevisionSha256: revision, + mcpServers: { + deploy: { + _tag: "stdio", + command: "npx", + args: ["deploy-mcp", "--token", "${DEPLOY_TOKEN}"], + }, + }, + review: { + reviewedAt: "2026-08-08T00:00:00.000Z", + reviewedBy: "user-1", + acceptedRisks: ["executable_commands"], + }, + }), + ); + assert.isTrue(result._tag === "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.reason, "missing_risk_acceptance"); + } + }), + ); + + it.effect("projects reviewed MCP capabilities into the versioned Agent Plugins file", () => + Effect.gen(function* () { + const bundle = yield* makePluginCapabilityBundle({ + bundleId: "engineering-tools", + skillSetId: "engineering", + skillSetRevisionSha256: revision, + mcpServers: { + docs: { _tag: "streamable-http", url: "https://example.com/mcp" }, + }, + review: { + reviewedAt: "2026-08-08T00:00:00.000Z", + reviewedBy: "user-1", + acceptedRisks: ["remote_network"], + }, + }); + const [file] = projectPluginCapabilityFiles(bundle, "agent-plugins-v1"); + assert.strictEqual(file?.relativePath, "mcp.json"); + assert.include( + new TextDecoder().decode(file?.content), + "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + ); + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(file?.content)), { + $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + mcpServers: { + docs: { type: "streamable-http", url: "https://example.com/mcp" }, + }, + }); + }), + ); + + it.effect("rejects insecure non-local remote MCP URLs", () => + Effect.gen(function* () { + const result = yield* Effect.result( + makePluginCapabilityBundle({ + bundleId: "engineering-tools", + skillSetId: "engineering", + skillSetRevisionSha256: revision, + mcpServers: { docs: { _tag: "sse", url: "http://example.com/mcp" } }, + review: { + reviewedAt: "2026-08-08T00:00:00.000Z", + reviewedBy: "user-1", + acceptedRisks: ["remote_network"], + }, + }), + ); + assert.isTrue(result._tag === "Failure"); + }), + ); + + it.effect("rejects plugin-relative working directories that traverse the package root", () => + Effect.gen(function* () { + const result = yield* Effect.result( + makePluginCapabilityBundle({ + bundleId: "engineering-tools", + skillSetId: "engineering", + skillSetRevisionSha256: revision, + mcpServers: { tools: { _tag: "stdio", command: "node", cwd: "./../outside" } }, + review: { + reviewedAt: "2026-08-08T00:00:00.000Z", + reviewedBy: "user-1", + acceptedRisks: ["executable_commands"], + }, + }), + ); + assert.isTrue(result._tag === "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.reason, "invalid_working_directory"); + } + }), + ); + + it.effect("does not misclassify portable plugin path variables as credentials", () => + Effect.gen(function* () { + const bundle = yield* makePluginCapabilityBundle({ + bundleId: "engineering-tools", + skillSetId: "engineering", + skillSetRevisionSha256: revision, + mcpServers: { + tools: { + _tag: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/server.js", "${PLUGIN_DATA}/cache.db"], + }, + }, + review: { + reviewedAt: "2026-08-08T00:00:00.000Z", + reviewedBy: "user-1", + acceptedRisks: ["executable_commands", "persistent_plugin_data"], + }, + }); + assert.strictEqual(bundle.bundleId, "engineering-tools"); + }), + ); +}); diff --git a/packages/control-plane/tests/portable-plugin-export.test.ts b/packages/control-plane/tests/portable-plugin-export.test.ts new file mode 100644 index 00000000..8ea60332 --- /dev/null +++ b/packages/control-plane/tests/portable-plugin-export.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + createPortablePluginZip, + projectPortablePluginFiles, +} from "../src/domain/portable-plugin-export"; + +const revision = "a".repeat(64); +const skill = { + name: "Research", + files: [ + { path: "reference.md", content: new TextEncoder().encode("Reference\n") }, + { path: "skill.md", content: new TextEncoder().encode("# Research\n") }, + ], +}; + +describe("portable plugin export", () => { + it("projects all manifests and binds Agent Plugins provenance to the sealed revision", () => { + const result = projectPortablePluginFiles({ + target: "all", + name: "Research Set", + description: "Pinned research tools", + skillSetId: "research-set", + skillSetRevisionSha256: revision, + skills: [skill], + }); + const paths = result.files.map((file) => file.path); + expect(paths).toContain(".claude-plugin/plugin.json"); + expect(paths).toContain(".codex-plugin/plugin.json"); + expect(paths).toContain("plugin.json"); + expect(paths).toContain("skills/research/SKILL.md"); + const manifest = result.files.find((file) => file.path === "plugin.json"); + expect(JSON.parse(new TextDecoder().decode(manifest?.content))).toMatchObject({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + extensions: { + "dev.selftune": { + skillSetId: "research-set", + skillSetRevisionSha256: revision, + }, + }, + }); + }); + + it("creates deterministic archives and rejects ambiguous paths", () => { + const files = projectPortablePluginFiles({ + target: "agent-plugins-v1", + name: "Research Set", + description: "Pinned research tools", + skillSetId: "research-set", + skillSetRevisionSha256: revision, + skills: [skill], + }).files; + const reordered = [files.at(-1)!, ...files.slice(0, -1)]; + expect(createPortablePluginZip(files)).toEqual(createPortablePluginZip(reordered)); + expect(() => + createPortablePluginZip([ + { path: "SKILL.md", content: new Uint8Array() }, + { path: "skill.md", content: new Uint8Array() }, + ]), + ).toThrow("duplicate path"); + }); + + it("requires every projected skill to contain a root SKILL.md", () => { + expect(() => + projectPortablePluginFiles({ + target: "openai", + name: "Invalid", + description: "Invalid", + skillSetId: "invalid", + skillSetRevisionSha256: revision, + skills: [{ name: "missing", files: [{ path: "README.md", content: new Uint8Array() }] }], + }), + ).toThrow("root SKILL.md"); + }); +}); diff --git a/packages/control-plane/tests/reconcile-library.test.ts b/packages/control-plane/tests/reconcile-library.test.ts index 7ccb43dc..c0d14372 100644 --- a/packages/control-plane/tests/reconcile-library.test.ts +++ b/packages/control-plane/tests/reconcile-library.test.ts @@ -93,6 +93,7 @@ describe("canonical Library reconciliation", () => { observation({ skillName: "alpha", contentHash: "d".repeat(64) }), ]; const first = yield* reconcileLibrary(input); + // oxlint-disable-next-line unicorn/no-array-reverse -- Reverse a copy to prove input-order invariance on the ES2022 target. const second = yield* reconcileLibrary([...input].reverse()); assert.deepStrictEqual(first.skills, second.skills); diff --git a/packages/control-plane/tests/skill-set-pack.test.ts b/packages/control-plane/tests/skill-set-pack.test.ts new file mode 100644 index 00000000..dd1bfdc7 --- /dev/null +++ b/packages/control-plane/tests/skill-set-pack.test.ts @@ -0,0 +1,67 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + parseSkillSetPackUrl, + renderSkillSetPackLandingPage, + skillSetPackDesktopUrl, + SkillSetPackPreview, +} from "../src/domain/skill-set-pack"; + +const TOKEN = "A".repeat(43); + +describe("Skill Set Pack protocol", () => { + it.effect("derives same-origin preview and content endpoints from a branded URL", () => + Effect.gen(function* () { + const parsed = yield* parseSkillSetPackUrl(`https://team.example/p/${TOKEN}`); + assert.strictEqual( + parsed.previewUrl.href, + `https://team.example/api/v1/public/packs/${TOKEN}`, + ); + assert.strictEqual( + parsed.contentUrl.href, + `https://team.example/api/v1/public/packs/${TOKEN}/content`, + ); + }), + ); + + it.effect("rejects non-branded paths and URL decorations", () => + Effect.gen(function* () { + yield* Effect.flip(parseSkillSetPackUrl(`https://team.example/x/${TOKEN}`)); + yield* Effect.flip( + parseSkillSetPackUrl(`https://team.example/p/${TOKEN}?redirect=elsewhere`), + ); + }), + ); + + it("creates an exact Desktop handoff without putting the Pack URL in a query string", () => { + assert.strictEqual( + skillSetPackDesktopUrl(`https://team.example/p/${TOKEN}`), + `selftune://pack/aHR0cHM6Ly90ZWFtLmV4YW1wbGU/${TOKEN}`, + ); + }); + + it("renders a metadata-first landing page with review and license details", () => { + const preview = new SkillSetPackPreview({ + protocol: "selftune.skill-set-pack.v1", + packId: "pack-1", + artifactId: "skill-set/launch/revision", + name: "Launch review", + description: "A pinned review workflow.", + skillSetRevisionSha256: "a".repeat(64), + objectSha256: "b".repeat(64), + mode: "reusable_unlisted", + expiresAt: "2026-09-07T10:00:00.000Z", + requiresSignIn: false, + components: [{ logicalSkillId: "release-review", licenseExpression: "MIT" }], + }); + const html = renderSkillSetPackLandingPage({ + packUrl: `https://team.example/p/${TOKEN}`, + preview, + }); + assert.match(html, /Open in SelfTune Desktop/); + assert.match(html, /release-review/); + assert.match(html, /MIT/); + assert.match(html, /Desktop opens a review first/); + }); +}); diff --git a/packages/control-plane/tests/skill-set-source-composer.test.ts b/packages/control-plane/tests/skill-set-source-composer.test.ts new file mode 100644 index 00000000..cc51f599 --- /dev/null +++ b/packages/control-plane/tests/skill-set-source-composer.test.ts @@ -0,0 +1,125 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { composeSkillSetSourcePreview } from "../src/domain/skill-set-source-composer"; + +const resolved = (index: number, kind: "skills-sh" | "github" | "folder" | "archive") => ({ + sourceId: `${kind}-${index}`, + kind, + source: `${kind}:source-${index}`, + status: "resolved" as const, + resolution: + kind === "skills-sh" + ? { + sourceId: `${kind}-${index}`, + kind, + source: `${kind}:source-${index}`, + logicalSkillId: `${kind}-${index}`, + displayName: `${kind} ${index}`, + sourceRevisionSha256: String(index + 1).repeat(64), + sourcePackageObjectSha256: String(index + 5).repeat(64), + installSpec: `owner/repo --skill skill-${index}`, + } + : { + sourceId: `${kind}-${index}`, + kind, + source: `${kind}:source-${index}`, + logicalSkillId: `${kind}-${index}`, + displayName: `${kind} ${index}`, + sourceRevisionSha256: String(index + 1).repeat(64), + sourcePackageObjectSha256: String(index + 5).repeat(64), + }, +}); + +describe("Skill Set source composer", () => { + it.effect("previews every supported source kind with exact immutable pins", () => + Effect.gen(function* () { + const preview = yield* composeSkillSetSourcePreview({ + name: "Engineering", + description: "Cross-source set", + harnesses: ["codex"], + sources: [ + resolved(0, "skills-sh"), + resolved(1, "github"), + resolved(2, "folder"), + resolved(3, "archive"), + ], + }); + assert.strictEqual(preview.components.length, 4); + assert.isFalse(preview.directInstall); + assert.include(preview.portableImportCommand, "selftune sets import"); + }), + ); + + it.effect("fails the whole preview when any source is unresolved", () => + Effect.gen(function* () { + const result = yield* Effect.result( + composeSkillSetSourcePreview({ + name: "Engineering", + description: "", + harnesses: ["codex"], + sources: [ + resolved(0, "github"), + { + sourceId: "skills-sh-1", + kind: "skills-sh", + source: "owner/repo", + status: "unresolved", + message: "Exact package revision is unavailable", + }, + ], + }), + ); + assert.isTrue(result._tag === "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.reason, "unresolved_source"); + } + }), + ); + + it.effect("rejects a resolver response bound to a different requested source", () => + Effect.gen(function* () { + const source = resolved(0, "github"); + const result = yield* Effect.result( + composeSkillSetSourcePreview({ + name: "Engineering", + description: "", + harnesses: ["codex"], + sources: [{ ...source, resolution: { ...source.resolution, source: "github:other" } }], + }), + ); + assert.isTrue(result._tag === "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.reason, "source_binding_mismatch"); + } + }), + ); + + it.effect("rejects duplicate logical components instead of emitting a partial preview", () => + Effect.gen(function* () { + const first = resolved(0, "folder"); + const second = resolved(1, "archive"); + const result = yield* Effect.result( + composeSkillSetSourcePreview({ + name: "Engineering", + description: "", + harnesses: ["codex"], + sources: [ + first, + { + ...second, + resolution: { + ...second.resolution, + logicalSkillId: first.resolution.logicalSkillId, + }, + }, + ], + }), + ); + assert.isTrue(result._tag === "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.reason, "duplicate_component"); + } + }), + ); +}); diff --git a/packages/control-plane/tests/synthesis-science.test.ts b/packages/control-plane/tests/synthesis-science.test.ts index 4e5ff751..32cc8a52 100644 --- a/packages/control-plane/tests/synthesis-science.test.ts +++ b/packages/control-plane/tests/synthesis-science.test.ts @@ -198,6 +198,7 @@ describe("synthesis evidence science", () => { session({ id: "6", skills: ["test"], project: "c", day: 14 }), ]; const first = buildCandidateSnapshot(sessions); + // oxlint-disable-next-line unicorn/no-array-reverse -- Reverse a copy to prove input-order invariance on the ES2022 target. const second = buildCandidateSnapshot([...sessions].reverse()); assert.deepStrictEqual(first, second); assert.strictEqual(first.candidates.length, 1); diff --git a/packages/control-plane/vitest.config.ts b/packages/control-plane/vitest.config.ts new file mode 100644 index 00000000..28e3aea5 --- /dev/null +++ b/packages/control-plane/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/dashboard-core/CHANGELOG.md b/packages/dashboard-core/CHANGELOG.md deleted file mode 100644 index 04e10ad1..00000000 --- a/packages/dashboard-core/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @selftune/dashboard-core - -## 1.1.0 - -### Minor Changes - -- [#141](https://github.com/selftune-dev/selftune/pull/141) [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262) Thanks [@WellDunDun](https://github.com/WellDunDun)! - Expose hosted Skill Set persistence and portable export through the shared Projects screen while keeping filesystem installation actions capability-gated. - -### Patch Changes - -- [#141](https://github.com/selftune-dev/selftune/pull/141) [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262) Thanks [@WellDunDun](https://github.com/WellDunDun)! - Render Local source merges and Cloud improve runs through one typed review contract, with a redacted, versioned Run Package for portable and agent-readable summaries. - -- [#141](https://github.com/selftune-dev/selftune/pull/141) [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262) Thanks [@WellDunDun](https://github.com/WellDunDun)! - Add protected This Mac, SelfTune Cloud, and custom Self-host server profiles to the shared dashboard, with explicit validation states, host-state isolation, and native-only Desktop credentials. diff --git a/packages/dashboard-core/package.json b/packages/dashboard-core/package.json index deb582c7..885a6384 100644 --- a/packages/dashboard-core/package.json +++ b/packages/dashboard-core/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/dashboard-core", - "version": "1.1.0", + "version": "1.0.0", "private": true, "description": "Shared dashboard application layer for selftune cloud and local hosts", "license": "MIT", @@ -22,28 +22,25 @@ "./screens/analytics": "./src/screens/analytics/index.ts", "./screens/decisions": "./src/screens/decisions/index.ts", "./screens/overview": "./src/screens/overview/index.ts", + "./screens/plugins": "./src/screens/plugins/index.ts", "./screens/projects": "./src/screens/projects/index.ts", "./screens/recipient-shares": "./src/screens/recipient-shares/index.ts", "./screens/settings": "./src/screens/settings/index.ts", "./screens/skill-report": "./src/screens/skill-report/index.ts", "./screens/skills": "./src/screens/skills/index.ts", + "./screens/team-collaboration": "./src/screens/team-collaboration/index.ts", "./review": "./src/review/index.ts", "./review/portable": "./src/review/portable.ts" }, - "scripts": { - "test": "vitest run" - }, "dependencies": { + "@selftune/control-plane": "workspace:*", "@selftune/ui": "workspace:*", - "lucide-react": "^0.577.0", + "lucide-react": "^0.576.0", "sonner": "^2.0.7" }, "devDependencies": { - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "jsdom": "^29.0.2" + "@types/react-dom": "^19.0.0" }, "peerDependencies": { "react": "^19.0.0", diff --git a/packages/dashboard-core/src/chrome/DashboardChrome.tsx b/packages/dashboard-core/src/chrome/DashboardChrome.tsx index e4464ed0..79b51444 100644 --- a/packages/dashboard-core/src/chrome/DashboardChrome.tsx +++ b/packages/dashboard-core/src/chrome/DashboardChrome.tsx @@ -4,7 +4,7 @@ import { MenuIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TooltipProvider } from "@selftune/ui/primitives"; -import { useOptionalDashboardHostAdapter } from "../host/DashboardHostProvider"; +import { useOptionalChromeModule } from "../host/DashboardHostProvider"; import { DashboardCommandPalette } from "./DashboardCommandPalette"; import { DashboardHeader } from "./DashboardHeader"; @@ -30,7 +30,7 @@ export function DashboardChrome({ contentClassName, children, }: DashboardChromeProps) { - const adapter = useOptionalDashboardHostAdapter(); + const chrome = useOptionalChromeModule(); const [mobileOpen, setMobileOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const openCommands = useCallback(() => { @@ -71,7 +71,7 @@ export function DashboardChrome({ sidebarHeader={sidebarHeader} onOpenCommands={openCommands} sidebarUser={sidebarUser} - serverProfiles={adapter?.profiles} + serverProfiles={chrome?.profiles} cloudProfileConnection={cloudProfileConnection} onSignOut={onSignOut} mobileOpen={mobileOpen} diff --git a/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx b/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx new file mode 100644 index 00000000..1660cc9e --- /dev/null +++ b/packages/dashboard-core/src/chrome/RuntimeBadge.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect } from "storybook/test"; + +import { RuntimeBadge } from "./RuntimeBadge"; + +const meta = { + component: RuntimeBadge, + tags: ["ai-generated"], + args: { + href: "/settings/runtime", + label: "Local runtime", + detail: "Connected", + renderLink: ({ href, className, children, onClick }) => ( + + {children} + + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Healthy: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole("link", { name: /local runtime.*connected/i })).toHaveAttribute( + "href", + "/settings/runtime", + ); + }, +}; + +export const Warning: Story = { + args: { tone: "warning", detail: "Attention needed" }, +}; + +export const Critical: Story = { + args: { tone: "critical", detail: "Disconnected" }, +}; diff --git a/packages/dashboard-core/src/gates/CloudFeatureGateDialog.test.ts b/packages/dashboard-core/src/gates/CloudFeatureGateDialog.test.ts index 064122e1..fbd6a13f 100644 --- a/packages/dashboard-core/src/gates/CloudFeatureGateDialog.test.ts +++ b/packages/dashboard-core/src/gates/CloudFeatureGateDialog.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { cloudFeatureGateContent } from "./CloudFeatureGateDialog"; @@ -12,12 +12,12 @@ describe("cloudFeatureGateContent", () => { expect(content.benefits.flat().join(" ")).toContain("Private by default"); }); - it("describes the currently supported reusable link boundary", () => { + it("distinguishes private sharing from a public link", () => { const content = cloudFeatureGateContent("skill-share"); - expect(content.channels).toEqual(["Copy link"]); - expect(content.description).toContain("expiring link"); - expect(content.benefits.flat().join(" ")).toContain("Direct download"); + expect(content.channels).toEqual(["People", "Workspace"]); + expect(content.description).toContain("invite a person or your workspace"); + expect(content.benefits.flat().join(" ")).toContain("Choose who gets access"); }); it("keeps Skill Set portability as the broad gate", () => { @@ -28,6 +28,6 @@ describe("cloudFeatureGateContent", () => { expect(content.previewName).toBe("Review pack"); expect(content.previewDetail).toBe("3 included skills · Revision 2"); - expect(content.channels).toEqual(["My devices", "Sandboxes", "Copy link"]); + expect(content.channels).toEqual(["My devices", "Sandboxes", "People", "Workspace"]); }); }); diff --git a/packages/dashboard-core/src/gates/CloudFeatureGateDialog.tsx b/packages/dashboard-core/src/gates/CloudFeatureGateDialog.tsx index 1c69fea2..1c6df8a1 100644 --- a/packages/dashboard-core/src/gates/CloudFeatureGateDialog.tsx +++ b/packages/dashboard-core/src/gates/CloudFeatureGateDialog.tsx @@ -42,22 +42,28 @@ export function cloudFeatureGateContent( ): CloudFeatureGateContent { if (kind === "skill-share") { return { - title: "Share this skill by link", + title: "Share this skill privately", description: - "Back up the skill to SelfTune Cloud, then create an expiring link to its exact immutable package without granting repository access or passing around a ZIP file.", - previewLabel: "Shared skill", + "Back up the skill to SelfTune Cloud, then invite a person or your workspace without granting repository access or passing around a ZIP file.", + previewLabel: "Private skill", previewName: context.name ?? "Selected skill", - previewDetail: context.detail ?? "Exact package revision from your Cloud Library", + previewDetail: context.detail ?? "Access is granted from your Cloud Library", benefits: [ - ["Exact revision", "The link downloads the immutable package revision you shared."], - ["Revocable link", "The link expires automatically and can be revoked earlier."], + [ + "Choose who gets access", + "Invite a specific recipient or make it available to your workspace.", + ], + ["Private by default", "Nothing is shared until you explicitly grant access."], [ "No repository setup", "Recipients do not need contributor access to your source repository.", ], - ["Direct download", "Recipients download the package directly from the link."], + [ + "One install flow", + "Recipients can install through SelfTune instead of unpacking and placing files manually.", + ], ], - channels: ["Copy link"], + channels: ["People", "Workspace"], }; } @@ -88,7 +94,7 @@ export function cloudFeatureGateContent( return { title: "Use this Skill Set anywhere", description: - "Keep a private copy in SelfTune Cloud, use the same pinned revisions on your other machines and sandboxes, or share the exact immutable package by reusable link.", + "Keep a private copy in SelfTune Cloud, install the same pinned revisions on your other machines and sandboxes, or share them with people you choose—without creating a repository or passing around ZIP files.", previewLabel: "Portable Skill Set", previewName: context.name ?? "Selected Skill Set", previewDetail: context.detail ?? "Pinned revisions ready for your Cloud Library", @@ -100,11 +106,14 @@ export function cloudFeatureGateContent( ], [ "No repository setup", - "Share a direct package link instead of granting repository access or sending a ZIP.", + "Invite someone directly instead of granting repository access or sending a ZIP.", + ], + [ + "Controlled sharing", + "Use a recipient-bound invite or workspace when a forwardable link is not private enough.", ], - ["Revocable link", "The reusable link expires automatically and can be revoked earlier."], ], - channels: ["My devices", "Sandboxes", "Copy link"], + channels: ["My devices", "Sandboxes", "People", "Workspace"], }; } diff --git a/packages/dashboard-core/src/host/DashboardHostProvider.tsx b/packages/dashboard-core/src/host/DashboardHostProvider.tsx index 7273d599..2dd4809d 100644 --- a/packages/dashboard-core/src/host/DashboardHostProvider.tsx +++ b/packages/dashboard-core/src/host/DashboardHostProvider.tsx @@ -1,28 +1,36 @@ import { createContext, useContext, useMemo, type ReactNode } from "react"; -import type { DashboardHostAdapter } from "./adapter"; +import { + type DashboardOverviewModule, + type DashboardPluginsModule, + type DashboardRecipientSharesModule, + type DashboardSkillSetsModule, + type DashboardSkillsModule, + type DashboardTeamCollaborationModule, +} from "./adapter"; import type { Capabilities, DashboardFeatureKey } from "./capabilities"; -import { capabilitiesFromAdapter, canUseFeature, featureAccessFromAdapter } from "./capabilities"; +import { capabilitiesFromModule, canUseFeature, featureAccessFromModule } from "./capabilities"; +import type { DashboardChromeModule, DashboardHostModules } from "./modules"; export interface DashboardHostContextValue { - adapter: DashboardHostAdapter; + modules: DashboardHostModules; capabilities: Capabilities; } const DashboardHostContext = createContext(null); interface DashboardHostProviderProps { - adapter: DashboardHostAdapter; + modules: DashboardHostModules; children: ReactNode; } -export function DashboardHostProvider({ adapter, children }: DashboardHostProviderProps) { +export function DashboardHostProvider({ modules, children }: DashboardHostProviderProps) { const value = useMemo( () => ({ - adapter, - capabilities: capabilitiesFromAdapter(adapter), + modules, + capabilities: capabilitiesFromModule(modules.capability), }), - [adapter], + [modules], ); return {children}; @@ -40,12 +48,32 @@ export function useOptionalDashboardHost(): DashboardHostContextValue | null { return useContext(DashboardHostContext); } -export function useDashboardHostAdapter(): DashboardHostAdapter { - return useDashboardHost().adapter; +export function useSkillSetsModule(): DashboardSkillSetsModule { + return useDashboardHost().modules.skillSets; } -export function useOptionalDashboardHostAdapter(): DashboardHostAdapter | null { - return useOptionalDashboardHost()?.adapter ?? null; +export function useSkillsModule(): DashboardSkillsModule { + return useDashboardHost().modules.skills; +} + +export function usePluginsModule(): DashboardPluginsModule { + return useDashboardHost().modules.plugins; +} + +export function useRecipientSharesModule(): DashboardRecipientSharesModule { + return useDashboardHost().modules.recipientShares; +} + +export function useTeamCollaborationModule(): DashboardTeamCollaborationModule { + return useDashboardHost().modules.teamCollaboration; +} + +export function useOptionalOverviewModule(): DashboardOverviewModule | null { + return useOptionalDashboardHost()?.modules.overview ?? null; +} + +export function useOptionalChromeModule(): DashboardChromeModule | null { + return useOptionalDashboardHost()?.modules.chrome ?? null; } export function useCapabilities(): Capabilities { @@ -57,5 +85,5 @@ export function useFeatureEnabled(feature: DashboardFeatureKey): boolean { } export function useFeatureAccess(feature: DashboardFeatureKey) { - return featureAccessFromAdapter(useDashboardHostAdapter(), feature); + return featureAccessFromModule(useDashboardHost().modules.capability, feature); } diff --git a/packages/dashboard-core/src/host/adapter.ts b/packages/dashboard-core/src/host/adapter.ts index 0f8b683c..058f3191 100644 --- a/packages/dashboard-core/src/host/adapter.ts +++ b/packages/dashboard-core/src/host/adapter.ts @@ -12,10 +12,15 @@ import type { LibrarySkillBackupReceiptModel, LibraryShareInput, LibraryShareReceiptModel, + LibraryLicenseDraftTerms, + LibraryLicenseDraftPreviewModel, LibraryInstallAgent, LibrarySkillInstallReceiptModel, LibraryUpdateReceiptModel, OverviewModel, + PluginInventoryModel, + PluginManagementInputModel, + PluginManagementReceiptModel, ProjectConflictResolutionInput, ProjectPlanModel, ProjectProvisionInput, @@ -27,6 +32,9 @@ import type { ProjectSkillSetSuggestionReviewInput, ProjectSkillSetDeriveInput, ProjectSkillSetExportInput, + ProjectSkillSetPluginInstallInput, + ProjectSkillSetPluginInstallPreviewModel, + ProjectSkillSetPluginInstallReceiptModel, ProjectSkillSetShareInput, ProjectSkillSetShareReceiptModel, ProjectSkillSetInput, @@ -44,15 +52,11 @@ import type { SkillsModel, CorrectionStudyReviewInput, CorrectionStudyReviewModel, + TeamCollaborationSnapshotModel, + TeamRolloutPolicyModel, } from "../models/index"; import type { ComponentType } from "react"; -import type { - DashboardFeatureContributions, - DashboardFeatureKey, - DashboardHostKind, - DashboardPlan, -} from "./capabilities"; -import type { ServerProfileController } from "./server-profiles"; +import type { DashboardFeatureKey, DashboardHostKind } from "./capabilities"; export interface DashboardUser { id?: string; @@ -132,23 +136,6 @@ export type DashboardLibraryAction = | { readonly access: "upgrade"; readonly href: string } | { readonly access: "unavailable"; readonly reason: string }; -export type DashboardShareDeliveryMethod = "copy_link" | "email"; -export type DashboardShareMode = "reusable_unlisted" | "private_single_claim"; - -export interface DashboardShareActionCapabilities { - /** - * Delivery methods this host can execute now. Omitted means the host supports - * every delivery method represented by the action input for backwards - * compatibility with existing Desktop adapters. - */ - readonly supportedDeliveryMethods?: ReadonlyArray; - /** - * Link modes this host can execute now. Omitted preserves the existing - * Desktop behavior. - */ - readonly supportedShareModes?: ReadonlyArray; -} - export interface DashboardLibraryCreateSurfaceProps { onChanged(): void | Promise; } @@ -165,8 +152,15 @@ export interface DashboardLibraryActions { updateCategory: DashboardLibraryAction; openLocation: DashboardLibraryAction; backup?: DashboardLibraryAction; - share?: DashboardLibraryAction & - DashboardShareActionCapabilities; + share?: DashboardLibraryAction; + previewLicenseDraft?: DashboardLibraryAction< + { skillId: string; terms: LibraryLicenseDraftTerms }, + LibraryLicenseDraftPreviewModel + >; + applyLicenseDraft?: DashboardLibraryAction< + { skillId: string; previewId: string; terms: LibraryLicenseDraftTerms }, + LibraryLicenseDraftPreviewModel + >; installTargets?: ReadonlyArray<{ id: LibraryInstallAgent; label: string }>; install?: DashboardLibraryAction< { skillId: string; targetAgent: LibraryInstallAgent }, @@ -206,6 +200,25 @@ export interface DashboardProjectsQueryState { refresh(): void | Promise; } +export interface DashboardPluginsQueryState { + data: PluginInventoryModel | null; + isLoading: boolean; + error: string | null; + refresh(): void | Promise; +} + +export interface DashboardPluginsActions { + manage: DashboardLibraryAction; +} + +export type DashboardPluginsContribution = + | { + readonly access: "available"; + useInventory(): DashboardPluginsQueryState; + useActions(): DashboardPluginsActions; + } + | { readonly access: "unavailable"; readonly reason: string }; + export type DashboardProjectsIntelligenceQueryState = | { readonly access: "available"; @@ -225,9 +238,29 @@ export interface DashboardProjectsActions { export: DashboardProjectsAction & { readonly requiresProjectRoot?: boolean; readonly label?: string; + readonly formats?: ReadonlyArray<{ + readonly id: import("../models").ProjectSkillSetExportFormat; + readonly label: string; + readonly description: string; + }>; }; - share?: DashboardProjectsAction & - DashboardShareActionCapabilities; + installPlugin?: { + preview: DashboardProjectsAction; + execute: DashboardProjectsAction< + ProjectSkillSetPluginInstallInput, + ProjectSkillSetPluginInstallReceiptModel + >; + }; + share?: DashboardProjectsAction; + importPack?: { + preview: DashboardProjectsAction; + execute: DashboardProjectsAction< + { packUrl: string; expectedObjectSha256: string }, + ProjectSkillSetModel + >; + }; + usePacks?(): import("../models").ProjectSkillSetPacksQueryState; + revokePack?: DashboardProjectsAction; useShareRecipients?(): ReadonlyArray<{ readonly email: string; readonly name: string | null; @@ -293,6 +326,40 @@ export type DashboardProjectsContribution = | { readonly access: "upgrade"; readonly href: string } | { readonly access: "unavailable"; readonly reason: string }; +/** The complete host interface needed by the shared Skill Sets journey. */ +export interface DashboardSkillSetsModule { + readonly projects: DashboardProjectsContribution; + readonly library: DashboardLibraryContribution; +} + +/** The complete host interface needed by the shared Skills Library journey. */ +export interface DashboardSkillsModule { + readonly host: DashboardHostKind; + readonly library: DashboardLibraryContribution; + readonly decisions: DashboardDecisionsContribution; + readonly correctionStudies?: DashboardCorrectionStudiesContribution; +} + +/** The complete host interface needed by the shared Plugins journey. */ +export interface DashboardPluginsModule { + readonly plugins?: DashboardPluginsContribution; +} + +/** The complete host interface needed by the shared recipient-share journey. */ +export interface DashboardRecipientSharesModule { + readonly recipientShares?: DashboardRecipientSharesContribution; +} + +/** The complete host interface needed by the shared team-collaboration journey. */ +export interface DashboardTeamCollaborationModule { + readonly collaboration?: DashboardTeamCollaborationContribution; +} + +/** The optional host interface used by the overview watchlist enhancement. */ +export interface DashboardOverviewModule { + readonly mutations: DashboardHostMutations; +} + export type DashboardRecipientAction = | { readonly access: "available"; @@ -346,22 +413,28 @@ export type DashboardDecisionsContribution = | { readonly access: "upgrade"; readonly href: string } | { readonly access: "unavailable"; readonly reason: string }; -export interface DashboardHostAdapter { - host: DashboardHostKind; - plan: DashboardPlan; - features: DashboardFeatureContributions; - authentication: DashboardHostAuthentication; - queries: DashboardHostQueries; - navigation: DashboardHostNavigation; - mutations: DashboardHostMutations; - permissions: DashboardHostPermissions; - liveUpdates?: DashboardHostLiveUpdates; - library: DashboardLibraryContribution; - projects: DashboardProjectsContribution; - decisions: DashboardDecisionsContribution; - /** Hosts without a local correction-study store expose this as unavailable. */ - correctionStudies?: DashboardCorrectionStudiesContribution; - /** Optional during host migration; the shared route fails closed when absent. */ - recipientShares?: DashboardRecipientSharesContribution; - profiles?: ServerProfileController; +export interface DashboardTeamCollaborationQueryState { + data: TeamCollaborationSnapshotModel | null; + isLoading: boolean; + error: string | null; + refresh(): void | Promise; } + +export interface DashboardTeamCollaborationActions { + updateRolloutPolicy: DashboardLibraryAction< + { entryId: string; policy: TeamRolloutPolicyModel }, + void + >; + adoptContribution: DashboardLibraryAction; + rejectContribution: DashboardLibraryAction; + rollbackContribution: DashboardLibraryAction; +} + +export type DashboardTeamCollaborationContribution = + | { + readonly access: "available"; + useSnapshot(): DashboardTeamCollaborationQueryState; + useActions(): DashboardTeamCollaborationActions; + } + | { readonly access: "upgrade"; readonly href: string } + | { readonly access: "unavailable"; readonly reason: string }; diff --git a/packages/dashboard-core/src/host/capabilities.test.ts b/packages/dashboard-core/src/host/capabilities.test.ts index 91d69da6..f07b16f5 100644 --- a/packages/dashboard-core/src/host/capabilities.test.ts +++ b/packages/dashboard-core/src/host/capabilities.test.ts @@ -1,75 +1,19 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; -import type { DashboardHostAdapter } from "./adapter"; -import { capabilitiesFromAdapter, featureAccessFromAdapter } from "./capabilities"; - -function adapter( - input: Pick, -): DashboardHostAdapter { - return { - ...input, - authentication: { useSession: () => ({ status: "authenticated" }) }, - queries: { - fetchOverview: async () => ({ - version: "test", - summary: { - totalSkills: 0, - avgPassRate30d: null, - unmatchedCount30d: 0, - sessionsCount30d: 0, - pendingCount: 0, - evidenceCount: 0, - }, - autonomy: { - level: "observe", - summary: "", - attentionRequired: false, - skillsObserved: 0, - pendingReviews: 0, - lastRunAt: null, - }, - skillCards: [], - watchlist: [], - attention: [], - decisions: [], - activity: [], - jobs: [], - signals: null, - }), - fetchSkills: async () => ({ items: [] }), - fetchAnalytics: async () => ({ - summary: { - activeSkills: 0, - totalChecks30d: 0, - totalEvolutions: 0, - avgImprovement: 0, - }, - passRateTrend: [], - skillRankings: [], - dailyActivity: [], - evolutionImpact: [], - }), - }, - navigation: { upgrade: "/upgrade", openUpgrade() {} }, - mutations: {}, - permissions: { can: () => true }, - library: { access: "unavailable", reason: "Not used by this capability test." }, - projects: { access: "unavailable", reason: "Not used by this capability test." }, - decisions: { access: "unavailable", reason: "Not used by this capability test." }, - }; -} +import type { DashboardCapabilityModule } from "./capabilities"; +import { capabilitiesFromModule, featureAccessFromModule } from "./capabilities"; describe("adapter-derived dashboard capabilities", () => { it("derives usable and discoverable features without a separate host matrix", () => { - const selfhost = adapter({ + const selfhost: DashboardCapabilityModule = { host: "selfhost", plan: "team", features: { analytics: { access: "available" }, registry: { access: "upgrade", href: "/upgrade/registry" }, }, - }); - const capabilities = capabilitiesFromAdapter(selfhost); + }; + const capabilities = capabilitiesFromModule(selfhost); expect(capabilities.host).toBe("selfhost"); expect(capabilities.features.analytics).toBe(true); @@ -77,11 +21,11 @@ describe("adapter-derived dashboard capabilities", () => { expect(capabilities.discoverable.registry).toBe(true); expect(capabilities.features.proposals).toBe(false); expect(capabilities.discoverable.proposals).toBe(false); - expect(featureAccessFromAdapter(selfhost, "registry")).toEqual({ + expect(featureAccessFromModule(selfhost, "registry")).toEqual({ access: "upgrade", href: "/upgrade/registry", }); - expect(featureAccessFromAdapter(selfhost, "proposals")).toEqual({ + expect(featureAccessFromModule(selfhost, "proposals")).toEqual({ access: "unavailable", reason: "This server does not provide this capability.", }); diff --git a/packages/dashboard-core/src/host/capabilities.ts b/packages/dashboard-core/src/host/capabilities.ts index 3c9a043a..d8f822e8 100644 --- a/packages/dashboard-core/src/host/capabilities.ts +++ b/packages/dashboard-core/src/host/capabilities.ts @@ -37,14 +37,14 @@ export interface Capabilities { discoverable: DashboardDiscoverableFlags; } -interface CapabilityContributor { +export interface DashboardCapabilityModule { readonly host: DashboardHostKind; readonly plan: DashboardPlan; readonly features: DashboardFeatureContributions; } -export function featureAccessFromAdapter( - adapter: CapabilityContributor, +export function featureAccessFromModule( + adapter: DashboardCapabilityModule, feature: DashboardFeatureKey, ): DashboardFeatureAccess { return ( @@ -55,7 +55,7 @@ export function featureAccessFromAdapter( ); } -export function capabilitiesFromAdapter(adapter: CapabilityContributor): Capabilities { +export function capabilitiesFromModule(adapter: DashboardCapabilityModule): Capabilities { return { host: adapter.host, plan: adapter.plan, diff --git a/packages/dashboard-core/src/host/index.ts b/packages/dashboard-core/src/host/index.ts index abb6c81d..46421d55 100644 --- a/packages/dashboard-core/src/host/index.ts +++ b/packages/dashboard-core/src/host/index.ts @@ -1,5 +1,6 @@ export * from "./adapter"; export * from "./capabilities"; export * from "./DashboardHostProvider"; +export * from "./modules"; export * from "./server-profiles"; export * from "./browser-server-profiles"; diff --git a/packages/dashboard-core/src/host/modules.ts b/packages/dashboard-core/src/host/modules.ts new file mode 100644 index 00000000..1207273a --- /dev/null +++ b/packages/dashboard-core/src/host/modules.ts @@ -0,0 +1,25 @@ +import type { + DashboardOverviewModule, + DashboardPluginsModule, + DashboardRecipientSharesModule, + DashboardSkillSetsModule, + DashboardSkillsModule, + DashboardTeamCollaborationModule, +} from "./adapter"; +import type { DashboardCapabilityModule } from "./capabilities"; +import type { ServerProfileController } from "./server-profiles"; + +export interface DashboardChromeModule { + readonly profiles?: ServerProfileController; +} + +export interface DashboardHostModules { + readonly capability: DashboardCapabilityModule; + readonly skillSets: DashboardSkillSetsModule; + readonly skills: DashboardSkillsModule; + readonly plugins: DashboardPluginsModule; + readonly recipientShares: DashboardRecipientSharesModule; + readonly teamCollaboration: DashboardTeamCollaborationModule; + readonly overview?: DashboardOverviewModule; + readonly chrome?: DashboardChromeModule; +} diff --git a/packages/dashboard-core/src/models/correction-studies.ts b/packages/dashboard-core/src/models/correction-studies.ts index 045433de..2439acfa 100644 --- a/packages/dashboard-core/src/models/correction-studies.ts +++ b/packages/dashboard-core/src/models/correction-studies.ts @@ -15,6 +15,29 @@ export interface CorrectionStudyReviewModel { >; } +/** + * Evidence levels that may ask a reviewer for a decision. + * + * E0 and E0.5 are hypotheses: a correlated trace, optionally with a stated + * correction intent, but no replay. Presenting one in a decision queue asks the + * reviewer to judge whether a change is an improvement using nothing at all, + * which is the evaluation work this product exists to perform on their behalf. + * They remain in the evidence ledger and belong on the skill's own surface as + * watched signals. + */ +const DECISION_READY_EVIDENCE: ReadonlySet = new Set< + CorrectionStudyReviewModel["evidenceLevel"] +>(["E1", "E2"]); + +/** + * Whether a study has enough evidence to be worth a human decision. A replayed + * level without an evaluation result is also excluded: the level claims a + * comparison that the payload cannot show. + */ +export function isDecisionReady(review: CorrectionStudyReviewModel): boolean { + return DECISION_READY_EVIDENCE.has(review.evidenceLevel) && review.evaluation !== null; +} + export interface CorrectionStudyReviewInput { readonly candidateId: string; readonly action: Exclude; diff --git a/packages/dashboard-core/src/models/index.ts b/packages/dashboard-core/src/models/index.ts index 7f8b8ad1..c6aed904 100644 --- a/packages/dashboard-core/src/models/index.ts +++ b/packages/dashboard-core/src/models/index.ts @@ -7,3 +7,5 @@ export * from "./library"; export * from "./projects"; export * from "./decisions"; export * from "./recipient-shares"; +export * from "./team-collaboration"; +export * from "./plugins"; diff --git a/packages/dashboard-core/src/models/library.ts b/packages/dashboard-core/src/models/library.ts index 73680579..e3dce021 100644 --- a/packages/dashboard-core/src/models/library.ts +++ b/packages/dashboard-core/src/models/library.ts @@ -189,6 +189,19 @@ export interface LibraryShareReceiptModel { expiresAt: string; } +export interface LibraryLicenseDraftTerms { + copyrightHolder: string; + licensedOrganization: string; + year: number; +} + +export interface LibraryLicenseDraftPreviewModel { + previewId: string; + skillPath: string; + licenseExpression: string; + files: ReadonlyArray<{ path: "SKILL.md" | "LICENSE"; patch: string }>; +} + export type LibraryInstallAgent = "codex" | "claude_code" | "opencode" | "openclaw" | "pi"; export interface LibrarySkillInstallReceiptModel { diff --git a/packages/dashboard-core/src/models/plugins.ts b/packages/dashboard-core/src/models/plugins.ts new file mode 100644 index 00000000..6da319b0 --- /dev/null +++ b/packages/dashboard-core/src/models/plugins.ts @@ -0,0 +1,55 @@ +export type PluginHostModel = "claude" | "codex"; + +export type PluginManagementActionModel = "update" | "enable" | "disable" | "remove"; + +export interface PluginHostStatusModel { + host: PluginHostModel; + label: string; + status: "available" | "unavailable" | "error"; + installedCount: number; + message: string | null; +} + +export interface PluginHostInstallationModel { + host: PluginHostModel; + hostLabel: string; + pluginId: string; + version: string | null; + enabled: boolean; + scope: string | null; + sourceType: "marketplace" | "local" | "managed" | "unknown"; + sourceLabel: string; + managedBySelfTune: boolean; + availableActions: PluginManagementActionModel[]; +} + +export interface PluginInventoryItemModel { + pluginId: string; + name: string; + marketplaceName: string; + installations: PluginHostInstallationModel[]; + managedBySelfTune: boolean; + versionDrift: boolean; +} + +export interface PluginInventoryModel { + hosts: PluginHostStatusModel[]; + plugins: PluginInventoryItemModel[]; + totalPlugins: number; + managedPlugins: number; + refreshedAt: string; +} + +export interface PluginManagementInputModel { + host: PluginHostModel; + pluginId: string; + action: PluginManagementActionModel; +} + +export interface PluginManagementReceiptModel { + host: PluginHostModel; + pluginId: string; + action: PluginManagementActionModel; + completedAt: string; + inventory: PluginInventoryModel; +} diff --git a/packages/dashboard-core/src/models/projects.ts b/packages/dashboard-core/src/models/projects.ts index 92346202..8ca3525a 100644 --- a/packages/dashboard-core/src/models/projects.ts +++ b/packages/dashboard-core/src/models/projects.ts @@ -334,6 +334,59 @@ export interface ProjectSkillSetTargetInput { export interface ProjectSkillSetExportInput { skillSetId: string; projectRoot?: string; + format?: ProjectSkillSetExportFormat; +} + +export type ProjectSkillSetExportFormat = + | "portable" + | "claude" + | "openai" + | "agent-plugins-v1" + | "dual" + | "all"; + +export type ProjectSkillSetPluginHost = "claude" | "codex"; + +export interface ProjectSkillSetPluginHostPreviewModel { + host: ProjectSkillSetPluginHost; + label: string; + available: boolean; + installedVersion: string | null; + status: "unavailable" | "ready" | "already_current" | "update_available"; + activation: string; +} + +export interface ProjectSkillSetPluginInstallPreviewModel { + setId: string; + setName: string; + revisionHash: string; + pluginName: string; + pluginVersion: string; + marketplaceName: string; + skillNames: ReadonlyArray; + hosts: ReadonlyArray; +} + +export interface ProjectSkillSetPluginInstallInput { + skillSetId: string; + expectedRevisionHash: string; + hosts: ReadonlyArray; +} + +export interface ProjectSkillSetPluginInstallReceiptModel { + setId: string; + setName: string; + revisionHash: string; + pluginName: string; + pluginVersion: string; + marketplaceName: string; + installedAt: string; + hosts: ReadonlyArray<{ + host: ProjectSkillSetPluginHost; + pluginId: string; + result: "installed" | "updated" | "already_current"; + activation: string; + }>; } export type ProjectSkillSetShareInput = @@ -357,6 +410,44 @@ export interface ProjectSkillSetShareReceiptModel { expiresAt: string; } +export interface ProjectSkillSetPackPreviewModel { + packUrl: string; + packId: string; + name: string; + description: string; + mode: "reusable_unlisted" | "private_single_claim"; + expiresAt: string; + skillSetRevisionSha256: string; + objectSha256: string; + components: Array<{ logicalSkillId: string; licenseExpression: string }>; +} + +export type ProjectSkillSetPackStatusModel = "active" | "claimed" | "expired" | "revoked"; + +export interface ProjectSkillSetPackModel { + packId: string; + artifactId: string; + name: string; + description: string; + mode: "reusable_unlisted" | "private_single_claim"; + status: ProjectSkillSetPackStatusModel; + packUrl: string | null; + expiresAt: string; + createdAt: string; + claimedAt: string | null; + revokedAt: string | null; + skillSetRevisionSha256: string; + objectSha256: string; + componentCount: number; +} + +export interface ProjectSkillSetPacksQueryState { + data: ReadonlyArray | null; + isLoading: boolean; + error: string | null; + refresh(): void | Promise; +} + export interface ProjectConflictResolutionInput extends ProjectSkillSetTargetInput { operation: ProjectPlanOperationModel; } diff --git a/packages/dashboard-core/src/models/team-collaboration.ts b/packages/dashboard-core/src/models/team-collaboration.ts new file mode 100644 index 00000000..25d273e9 --- /dev/null +++ b/packages/dashboard-core/src/models/team-collaboration.ts @@ -0,0 +1,80 @@ +export type TeamRolloutPolicyModel = "manual" | "notify" | "automatic"; +export type TeamContributionStatusModel = + | "pending" + | "rejected" + | "adopted" + | "stale" + | "rolled_back"; + +export interface TeamContributionFileModel { + path: string; + hash: string; + size: number; +} + +export interface TeamContributionChangeModel { + path: string; + kind: "added" | "modified" | "removed"; + baseHash: string | null; + candidateHash: string | null; +} + +export interface TeamContributionEfficacyEvidenceModel { + summary: string; + evaluatedCases: number; + passedCases: number; + regressionCount: number; +} + +export interface TeamRevisionContributionModel { + id: string; + entryId: string; + entryName: string; + baseVersionId: string; + baseVersion: string; + candidateVersion: string; + candidateContentHash: string; + files: TeamContributionFileModel[]; + changes: TeamContributionChangeModel[]; + efficacyEvidence?: TeamContributionEfficacyEvidenceModel | null; + summary: string; + submittedBy: string; + submittedByName: string; + status: TeamContributionStatusModel; + reviewedBy: string | null; + adoptedVersionId: string | null; + createdAt: string; + reviewedAt: string | null; +} + +export interface TeamManagedInstallationModel { + id: string; + entryId: string; + entryName: string; + deviceId: string; + installedVersion: string; + installedContentHash: string | null; + latestVersion: string; + latestContentHash: string; + rolloutPolicy: TeamRolloutPolicyModel; + updateStatus: "current" | "update_available" | "updated" | "conflict" | "failed" | "rolled_back"; + lastSyncedAt: string; + lastConflictAt: string | null; + lastReceiptId: string | null; +} + +export interface TeamRegistryEntryModel { + id: string; + name: string; + rolloutPolicy: TeamRolloutPolicyModel; + currentVersion: string | null; + pendingContributions: number; + installations: number; + conflicts: number; +} + +export interface TeamCollaborationSnapshotModel { + entries: TeamRegistryEntryModel[]; + contributions: TeamRevisionContributionModel[]; + installations: TeamManagedInstallationModel[]; +} diff --git a/packages/dashboard-core/src/review/adapters.ts b/packages/dashboard-core/src/review/adapters.ts index 5b3d60c1..59f08bb7 100644 --- a/packages/dashboard-core/src/review/adapters.ts +++ b/packages/dashboard-core/src/review/adapters.ts @@ -1,189 +1 @@ -import type { RunReviewState, RunReviewView } from "./run-package"; - -interface LocalSourceMergeTarget { - readonly target_path: string; - readonly summary: string; - readonly merged_diff: string; - readonly conflict_files: readonly string[]; -} - -export interface LocalSourceMergeReviewInput { - readonly approval_id: string; - readonly status: string; - readonly skill_name: string; - readonly source: string; - readonly harness_id: string; - readonly model: string | null; - readonly installed_hash: string; - readonly latest_hash: string; - readonly created_at: string; - readonly updated_at: string; - readonly expires_at: string; - readonly receipt: { readonly status: string; readonly receipt_id: string } | null; - readonly failure: { readonly code: string; readonly message: string } | null; - readonly targets: readonly LocalSourceMergeTarget[]; -} - -function localState(status: string): RunReviewState { - if (status === "approved") return "applied"; - if (status === "declined") return "declined"; - if (status === "stale") return "stale"; - if (status === "expired") return "expired"; - if (status === "failed") return "failed"; - return "pending"; -} - -export function adaptLocalSourceMerge(input: LocalSourceMergeReviewInput): RunReviewView { - const state = localState(input.status); - const diffText = input.targets - .map((target) => target.merged_diff) - .filter(Boolean) - .join("\n"); - const outcomeSummary = - state === "applied" - ? `Applied merged source revision ${input.latest_hash}.` - : (input.failure?.message ?? - (state === "declined" - ? "The reviewed merge was declined; installed files were left unchanged." - : "The staged merge has not changed installed files.")); - return { - runId: input.approval_id, - producer: "local_source_merge", - intent: { - title: `Merge ${input.skill_name} source update`, - summary: `Review the staged merge from ${input.source} before updating the installed package.`, - }, - evidence: [ - { label: "Connection", value: input.harness_id }, - { label: "Model", value: input.model ?? "Default model" }, - { label: "Installed revision", value: input.installed_hash }, - { label: "Source revision", value: input.latest_hash }, - ], - candidate: { - summary: input.targets.map((target) => target.summary).join(" "), - diffText: diffText || null, - }, - decision: { - state, - summary: - state === "pending" - ? `Awaiting explicit approval before ${input.expires_at}.` - : outcomeSummary, - }, - validation: { - state: state === "pending" ? "pending" : state === "applied" ? "passed" : state, - summary: - state === "applied" - ? "Source, installed files, and staged candidate fingerprints matched at apply time." - : "Fingerprints will be checked again immediately before apply.", - }, - outcome: { state, summary: outcomeSummary }, - createdAt: input.created_at, - updatedAt: input.updated_at, - }; -} - -interface CloudRunInput { - readonly id: string; - readonly status: string; - readonly phase: string | null; - readonly applyTarget: string; - readonly createdAt: string; - readonly updatedAt: string; - readonly completedAt: string | null; -} - -interface CloudCandidateInput { - readonly id: string; - readonly status: string; - readonly mutationSurface: string | null; - readonly diffText: string | null; - readonly currentSkillScore: number | null; - readonly candidateSkillScore: number | null; - readonly noSkillScore: number | null; - readonly improvementPct: number | null; - readonly summaryJson: Record; - readonly archiveUrl: string | null; -} - -export interface CloudImproveReviewInput { - readonly run: CloudRunInput; - readonly winner: CloudCandidateInput | null; - readonly latestApplyAttempt: { readonly success: boolean; readonly prUrl: string | null } | null; - readonly skillName: string; -} - -function score(value: number | null): string { - return value === null ? "Not measured" : value.toFixed(3); -} - -export function adaptCloudImproveRun(input: CloudImproveReviewInput): RunReviewView { - const { run, winner, latestApplyAttempt } = input; - const applied = latestApplyAttempt?.success === true; - const failedApply = latestApplyAttempt?.success === false; - const hasWinner = winner !== null; - const outcomeState: RunReviewState = applied - ? "applied" - : failedApply - ? "failed" - : run.status === "no_winner" - ? "no_change" - : run.status.startsWith("failed") - ? "failed" - : "pending"; - const validationState: RunReviewState = hasWinner ? "passed" : "blocked"; - const surface = winner?.mutationSurface ?? "skill content"; - const summary = winner - ? `Winning ${surface} candidate improved the score from ${score(winner.currentSkillScore)} to ${score(winner.candidateSkillScore)}.` - : "No candidate passed the hosted improve comparison."; - return { - runId: run.id, - producer: "cloud_improve", - intent: { - title: `Improve ${input.skillName}`, - summary: `Evaluate candidate changes and apply the winning package to ${run.applyTarget}.`, - }, - evidence: winner - ? [ - { label: "Current score", value: score(winner.currentSkillScore) }, - { label: "Candidate score", value: score(winner.candidateSkillScore) }, - { label: "No-skill score", value: score(winner.noSkillScore) }, - { - label: "Improvement", - value: winner.improvementPct === null ? "Not measured" : `${winner.improvementPct}%`, - }, - ] - : [], - candidate: { - summary, - diffText: winner?.diffText ?? null, - ...(winner?.archiveUrl - ? { artifact: { label: "Download candidate package", href: winner.archiveUrl } } - : {}), - }, - decision: { - state: applied ? "applied" : hasWinner ? "pending" : outcomeState, - summary: applied - ? "The winning candidate was applied." - : hasWinner - ? "The winning candidate is ready for human review." - : summary, - }, - validation: { - state: validationState, - summary: hasWinner - ? "The candidate beat the current skill and no-skill comparison." - : "No candidate cleared the hosted validation gate.", - }, - outcome: { - state: outcomeState, - summary: applied - ? summary - : failedApply - ? "The latest apply attempt failed; the candidate remains reviewable." - : summary, - }, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - }; -} +export * from "@selftune/control-plane/review/portable"; diff --git a/packages/dashboard-core/src/review/evaluation-submission.test.ts b/packages/dashboard-core/src/review/evaluation-submission.test.ts index 810fb00a..0a92d371 100644 --- a/packages/dashboard-core/src/review/evaluation-submission.test.ts +++ b/packages/dashboard-core/src/review/evaluation-submission.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { buildEvaluationSubmission, @@ -132,40 +132,6 @@ describe("evaluation submission", () => { expect(built.candidate.rationale).toContain("[local-path]"); }); - it("redacts a typed private-key block with a linear near-miss scan", () => { - const value = submission(); - const privateKey = - "-----begin rsa private key-----\nprivate material\n-----end ec private key-----"; - const unsafe = { - ...value, - candidate: { - ...value.candidate, - rationale: `before ${privateKey} after`, - }, - }; - - expect(() => parseEvaluationSubmission(unsafe)).toThrow("secret or absolute local path"); - expect(buildEvaluationSubmission(unsafe).candidate.rationale).toBe( - "before [redacted-private-key] after", - ); - - const twoBlocks = { - ...value, - candidate: { - ...value.candidate, - rationale: `${privateKey} between ${privateKey}`, - }, - }; - expect(() => buildEvaluationSubmission(twoBlocks)).toThrow("secret or absolute local path"); - - const nearMiss = `-----BEGIN ${"PRIVATE KEY ".repeat(1_000)}!`; - const nearMissSubmission = { - ...value, - candidate: { ...value.candidate, proposed_body: nearMiss }, - }; - expect(buildEvaluationSubmission(nearMissSubmission).candidate.proposed_body).toBe(nearMiss); - }); - it("keeps a frozen Cloud suite as the evaluation authority", () => { const built = buildEvaluationSubmission(submission()); diff --git a/packages/dashboard-core/src/review/evaluation-submission.ts b/packages/dashboard-core/src/review/evaluation-submission.ts index 25638a86..59f08bb7 100644 --- a/packages/dashboard-core/src/review/evaluation-submission.ts +++ b/packages/dashboard-core/src/review/evaluation-submission.ts @@ -1,420 +1 @@ -export type EvaluationLane = "structural_validation" | "trigger_routing" | "outcome_task"; -export type EvidenceRole = "calibration" | "holdout"; -export type MutationSurface = "body" | "routing" | "description" | "structure"; - -export interface EvaluationSubmissionV1 { - readonly schema_version: 1; - readonly submission_id: string; - readonly idempotency_key: string; - readonly baseline: { - readonly cloud_source_id: string; - readonly cloud_snapshot_id: string; - readonly skill_id: string; - readonly skill_name: string; - readonly skill_revision: string; - }; - readonly hypothesis: { - readonly pattern_id: string; - readonly kind: "repeated_correlated_errors"; - readonly summary: string; - }; - readonly candidate: { - readonly proposal_id: string; - readonly mutation_surface: MutationSurface; - readonly target_revision: string; - readonly proposed_body: string; - readonly rationale: string; - }; - readonly evaluation: { - readonly cloud_eval_suite_id: string; - readonly manifest_digest: string; - readonly lane: EvaluationLane; - readonly max_repetitions: number; - /** Structural verification cannot be reported as a candidate winner. */ - readonly verification_only: boolean; - }; - readonly evidence: { - readonly cohort_fingerprint: string; - readonly selected_trace_count: number; - readonly entries: readonly TraceEvidenceEntry[]; - }; -} - -/** Bounded trace-derived hypothesis evidence, never an executable Cloud eval case. */ -export interface TraceEvidenceEntry { - readonly role: EvidenceRole; - readonly query: string; - readonly should_trigger: boolean; - readonly source_reference: string; - readonly redacted_excerpt?: string; -} - -const MAX_TRACE_EVIDENCE_ENTRIES = 14; -const MAX_SELECTED_TRACE_COUNT = 10_000; -const fingerprintPattern = /^sha256:[a-f0-9]{64}$/; -const sensitiveTextPattern = - /(?:bearer\s+|\b(?:api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*)[^\s,;]+/i; -const localPathPattern = /(?:^|[\s"'`])(?:\/(?:[^\s"'`]+)|[a-zA-Z]:\\[^\s"'`]+)/; -const PRIVATE_KEY_BEGIN = "-----BEGIN "; -const PRIVATE_KEY_END = "-----END "; -const PRIVATE_KEY_LABEL = "PRIVATE KEY"; -const PRIVATE_KEY_FENCE = "-----"; -const PRIVATE_KEY_REPLACEMENT = "[redacted-private-key]"; - -interface TextRange { - readonly start: number; - readonly end: number; -} - -function matchesAsciiCaseInsensitive(value: string, start: number, expected: string): boolean { - if (start < 0 || start + expected.length > value.length) return false; - for (let offset = 0; offset < expected.length; offset += 1) { - const actualCode = value.charCodeAt(start + offset); - const upperCode = actualCode >= 97 && actualCode <= 122 ? actualCode - 32 : actualCode; - if (upperCode !== expected.charCodeAt(offset)) return false; - } - return true; -} - -function isAsciiLetterOrSpace(value: string, index: number): boolean { - const code = value.charCodeAt(index); - return code === 32 || (code >= 65 && code <= 90) || (code >= 97 && code <= 122); -} - -function findPrivateKeyMarker( - value: string, - prefix: typeof PRIVATE_KEY_BEGIN | typeof PRIVATE_KEY_END, - fromIndex: number, -): TextRange | null { - const lastCandidate = value.length - prefix.length; - for (let candidate = Math.max(0, fromIndex); candidate <= lastCandidate; candidate += 1) { - if (!matchesAsciiCaseInsensitive(value, candidate, prefix)) continue; - - const labelStart = candidate + prefix.length; - let cursor = labelStart; - while (cursor < value.length && isAsciiLetterOrSpace(value, cursor)) cursor += 1; - - const keyLabelStart = cursor - PRIVATE_KEY_LABEL.length; - if ( - keyLabelStart >= labelStart && - matchesAsciiCaseInsensitive(value, keyLabelStart, PRIVATE_KEY_LABEL) && - value.startsWith(PRIVATE_KEY_FENCE, cursor) - ) { - return { start: candidate, end: cursor + PRIVATE_KEY_FENCE.length }; - } - - // A marker prefix cannot start inside the ASCII label run, so skip it in one pass. - candidate = Math.max(candidate, cursor - 1); - } - return null; -} - -function findPrivateKeyBlock(value: string, fromIndex = 0): TextRange | null { - const begin = findPrivateKeyMarker(value, PRIVATE_KEY_BEGIN, fromIndex); - if (begin === null) return null; - const end = findPrivateKeyMarker(value, PRIVATE_KEY_END, begin.end); - return end === null ? null : { start: begin.start, end: end.end }; -} - -function redactFirstPrivateKeyBlock(value: string): string { - const block = findPrivateKeyBlock(value); - return block === null - ? value - : `${value.slice(0, block.start)}${PRIVATE_KEY_REPLACEMENT}${value.slice(block.end)}`; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasOwn(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} - -function assertExactKeys( - record: Record, - keys: readonly string[], - label: string, -): void { - for (const key of Object.keys(record)) { - if (!keys.includes(key)) throw new TypeError(`${label} contains an unsupported field: ${key}.`); - } - for (const key of keys) { - if (!hasOwn(record, key)) throw new TypeError(`${label} is missing ${key}.`); - } -} - -function readRecord(value: unknown, label: string): Record { - if (!isRecord(value)) throw new TypeError(`${label} must be an object.`); - return value; -} - -function readSafeText(record: Record, key: string, maxLength: number): string { - const value = record[key]; - if (typeof value !== "string" || value.length === 0 || value.length > maxLength) { - throw new TypeError(`${key} must be a non-empty string up to ${maxLength} characters.`); - } - if ( - sensitiveTextPattern.test(value) || - findPrivateKeyBlock(value) !== null || - localPathPattern.test(value) - ) { - throw new TypeError(`${key} must not contain a secret or absolute local path.`); - } - return value; -} - -function readInteger( - record: Record, - key: string, - minimum: number, - maximum: number, -): number { - const value = record[key]; - if (!Number.isInteger(value) || typeof value !== "number" || value < minimum || value > maximum) { - throw new TypeError(`${key} must be an integer from ${minimum} to ${maximum}.`); - } - return value; -} - -function readLane(value: unknown): EvaluationLane { - if ( - value === "structural_validation" || - value === "trigger_routing" || - value === "outcome_task" - ) { - return value; - } - throw new TypeError("evaluation lane is invalid."); -} - -function readEvidenceRole(value: unknown): EvidenceRole { - if (value === "calibration" || value === "holdout") return value; - throw new TypeError("evidence entry role is invalid."); -} - -function readMutationSurface(value: unknown): MutationSurface { - if (value === "body" || value === "routing" || value === "description" || value === "structure") { - return value; - } - throw new TypeError("candidate mutation_surface is invalid."); -} - -function parseEvidenceEntry(value: unknown): TraceEvidenceEntry { - const record = readRecord(value, "evidence entry"); - const hasExcerpt = hasOwn(record, "redacted_excerpt"); - assertExactKeys( - record, - hasExcerpt - ? ["role", "query", "should_trigger", "source_reference", "redacted_excerpt"] - : ["role", "query", "should_trigger", "source_reference"], - "evidence entry", - ); - if (typeof record.should_trigger !== "boolean") { - throw new TypeError("evidence entry should_trigger must be a boolean."); - } - return { - role: readEvidenceRole(record.role), - query: readSafeText(record, "query", 2_000), - should_trigger: record.should_trigger, - source_reference: readSafeText(record, "source_reference", 512), - ...(hasExcerpt ? { redacted_excerpt: readSafeText(record, "redacted_excerpt", 1_000) } : {}), - }; -} - -function redactText(value: string): string { - return redactFirstPrivateKeyBlock(value) - .replace(/bearer\s+[^\s,;]+/gi, "[redacted]") - .replace( - /\b(api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*[^\s,;]+/gi, - "[redacted]", - ) - .replace(/(?:^|([\s"'`]))(?:\/(?:[^\s"'`]+)|[a-zA-Z]:\\[^\s"'`]+)/g, "$1[local-path]"); -} - -/** Explicitly redacts user-derived text before producing a portable submission. */ -export function buildEvaluationSubmission(input: EvaluationSubmissionV1): EvaluationSubmissionV1 { - const submission: EvaluationSubmissionV1 = { - schema_version: 1, - submission_id: redactText(input.submission_id), - idempotency_key: redactText(input.idempotency_key), - baseline: { - cloud_source_id: redactText(input.baseline.cloud_source_id), - cloud_snapshot_id: redactText(input.baseline.cloud_snapshot_id), - skill_id: redactText(input.baseline.skill_id), - skill_name: redactText(input.baseline.skill_name), - skill_revision: redactText(input.baseline.skill_revision), - }, - hypothesis: { - pattern_id: redactText(input.hypothesis.pattern_id), - kind: "repeated_correlated_errors", - summary: redactText(input.hypothesis.summary), - }, - candidate: { - proposal_id: redactText(input.candidate.proposal_id), - mutation_surface: input.candidate.mutation_surface, - target_revision: redactText(input.candidate.target_revision), - proposed_body: redactText(input.candidate.proposed_body), - rationale: redactText(input.candidate.rationale), - }, - evaluation: { - cloud_eval_suite_id: redactText(input.evaluation.cloud_eval_suite_id), - manifest_digest: input.evaluation.manifest_digest, - lane: input.evaluation.lane, - max_repetitions: input.evaluation.max_repetitions, - verification_only: input.evaluation.verification_only, - }, - evidence: { - cohort_fingerprint: input.evidence.cohort_fingerprint, - selected_trace_count: input.evidence.selected_trace_count, - entries: input.evidence.entries.map((entry) => ({ - role: entry.role, - query: redactText(entry.query), - should_trigger: entry.should_trigger, - source_reference: redactText(entry.source_reference), - ...(entry.redacted_excerpt === undefined - ? {} - : { redacted_excerpt: redactText(entry.redacted_excerpt) }), - })), - }, - }; - return parseEvaluationSubmission(submission); -} - -export function parseEvaluationSubmission(value: unknown): EvaluationSubmissionV1 { - const record = readRecord(value, "Evaluation submission"); - assertExactKeys( - record, - [ - "schema_version", - "submission_id", - "idempotency_key", - "baseline", - "hypothesis", - "candidate", - "evaluation", - "evidence", - ], - "Evaluation submission", - ); - if (record.schema_version !== 1) - throw new TypeError("Unsupported evaluation submission schema version."); - - const baseline = readRecord(record.baseline, "baseline"); - assertExactKeys( - baseline, - ["cloud_source_id", "cloud_snapshot_id", "skill_id", "skill_name", "skill_revision"], - "baseline", - ); - const hypothesis = readRecord(record.hypothesis, "hypothesis"); - assertExactKeys(hypothesis, ["pattern_id", "kind", "summary"], "hypothesis"); - if (hypothesis.kind !== "repeated_correlated_errors") - throw new TypeError("hypothesis kind is invalid."); - const candidate = readRecord(record.candidate, "candidate"); - assertExactKeys( - candidate, - ["proposal_id", "mutation_surface", "target_revision", "proposed_body", "rationale"], - "candidate", - ); - const evaluation = readRecord(record.evaluation, "evaluation"); - assertExactKeys( - evaluation, - ["cloud_eval_suite_id", "manifest_digest", "lane", "max_repetitions", "verification_only"], - "evaluation", - ); - const lane = readLane(evaluation.lane); - if (typeof evaluation.verification_only !== "boolean") { - throw new TypeError("evaluation verification_only must be a boolean."); - } - const mutationSurface = readMutationSurface(candidate.mutation_surface); - if (mutationSurface === "body" && (lane !== "outcome_task" || evaluation.verification_only)) { - throw new TypeError( - "body candidates require an outcome_task evaluation that can make a winner claim.", - ); - } - if ( - mutationSurface === "routing" && - (lane !== "trigger_routing" || evaluation.verification_only) - ) { - throw new TypeError( - "routing candidates require a trigger_routing evaluation that can make a winner claim.", - ); - } - if ( - (mutationSurface === "description" || mutationSurface === "structure") && - (lane !== "structural_validation" || !evaluation.verification_only) - ) { - throw new TypeError( - "description and structure candidates require verification-only structural validation.", - ); - } - - const evidence = readRecord(record.evidence, "evidence"); - assertExactKeys(evidence, ["cohort_fingerprint", "selected_trace_count", "entries"], "evidence"); - const fingerprint = readSafeText(evidence, "cohort_fingerprint", 71); - if (!fingerprintPattern.test(fingerprint)) - throw new TypeError("evidence cohort_fingerprint is invalid."); - if ( - !Array.isArray(evidence.entries) || - evidence.entries.length === 0 || - evidence.entries.length > MAX_TRACE_EVIDENCE_ENTRIES - ) { - throw new TypeError( - `evidence entries must contain 1 to ${MAX_TRACE_EVIDENCE_ENTRIES} entries.`, - ); - } - const entries = evidence.entries.map(parseEvidenceEntry); - if (!entries.some((entry) => entry.role === "calibration")) { - throw new TypeError("evidence entries must include calibration."); - } - if (!entries.some((entry) => entry.role === "holdout")) { - throw new TypeError("evidence entries must include holdout."); - } - - return { - schema_version: 1, - submission_id: readSafeText(record, "submission_id", 128), - idempotency_key: readSafeText(record, "idempotency_key", 128), - baseline: { - cloud_source_id: readSafeText(baseline, "cloud_source_id", 128), - cloud_snapshot_id: readSafeText(baseline, "cloud_snapshot_id", 128), - skill_id: readSafeText(baseline, "skill_id", 128), - skill_name: readSafeText(baseline, "skill_name", 160), - skill_revision: readSafeText(baseline, "skill_revision", 128), - }, - hypothesis: { - pattern_id: readSafeText(hypothesis, "pattern_id", 128), - kind: "repeated_correlated_errors", - summary: readSafeText(hypothesis, "summary", 2_000), - }, - candidate: { - proposal_id: readSafeText(candidate, "proposal_id", 128), - mutation_surface: mutationSurface, - target_revision: readSafeText(candidate, "target_revision", 128), - proposed_body: readSafeText(candidate, "proposed_body", 16_000), - rationale: readSafeText(candidate, "rationale", 2_000), - }, - evaluation: { - cloud_eval_suite_id: readSafeText(evaluation, "cloud_eval_suite_id", 128), - manifest_digest: (() => { - const digest = readSafeText(evaluation, "manifest_digest", 71); - if (!fingerprintPattern.test(digest)) - throw new TypeError("evaluation manifest_digest is invalid."); - return digest; - })(), - lane, - max_repetitions: readInteger(evaluation, "max_repetitions", 1, 5), - verification_only: evaluation.verification_only, - }, - evidence: { - cohort_fingerprint: fingerprint, - selected_trace_count: readInteger( - evidence, - "selected_trace_count", - 1, - MAX_SELECTED_TRACE_COUNT, - ), - entries, - }, - }; -} +export * from "@selftune/control-plane/review/portable"; diff --git a/packages/dashboard-core/src/review/run-package.test.tsx b/packages/dashboard-core/src/review/run-package.test.tsx index d08ea0ab..96bd9b0a 100644 --- a/packages/dashboard-core/src/review/run-package.test.tsx +++ b/packages/dashboard-core/src/review/run-package.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { diff --git a/packages/dashboard-core/src/review/run-package.ts b/packages/dashboard-core/src/review/run-package.ts index c5c0c684..59f08bb7 100644 --- a/packages/dashboard-core/src/review/run-package.ts +++ b/packages/dashboard-core/src/review/run-package.ts @@ -1,181 +1 @@ -export type RunReviewProducer = "local_source_merge" | "cloud_improve"; -export type RunReviewState = - | "pending" - | "approved" - | "declined" - | "passed" - | "blocked" - | "applied" - | "rolled_back" - | "stale" - | "expired" - | "failed" - | "no_change"; - -export interface RunReviewEvidence { - readonly label: string; - readonly value: string; -} - -export interface RunReviewView { - readonly runId: string; - readonly producer: RunReviewProducer; - readonly intent: { readonly title: string; readonly summary: string }; - readonly evidence: readonly RunReviewEvidence[]; - readonly candidate: { - readonly summary: string; - readonly diffText: string | null; - readonly artifact?: { readonly label: string; readonly href: string }; - }; - readonly decision: { readonly state: RunReviewState; readonly summary: string }; - readonly validation: { readonly state: RunReviewState; readonly summary: string }; - readonly outcome: { readonly state: RunReviewState; readonly summary: string }; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface RunPackageV1 { - readonly schema_version: 1; - readonly run_id: string; - readonly producer: RunReviewProducer; - readonly intent: { readonly title: string; readonly summary: string }; - readonly evidence: readonly RunReviewEvidence[]; - readonly candidate: { readonly summary: string; readonly diff_text: string | null }; - readonly decision: { readonly state: RunReviewState; readonly summary: string }; - readonly validation: { readonly state: RunReviewState; readonly summary: string }; - readonly outcome: { readonly state: RunReviewState; readonly summary: string }; - readonly created_at: string; - readonly updated_at: string; -} - -export interface RunReviewAgentSummary { - readonly run_id: string; - readonly producer: RunReviewProducer; - readonly intent: string; - readonly decision: RunReviewState; - readonly validation: RunReviewState; - readonly outcome: RunReviewState; - readonly summary: string; -} - -const states = new Set([ - "pending", - "approved", - "declined", - "passed", - "blocked", - "applied", - "rolled_back", - "stale", - "expired", - "failed", - "no_change", -]); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isState(value: unknown): value is RunReviewState { - return typeof value === "string" && states.has(value); -} - -function readText(record: Record, key: string): string { - const value = record[key]; - if (typeof value !== "string") throw new TypeError(`Run Package ${key} must be a string.`); - return value; -} - -function readSection(value: unknown, label: string): { state: RunReviewState; summary: string } { - if (!isRecord(value) || !isState(value.state) || typeof value.summary !== "string") { - throw new TypeError(`Run Package ${label} is invalid.`); - } - return { state: value.state, summary: value.summary }; -} - -function redactText(value: string): string { - return value - .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") - .replace(/\b(api[_-]?key|token|secret|password|signature)=([^\s&]+)/gi, "$1=[redacted]") - .replace(/\/(?:Users|home|tmp|private\/tmp|var\/folders)\/[^\s"'`]+/g, "[local-path]"); -} - -export function buildRunPackage(review: RunReviewView): RunPackageV1 { - return { - schema_version: 1, - run_id: redactText(review.runId), - producer: review.producer, - intent: { - title: redactText(review.intent.title), - summary: redactText(review.intent.summary), - }, - evidence: review.evidence.map((item) => ({ - label: redactText(item.label), - value: redactText(item.value), - })), - candidate: { - summary: redactText(review.candidate.summary), - diff_text: review.candidate.diffText === null ? null : redactText(review.candidate.diffText), - }, - decision: { - state: review.decision.state, - summary: redactText(review.decision.summary), - }, - validation: { - state: review.validation.state, - summary: redactText(review.validation.summary), - }, - outcome: { state: review.outcome.state, summary: redactText(review.outcome.summary) }, - created_at: review.createdAt, - updated_at: review.updatedAt, - }; -} - -export function parseRunPackage(value: unknown): RunPackageV1 { - if (!isRecord(value)) throw new TypeError("Run Package must be an object."); - if (value.schema_version !== 1) { - throw new TypeError(`Unsupported Run Package schema version: ${String(value.schema_version)}`); - } - if (value.producer !== "local_source_merge" && value.producer !== "cloud_improve") { - throw new TypeError("Run Package producer is invalid."); - } - if (!isRecord(value.intent)) throw new TypeError("Run Package intent is invalid."); - if (!Array.isArray(value.evidence)) throw new TypeError("Run Package evidence is invalid."); - const evidence = value.evidence.map((entry) => { - if (!isRecord(entry)) throw new TypeError("Run Package evidence entry is invalid."); - return { label: readText(entry, "label"), value: readText(entry, "value") }; - }); - if (!isRecord(value.candidate)) throw new TypeError("Run Package candidate is invalid."); - const diffText = value.candidate.diff_text; - if (diffText !== null && typeof diffText !== "string") { - throw new TypeError("Run Package candidate diff is invalid."); - } - return { - schema_version: 1, - run_id: readText(value, "run_id"), - producer: value.producer, - intent: { - title: readText(value.intent, "title"), - summary: readText(value.intent, "summary"), - }, - evidence, - candidate: { summary: readText(value.candidate, "summary"), diff_text: diffText }, - decision: readSection(value.decision, "decision"), - validation: readSection(value.validation, "validation"), - outcome: readSection(value.outcome, "outcome"), - created_at: readText(value, "created_at"), - updated_at: readText(value, "updated_at"), - }; -} - -export function summarizeRunReview(review: RunReviewView): RunReviewAgentSummary { - return { - run_id: review.runId, - producer: review.producer, - intent: review.intent.title, - decision: review.decision.state, - validation: review.validation.state, - outcome: review.outcome.state, - summary: review.outcome.summary, - }; -} +export * from "@selftune/control-plane/review/portable"; diff --git a/packages/dashboard-core/src/routes/manifest.test.ts b/packages/dashboard-core/src/routes/manifest.test.ts index 59d9e233..09016198 100644 --- a/packages/dashboard-core/src/routes/manifest.test.ts +++ b/packages/dashboard-core/src/routes/manifest.test.ts @@ -43,11 +43,6 @@ const CLOUD_CAPABILITIES: Capabilities = { }, }; -const SELF_HOST_CAPABILITIES: Capabilities = { - ...LOCAL_CAPABILITIES, - host: "selfhost", -}; - describe("resolveDashboardRoutes", () => { it("keeps signals, proposals, and registry as locked host-only cloud modules in local", () => { const routes = resolveDashboardRoutes("local", LOCAL_CAPABILITIES); @@ -62,6 +57,8 @@ describe("resolveDashboardRoutes", () => { expect(byId.get("projects")?.path).toBe("/projects"); expect(byId.get("projects")?.label).toBe("Skill Sets"); expect(byId.get("projects")?.title).toBe("Skill Sets"); + expect(byId.get("collaboration")?.path).toBe("/collaboration"); + expect(byId.get("collaboration")?.access).toBe("enabled"); expect(byId.get("analytics")?.path).toBe("/insights"); }); @@ -73,26 +70,9 @@ describe("resolveDashboardRoutes", () => { expect(byId.get("proposals")?.access).toBe("enabled"); expect(byId.get("registry")?.access).toBe("enabled"); expect(byId.get("unmatched")?.access).toBe("enabled"); + expect(byId.get("collaboration")?.path).toBe("/collaboration"); + expect(byId.get("collaboration")?.access).toBe("enabled"); expect(byId.get("settings")?.access).toBe("enabled"); expect(byId.has("status")).toBe(false); }); - - it("exposes the Remote Library and Skill Sets to Self-host navigation and search", () => { - const routes = resolveDashboardRoutes("selfhost", SELF_HOST_CAPABILITIES); - const byId = new Map(routes.map((route) => [route.id, route])); - - expect(routes.map((route) => route.id)).toEqual(["skills", "projects"]); - expect(byId.get("skills")).toMatchObject({ - access: "enabled", - path: "/skills", - label: "Skills", - title: "Remote Library", - }); - expect(byId.get("projects")).toMatchObject({ - access: "enabled", - path: "/projects", - label: "Skill Sets", - title: "Skill Sets", - }); - }); }); diff --git a/packages/dashboard-core/src/routes/manifest.ts b/packages/dashboard-core/src/routes/manifest.ts index b84b283b..85a0709e 100644 --- a/packages/dashboard-core/src/routes/manifest.ts +++ b/packages/dashboard-core/src/routes/manifest.ts @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; import { BarChart3Icon, + BlocksIcon, BrainCircuitIcon, FolderKanbanIcon, GitPullRequestIcon, @@ -28,6 +29,8 @@ export type DashboardRouteId = | "overview" | "skills" | "projects" + | "plugins" + | "collaboration" | "observed" | "improve" | "analytics" @@ -189,12 +192,6 @@ export const DASHBOARD_ROUTE_MANIFEST: readonly DashboardRouteManifestEntry[] = detailBackHref: "/skills", detailBackLabel: "Library", }, - selfhost: { - path: "/skills", - title: "Remote Library", - badge: "Library", - activePatterns: [{ mode: "exact", value: "/skills" }], - }, }, }, { @@ -229,6 +226,62 @@ export const DASHBOARD_ROUTE_MANIFEST: readonly DashboardRouteManifestEntry[] = }, }, }, + { + id: "plugins", + label: "Plugins", + tooltip: "Manage plugins installed in Claude and Codex", + icon: BlocksIcon, + hosts: { + local: { + path: "/plugins", + title: "Plugins", + badge: "This Mac", + backHref: "/skills", + backLabel: "Library", + activePatterns: [{ mode: "exact", value: "/plugins" }], + }, + selfhost: { + path: "/plugins", + title: "Plugins", + badge: "Server", + backHref: "/skills", + backLabel: "Library", + activePatterns: [{ mode: "exact", value: "/plugins" }], + }, + }, + }, + { + id: "collaboration", + label: "Team", + tooltip: "Review team skill changes and rollouts", + icon: UsersIcon, + hosts: { + cloud: { + path: "/collaboration", + title: "Team collaboration", + badge: "Team", + backHref: "/", + backLabel: "Dashboard", + activePatterns: [{ mode: "prefix", value: "/collaboration" }], + }, + local: { + path: "/collaboration", + title: "Team collaboration", + badge: "Team", + backHref: "/skills", + backLabel: "Library", + activePatterns: [{ mode: "prefix", value: "/collaboration" }], + }, + selfhost: { + path: "/collaboration", + title: "Team collaboration", + badge: "Team", + backHref: "/skills", + backLabel: "Library", + activePatterns: [{ mode: "prefix", value: "/collaboration" }], + }, + }, + }, { id: "observed", label: "Observed", diff --git a/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx b/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx new file mode 100644 index 00000000..e0b0acaf --- /dev/null +++ b/packages/dashboard-core/src/screens/MarketingProductProof.stories.tsx @@ -0,0 +1,259 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { DashboardHostProvider, type DashboardHostModules } from "../host"; +import type { LibraryInventoryModel, ProjectsInventoryModel } from "../models"; +import { hostModules } from "../test/host-modules"; +import { ProjectsScreen } from "./projects"; +import { SkillsLibraryScreen } from "./skills"; + +const unavailable = { access: "unavailable", reason: "Not used in this demo." } as const; + +const locations = { + global: { + id: "global", + label: "Global", + path: "/Users/demo/.agents/skills/research", + connection: "Codex", + sourceKind: "installed" as const, + lastUsedAt: "2026-08-24T12:00:00.000Z", + modifiedAt: "2026-08-23T12:00:00.000Z", + removable: true, + }, + project: { + id: "project", + label: "Launch site", + path: "/Projects/launch/.claude/skills/research", + connection: "Claude", + sourceKind: "installed" as const, + lastUsedAt: "2026-08-22T12:00:00.000Z", + modifiedAt: "2026-08-18T12:00:00.000Z", + removable: true, + }, +}; + +function demoUpdateStatus(value: string): "current" | "available" | "untracked" { + if (value === "current" || value === "available" || value === "untracked") return value; + throw new Error(`Unsupported demo update status: ${value}`); +} + +const libraryInventory: LibraryInventoryModel = { + categoryOptions: [ + { id: "agent_tooling", label: "Agent Tooling" }, + { id: "research", label: "Research" }, + { id: "software_development", label: "Software Development" }, + ], + skills: [ + { + id: "research", + name: "research", + lifecycle: "active", + category: null, + status: "Ready", + updateStatus: "available", + sources: [{ kind: "github", label: "selftune-dev/research-skills" }], + locations: [locations.global, locations.project], + revisionHashes: ["research-current", "research-old"], + modifiedAt: "2026-08-23T12:00:00.000Z", + lastUsedAt: "2026-08-24T12:00:00.000Z", + triggerTrend: [], + lifetimeTriggerCount: 34, + detailHref: "/skills/research", + restoreId: null, + consolidationRecommendation: { + installedCount: 2, + projectCount: 1, + duplicateCount: 0, + divergentCount: 1, + reason: "One project install can use the managed Library revision.", + canonical: { + contentHash: "research-current", + packagePath: locations.global.path, + confidence: "source_current", + }, + targets: [ + { + packagePath: locations.project.path, + contentHash: "research-old", + action: "archive_copy", + projectRoot: "/Projects/launch", + connection: "claude_code", + }, + ], + }, + }, + ...[ + ["agent-browser", "Agent Tooling", "current", "Codex", 21], + ["frontend-design", "Software Development", "current", "Claude", 18], + ["release-checklist", "Software Development", "available", "Codex", 9], + ["customer-research", "Research", "untracked", "Pi", 4], + ].map(([name, category, updateStatus, connection, triggers], index) => ({ + id: String(name), + name: String(name), + lifecycle: "active" as const, + category: { + id: + category === "Research" + ? "research" + : category === "Agent Tooling" + ? "agent_tooling" + : "software_development", + label: String(category), + inferredId: "agent_tooling", + source: "inferred" as const, + confidence: 0.91, + reason: "Demo classification.", + matchedTerms: [], + }, + status: "Ready", + updateStatus: demoUpdateStatus(String(updateStatus)), + sources: [{ kind: "local" as const, label: "Local package" }], + locations: [ + { + ...locations.global, + id: `location-${index}`, + path: `/Users/demo/.agents/skills/${String(name)}`, + connection: String(connection), + }, + ], + revisionHashes: [`revision-${index}`], + modifiedAt: "2026-08-23T12:00:00.000Z", + lastUsedAt: "2026-08-24T12:00:00.000Z", + triggerTrend: [], + lifetimeTriggerCount: Number(triggers), + detailHref: `/skills/${String(name)}`, + restoreId: null, + })), + ], +}; + +const projectsInventory: ProjectsInventoryModel = { + skillSets: [ + { + id: "product-launch", + name: "Product launch", + description: + "The focused toolkit for researching, building, checking, and shipping the launch site.", + connections: ["codex", "claude_code"], + skills: ["research", "agent-browser", "frontend-design", "release-checklist"].map( + (name, index) => ({ + name, + packagePath: `/Users/demo/.agents/skills/${name}`, + contentHash: `revision-${index}`, + }), + ), + revision: 3, + revisionHash: "product-launch-v3", + updatedAt: "2026-08-24T12:00:00.000Z", + ownerScope: "personal", + }, + ], + availableSkills: [], + receipts: [ + { + id: "receipt-launch", + skillSetId: "product-launch", + skillSetName: "Product launch", + projectRoot: "/Projects/launch-site", + status: "applied", + operationCount: 8, + dependenciesDownloaded: 0, + }, + ], + captureCandidates: [], +}; + +const libraryActions = { + updateCategory: unavailable, + openLocation: unavailable, + backup: unavailable, + installTargets: [], + install: unavailable, + previewSourceUpdate: unavailable, + applySourceUpdate: unavailable, + mergeConnections: [], + prepareMerge: unavailable, + applyMerge: unavailable, + archive: unavailable, + remove: unavailable, + decideRemoval: unavailable, + restore: unavailable, + create: unavailable, + primary: [], +} as const; + +function libraryModules(): DashboardHostModules { + return hostModules({ + skills: { + host: "local", + decisions: unavailable, + library: { + access: "available", + useInventory: () => ({ + data: libraryInventory, + isLoading: false, + error: null, + refresh() {}, + }), + useActions: () => libraryActions, + }, + }, + }); +} + +function projectModules(): DashboardHostModules { + return hostModules({ + skillSets: { + library: unavailable, + projects: { + access: "available", + useInventory: () => ({ + data: projectsInventory, + isLoading: false, + error: null, + refresh() {}, + }), + useIntelligence: () => ({ access: "unavailable", reason: "Not used in this demo." }), + useActions: () => ({ + create: unavailable, + update: unavailable, + derive: unavailable, + export: unavailable, + remove: unavailable, + plan: unavailable, + apply: unavailable, + resolveConflict: unavailable, + decideConflict: unavailable, + rollbackConflict: unavailable, + rollback: unavailable, + reviewSuggestion: unavailable, + }), + }, + }, + }); +} + +const meta = { + title: "Marketing/Product proof", + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SkillSprawl: Story = { + render: () => ( + + + + ), +}; + +export const ProjectScopedSkillSet: Story = { + render: () => ( +
    + + + +
    + ), +}; diff --git a/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx b/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx index 00fc2c23..856f81a5 100644 --- a/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx +++ b/packages/dashboard-core/src/screens/decisions/DurableDecisionCard.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import type { DashboardDecisionModel } from "../../models"; import { DurableDecisionCard } from "./DurableDecisionCard"; diff --git a/packages/dashboard-core/src/screens/index.ts b/packages/dashboard-core/src/screens/index.ts index 72c446ca..7e72ca70 100644 --- a/packages/dashboard-core/src/screens/index.ts +++ b/packages/dashboard-core/src/screens/index.ts @@ -40,8 +40,10 @@ export { type ObservedSkillsScreenProps, } from "./skills/index"; export { PlanReview, ProjectsScreen, SkillSetEditor, SkillSetSkillPicker } from "./projects/index"; +export { PluginsScreen } from "./plugins/index"; export { DurableDecisionCard } from "./decisions/index"; export { RecipientShareScreen } from "./recipient-shares/index"; +export { TeamCollaborationScreen } from "./team-collaboration/index"; export { SettingsShell, type SettingsShellProps, diff --git a/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts b/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts index 07132031..3dd98035 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 = { - mutations: { + actions: { updateOverviewWatchlist: vi.fn(), }, }; @@ -35,7 +35,7 @@ describe("resolveOverviewWatchlistChange", () => { initialSkills: ["selftune"], }, { - mutations: { + actions: { openUpgrade: vi.fn(), updateOverviewWatchlist: hostAction, }, @@ -52,7 +52,7 @@ describe("resolveOverviewWatchlistChange", () => { initialSkills: [], }, { - mutations: { + actions: { openUpgrade: vi.fn(), }, } as never, @@ -67,7 +67,7 @@ describe("resolveOverviewWatchlistLoad", () => { expect( resolveOverviewWatchlistLoad({ - mutations: { + actions: { openUpgrade: vi.fn(), getOverviewWatchlist: hostLoader, }, @@ -78,7 +78,7 @@ describe("resolveOverviewWatchlistLoad", () => { it("returns undefined when the host does not provide a loader", () => { expect( resolveOverviewWatchlistLoad({ - mutations: { + actions: { openUpgrade: vi.fn(), }, } as never), diff --git a/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx b/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx index a25d7f11..1e1d1a07 100644 --- a/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx +++ b/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx @@ -19,12 +19,12 @@ import { import { timeAgo } from "@selftune/ui/lib"; import type { TrustBucket } from "@selftune/ui/types"; -import { useOptionalDashboardHostAdapter } from "../../host/index"; +import { useOptionalOverviewModule, type DashboardOverviewModule } from "../../host/index"; import type { OverviewComparisonRow } from "./types"; const BUCKET_ORDER: TrustBucket[] = ["at_risk", "improving", "uncertain", "stable"]; -const BUCKET_CFG: Record = { +const BUCKET_CFG = { at_risk: { label: "At Risk", accent: "text-destructive", @@ -41,7 +41,7 @@ const BUCKET_CFG: Record; export interface OverviewComparisonWatchlistConfig { initialSkills: string[]; @@ -58,15 +58,13 @@ export interface OverviewComparisonSurfaceProps { export function resolveOverviewWatchlistChange( watchlist: OverviewComparisonWatchlistConfig | undefined, - hostAdapter: ReturnType, + overviewModule: DashboardOverviewModule | null, ) { - return watchlist?.onChange ?? hostAdapter?.mutations.updateOverviewWatchlist; + return watchlist?.onChange ?? overviewModule?.mutations.updateOverviewWatchlist; } -export function resolveOverviewWatchlistLoad( - hostAdapter: ReturnType, -) { - return hostAdapter?.mutations.getOverviewWatchlist; +export function resolveOverviewWatchlistLoad(overviewModule: DashboardOverviewModule | null) { + return overviewModule?.mutations.getOverviewWatchlist; } export function getOverviewWatchlistSyncKey(initialSkills: string[] | undefined): string { @@ -74,9 +72,14 @@ export function getOverviewWatchlistSyncKey(initialSkills: string[] | undefined) } function parseOverviewWatchlistSyncKey(syncKey: string): string[] { + // SAFETY: syncKey is produced only by JSON.stringify(string[]) in this module. return JSON.parse(syncKey) as string[]; } +function parseViewMode(value: string): "watched" | "all" { + return value === "watched" ? "watched" : "all"; +} + function formatEvolutionAction(action: string): string { switch (action) { case "created": @@ -114,7 +117,7 @@ export function OverviewComparisonSurface({ libraryAction, watchlist, }: OverviewComparisonSurfaceProps) { - const hostAdapter = useOptionalDashboardHostAdapter(); + const overviewModule = useOptionalOverviewModule(); const interactive = Boolean(watchlist); const watchlistInitialSkills = watchlist?.initialSkills ?? []; const watchlistSyncKey = getOverviewWatchlistSyncKey(watchlistInitialSkills); @@ -124,8 +127,8 @@ export function OverviewComparisonSurface({ ); const watchlistRequestSeq = useRef(0); const watchlistLoadSeq = useRef(0); - const loadWatchlist = resolveOverviewWatchlistLoad(hostAdapter); - const onWatchlistChange = resolveOverviewWatchlistChange(watchlist, hostAdapter); + const loadWatchlist = resolveOverviewWatchlistLoad(overviewModule); + const onWatchlistChange = resolveOverviewWatchlistChange(watchlist, overviewModule); useEffect(() => { if (!interactive) return; @@ -142,9 +145,7 @@ export function OverviewComparisonSurface({ Promise.resolve(loadWatchlist()) .then((result) => { if (cancelled || watchlistLoadSeq.current !== requestSeq) return; - if (Array.isArray(result) && result.every((value) => typeof value === "string")) { - setWatchedSkills(result); - } + setWatchedSkills(result); return undefined; }) .catch(() => { @@ -198,11 +199,7 @@ export function OverviewComparisonSurface({ try { const result = await onWatchlistChange(next); - if ( - watchlistRequestSeq.current === requestSeq && - Array.isArray(result) && - result.every((value) => typeof value === "string") - ) { + if (watchlistRequestSeq.current === requestSeq && result) { setWatchedSkills(result); } } catch { @@ -227,10 +224,7 @@ export function OverviewComparisonSurface({
    {interactive ? ( - setViewMode(value as "watched" | "all")} - > + setViewMode(parseViewMode(value))}> ; + +export default meta; +type Story = StoryObj; + +export const LocalFirstRun: Story = {}; + +export const CloudFirstRun: Story = { + args: { skillCount: 4, cloudSourceCount: 0 }, +}; + +export const Dismissible: Story = { + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole("button", { name: "Dismiss local dashboard guidance" })); + await expect(canvas.queryByText("Local Dashboard")).not.toBeInTheDocument(); + await expect(localStorage.getItem(storageKey)).toBe("true"); + }, +}; diff --git a/packages/dashboard-core/src/screens/plugins/PluginsScreen.test.tsx b/packages/dashboard-core/src/screens/plugins/PluginsScreen.test.tsx new file mode 100644 index 00000000..46ec05cf --- /dev/null +++ b/packages/dashboard-core/src/screens/plugins/PluginsScreen.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DashboardHostProvider, type DashboardHostModules } from "../../host"; +import type { PluginInventoryModel } from "../../models"; +import { hostModules } from "../../test/host-modules"; +import { PluginsScreen } from "./PluginsScreen"; + +afterEach(cleanup); + +const inventory: PluginInventoryModel = { + hosts: [ + { host: "claude", label: "Claude", status: "available", installedCount: 1, message: null }, + { host: "codex", label: "Codex", status: "available", installedCount: 1, message: null }, + ], + plugins: [ + { + pluginId: "paper-desktop@paper", + name: "paper-desktop", + marketplaceName: "paper", + managedBySelfTune: false, + versionDrift: true, + installations: [ + { + host: "claude", + hostLabel: "Claude", + pluginId: "paper-desktop@paper", + version: "0.1.0", + enabled: true, + scope: "user", + sourceType: "marketplace", + sourceLabel: "paper", + managedBySelfTune: false, + availableActions: ["update", "disable", "remove"], + }, + { + host: "codex", + hostLabel: "Codex", + pluginId: "paper-desktop@paper", + version: "0.2.0", + enabled: true, + scope: null, + sourceType: "marketplace", + sourceLabel: "paper", + managedBySelfTune: false, + availableActions: ["remove"], + }, + ], + }, + ], + totalPlugins: 1, + managedPlugins: 0, + refreshedAt: "2026-08-11T09:30:00.000Z", +}; + +function makeModules(execute = vi.fn()): DashboardHostModules { + return hostModules({ + plugins: { + plugins: { + access: "available", + useInventory: () => ({ data: inventory, isLoading: false, error: null, refresh() {} }), + useActions: () => ({ + manage: { access: "available", execute }, + }), + }, + }, + }); +} + +describe("PluginsScreen", () => { + it("groups one plugin across hosts and exposes only supported actions", () => { + render( + + + , + ); + + expect(screen.getByRole("heading", { name: "paper-desktop" })).toBeTruthy(); + expect(screen.getByText("Versions differ")).toBeTruthy(); + expect(screen.getByText("Installed in Claude").previousElementSibling?.textContent).toBe("1"); + expect(screen.getByText("Installed in Codex").previousElementSibling?.textContent).toBe("1"); + expect(screen.getByRole("button", { name: "Update" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disable" })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Remove" })).toHaveLength(2); + }); + + it("requires confirmation before removing and explains Claude data retention", async () => { + const execute = vi.fn().mockResolvedValue({ inventory }); + render( + + + , + ); + + const card = screen.getByTestId("plugin-card-paper-desktop@paper"); + const claudeRow = within(card).getByText("Claude").closest(".rounded-lg"); + if (!(claudeRow instanceof HTMLElement)) throw new Error("Claude installation row not found"); + fireEvent.click(within(claudeRow).getByRole("button", { name: "Remove" })); + + expect(execute).not.toHaveBeenCalled(); + expect(screen.getByText(/Claude plugin data will be kept/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Remove from Claude" })); + + await waitFor(() => + expect(execute).toHaveBeenCalledWith({ + host: "claude", + pluginId: "paper-desktop@paper", + action: "remove", + }), + ); + }); +}); diff --git a/packages/dashboard-core/src/screens/plugins/PluginsScreen.tsx b/packages/dashboard-core/src/screens/plugins/PluginsScreen.tsx new file mode 100644 index 00000000..d35df43d --- /dev/null +++ b/packages/dashboard-core/src/screens/plugins/PluginsScreen.tsx @@ -0,0 +1,486 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + AlertTriangleIcon, + BlocksIcon, + CheckCircle2Icon, + CircleSlash2Icon, + PowerIcon, + RefreshCwIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { toast } from "sonner"; + +import { usePluginsModule, type DashboardPluginsContribution } from "../../host"; +import type { + PluginHostInstallationModel, + PluginHostModel, + PluginInventoryItemModel, + PluginManagementActionModel, +} from "../../models"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + PageHeader, + PageScaffold, +} from "@selftune/ui/components"; +import { + Badge, + Button, + Card, + CardContent, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Skeleton, +} from "@selftune/ui/primitives"; + +type HostFilter = "all" | PluginHostModel; +type OwnershipFilter = "all" | "selftune" | "external"; + +const HOST_ACCENTS = { + claude: { monogram: "C", className: "border-border bg-muted text-muted-foreground" }, + codex: { monogram: "◎", className: "border-primary/20 bg-primary/10 text-primary" }, +} satisfies Record; + +function actionLabel(action: PluginManagementActionModel): string { + if (action === "remove") return "Remove"; + if (action === "disable") return "Disable"; + if (action === "enable") return "Enable"; + return "Update"; +} + +function actionIcon(action: PluginManagementActionModel) { + if (action === "remove") return
    @@ -559,24 +633,50 @@ function AvailableProjects({

    + {pluginInstallPreviewAction && pluginInstallAction ? ( + + ) : null} {actions.export.access === "available" && actions.export.requiresProjectRoot !== true ? ( ) : null}
    @@ -897,8 +997,345 @@ function AvailableProjects({ action={actions.share} recipients={shareRecipients} workspaceAction={actions.shareWithWorkspace} + previewLicenseAction={libraryActions?.previewLicenseDraft} + applyLicenseAction={libraryActions?.applyLicenseDraft} + onShared={packInventory.refresh} /> ) : null} + {selected && pluginInstallPreviewAction && pluginInstallAction ? ( + { + setPluginInstallOpen(open); + if (!open) { + setPluginInstallPreview(null); + setPluginInstallHosts([]); + } + }} + > + + + Install {selected.name} as a plugin + + SelfTune will register a local marketplace and ask each selected host to install the + exact reviewed revision. It never edits Claude or Codex registry files directly. + + + {pluginInstallPreview ? ( +
    +
    +
    +
    +

    {pluginInstallPreview.pluginName}

    +

    + {pluginInstallPreview.skillNames.length} skills · skills only · local user + scope +

    +
    + {pluginInstallPreview.pluginVersion} +
    +

    + Revision {pluginInstallPreview.revisionHash} +

    +
    +
    + {pluginInstallPreview.hosts.map((host) => { + const selectedHost = pluginInstallHosts.includes(host.host); + const harness = connectedHarnessesById.get( + host.host === "claude" ? "claude_code" : "codex", + ); + const status = + host.status === "already_current" + ? "Installed" + : host.status === "update_available" + ? "Update available" + : host.status === "ready" + ? "Ready" + : "Not found"; + return ( + + ); + })} +
    +

    + The host copies this plugin into its own cache. Updating the Skill Set later does + not change the installed plugin until you confirm another install. +

    +
    + ) : ( +
    + + +
    + )} + + + + +
    +
    + ) : null} + {selected && actions.export.access === "available" && actions.export.formats ? ( + + + + Export {selected.name} + + Every option uses the same sealed Skill Set revision. Plugin ZIPs must be inspected + and installed through the selected host; they are not direct-install URLs. + + +
    + {actions.export.formats.map((format) => ( + + ))} +
    +
    +
    + ) : null} + {packPreviewAction && packImportAction ? ( + { + setPackOpen(open); + if (!open) setPackPreview(null); + }} + > + + + Add Skill Set from URL + + Paste a SelfTune Pack link. Desktop verifies the origin, immutable object hash, + component hashes, paths, and license terms before anything is imported. + + + {packPreview ? ( +
    +
    +

    {packPreview.name}

    + {packPreview.description ? ( +

    {packPreview.description}

    + ) : null} +
    +
    + {packPreview.components.map((component) => ( +
    + {component.logicalSkillId} + {component.licenseExpression} +
    + ))} +
    +

    + Revision {packPreview.skillSetRevisionSha256} +

    +

    + {packPreview.mode === "private_single_claim" + ? "This link is consumed by the first successful download." + : "This is an unlisted reusable link."}{" "} + Expires {new Date(packPreview.expiresAt).toLocaleString()}. +

    +
    + ) : ( +
    + + setPackUrl(event.target.value)} + /> +
    + )} + + {packPreview ? ( + + ) : null} + + +
    +
    + ) : null} {actions.share?.access === "upgrade" ? ( ; if (projects.access === "upgrade") return ; + if (library.access === "available") { + return ; + } return ; } + +function ProjectsWithLibraryActions({ + projects, + library, +}: { + projects: DashboardProjectsContribution & { access: "available" }; + library: DashboardLibraryContribution & { access: "available" }; +}) { + return ; +} diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx index 9650e74a..5ba94e67 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx @@ -7,6 +7,12 @@ import type { DashboardProjectsActions } from "../../host"; import type { ProjectSkillSetModel } from "../../models"; import { ShareSkillSetDialog } from "./ShareSkillSetDialog"; +vi.mock("@selftune/ui/components", () => ({ + PierreDiffReview: ({ files }: { files: Array<{ path: string }> }) => ( +
    {files.map((file) => file.path).join(", ")}
    + ), +})); + afterEach(cleanup); const skillSet: ProjectSkillSetModel = { @@ -118,29 +124,51 @@ describe("ShareSkillSetDialog", () => { expect(execute).not.toHaveBeenCalled(); }); - it("hides email, member, and workspace delivery when the host only supports copy links", () => { - const execute = vi.fn(); + it("drafts and applies a missing license before retrying Skill Set sharing", async () => { + const execute = vi.fn(async () => { + throw new Error("Every included skill needs distributable license evidence."); + }); + const preview = vi.fn(async () => ({ + previewId: "preview-1", + skillPath: "/skills/reviewer", + licenseExpression: "LicenseRef-Ithraa-Center-Proprietary", + files: [ + { path: "SKILL.md" as const, patch: "skill patch" }, + { path: "LICENSE" as const, patch: "license patch" }, + ], + })); + const apply = vi.fn(async () => preview()); render( undefined), - }} + action={action(execute)} + previewLicenseAction={{ access: "available", execute: preview }} + applyLicenseAction={{ access: "available", execute: apply }} open onOpenChange={() => {}} />, ); - expect(screen.queryByRole("button", { name: /People & workspace/ })).toBeNull(); - expect(screen.queryByRole("combobox", { name: "Share with" })).toBeNull(); - expect(screen.queryByLabelText("Who can use this link?")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Create link" })); + fireEvent.click(await screen.findByRole("button", { name: "Draft missing license" })); + expect((screen.getByLabelText("Skill needing a license") as HTMLSelectElement).value).toBe( + "reviewer", + ); + fireEvent.change(screen.getByLabelText("Copyright holder"), { + target: { value: "Daniel Petro" }, + }); + fireEvent.change(screen.getByLabelText("Licensed organization"), { + target: { value: "Ithraa Center" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Review draft" })); + + expect((await screen.findByTestId("pierre-review")).textContent).toBe("SKILL.md, LICENSE"); + fireEvent.click(screen.getByRole("button", { name: "Apply reviewed license" })); + await waitFor(() => + expect(apply).toHaveBeenCalledWith( + expect.objectContaining({ skillId: "reviewer", previewId: "preview-1" }), + ), + ); expect(screen.getByRole("button", { name: "Create link" })).toBeTruthy(); }); }); diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx index 42862f97..073501cf 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx @@ -1,10 +1,18 @@ "use client"; import { useEffect, useState } from "react"; -import { CheckIcon, CopyIcon, MailIcon, Share2Icon } from "lucide-react"; +import { + ArrowLeftIcon, + CheckIcon, + CopyIcon, + FileKey2Icon, + MailIcon, + Share2Icon, +} from "lucide-react"; -import type { DashboardProjectsActions } from "../../host"; -import type { ProjectSkillSetModel } from "../../models"; +import type { DashboardLibraryActions, DashboardProjectsActions } from "../../host"; +import type { LibraryLicenseDraftPreviewModel, ProjectSkillSetModel } from "../../models"; +import { PierreDiffReview } from "@selftune/ui/components"; import { Button, Combobox, @@ -30,6 +38,9 @@ export function ShareSkillSetDialog({ action, recipients = [], workspaceAction, + previewLicenseAction, + applyLicenseAction, + onShared, }: { skillSet: ProjectSkillSetModel; open: boolean; @@ -37,9 +48,10 @@ export function ShareSkillSetDialog({ action: Extract, { access: "available" }>; recipients?: ReturnType>; workspaceAction?: DashboardProjectsActions["shareWithWorkspace"]; + previewLicenseAction?: DashboardLibraryActions["previewLicenseDraft"]; + applyLicenseAction?: DashboardLibraryActions["applyLicenseDraft"]; + onShared?(): void | Promise; }) { - const supportsEmail = action.supportedDeliveryMethods?.includes("email") ?? true; - const supportsPrivateClaim = action.supportedShareModes?.includes("private_single_claim") ?? true; const [delivery, setDelivery] = useState<"copy_link" | "email">("copy_link"); const [mode, setMode] = useState<"reusable_unlisted" | "private_single_claim">( "reusable_unlisted", @@ -50,21 +62,72 @@ export function ShareSkillSetDialog({ const [sent, setSent] = useState(false); const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [draftingLicense, setDraftingLicense] = useState(false); + const [draftSkillId, setDraftSkillId] = useState(skillSet.skills[0]?.name ?? ""); + const [copyrightHolder, setCopyrightHolder] = useState(""); + const [licensedOrganization, setLicensedOrganization] = useState(""); + const [year, setYear] = useState(new Date().getFullYear().toString()); + const [licensePreview, setLicensePreview] = useState( + null, + ); + const resolvedTheme = + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light"; useEffect(() => { if (open) return; setShareUrl(null); setSent(false); setError(null); + setDraftingLicense(false); + setLicensePreview(null); }, [open]); + const terms = () => ({ + copyrightHolder: copyrightHolder.trim(), + licensedOrganization: licensedOrganization.trim(), + year: Number(year), + }); + + async function previewLicense() { + if (previewLicenseAction?.access !== "available") return; + setPending(true); + setError(null); + try { + setLicensePreview( + await previewLicenseAction.execute({ skillId: draftSkillId, terms: terms() }), + ); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setPending(false); + } + } + + async function applyLicense() { + if (applyLicenseAction?.access !== "available" || !licensePreview) return; + setPending(true); + setError(null); + try { + await applyLicenseAction.execute({ + skillId: draftSkillId, + previewId: licensePreview.previewId, + terms: terms(), + }); + setDraftingLicense(false); + setLicensePreview(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setPending(false); + } + } + async function submit() { setPending(true); setError(null); try { - if (delivery === "email" && !supportsEmail) { - throw new Error("Email, member, and workspace sharing are unavailable from this host."); - } if (delivery === "email" && workspaceSelected) { if (workspaceAction?.access !== "available") { throw new Error("Workspace sharing is unavailable."); @@ -83,6 +146,7 @@ export function ShareSkillSetDialog({ } : { skillSetId: skillSet.id, mode, delivery: "copy_link" }, ); + await onShared?.(); setShareUrl(receipt.shareUrl ?? null); setSent(receipt.delivery === "email"); } catch (cause) { @@ -94,14 +158,81 @@ export function ShareSkillSetDialog({ return ( - + - Share {skillSet.name} + + {draftingLicense ? `Draft license for ${skillSet.name}` : `Share ${skillSet.name}`} + - Share this Skill Set and all of its pinned skills as one portable package. + {draftingLicense + ? "Choose the unlicensed skill, draft internal-use terms, and review the exact files before applying. This is a drafting aid, not legal advice." + : "Share this Skill Set and all of its pinned skills as one portable package."} - {shareUrl ? ( + {draftingLicense ? ( +
    + {licensePreview ? ( + <> +
    + {licensePreview.licenseExpression} + +
    + + + ) : ( +
    +
    + + +
    +
    + + setCopyrightHolder(event.target.value)} + /> +
    +
    + + setLicensedOrganization(event.target.value)} + /> +
    +
    + + setYear(event.target.value)} + /> +
    +

    + Internal use, modification, and private team distribution are permitted. External + redistribution remains prohibited. +

    +
    + )} +
    + ) : shareUrl ? (
    - -
    - ) : null} +
    + + +
    {delivery === "copy_link" ? (
    - {supportsPrivateClaim ? ( - <> - - - - ) : null} + +
    ) : (
    @@ -228,17 +355,60 @@ export function ShareSkillSetDialog({ unless the recipient separately opts in.

    {error ? ( -

    - {error} -

    +
    +

    + {error} +

    + {previewLicenseAction?.access === "available" && + applyLicenseAction?.access === "available" && + /license/i.test(error) ? ( + + ) : null} +
    ) : null}
    )} - - {!shareUrl && !sent ? ( + {draftingLicense ? ( + + ) : ( + + )} + {draftingLicense ? ( + + ) : !shareUrl && !sent ? ( + + + ) : null} + {isActive ? ( + + ) : null} + + {!pack.packUrl && isActive ? ( +

    + This legacy link remains valid, but its one-way token cannot be copied again. Create a new + Pack from the Skill Set when you need another link. +

    + ) : null} + + ); +} + +export function SharedSkillSetPacks({ + query, + revoke, + onCreatePack, +}: { + query: ProjectSkillSetPacksQueryState; + revoke: DashboardProjectsAction | undefined; + onCreatePack(): void; +}) { + const [pendingRevoke, setPendingRevoke] = useState(null); + const [revokeError, setRevokeError] = useState(null); + + if (query.isLoading) return ; + if (query.error) { + return ( + + + Shared Packs could not be loaded + {query.error} + + + + + + ); + } + + const packs = query.data ?? []; + return ( +
    +
    +
    +

    + Distribution +

    +

    + Shared Packs +

    +

    + Copy active links, see when single-use Packs are claimed, and revoke access immediately. +

    +
    +
    + + +
    +
    + + {packs.length === 0 ? ( + + + + No shared Packs yet + + Select a Skill Set and create a link. Recipients can review it in a browser and open + it directly in SelfTune Desktop. + + + + + + + ) : ( +
    + {packs.map((pack) => ( + + ))} +
    + )} + +
    + +

    + Pack links carry access by possession. Account and device tokens stay on the + creator's machine and are never placed in the shared URL. +

    +
    + + { + if (!open) { + setPendingRevoke(null); + setRevokeError(null); + } + }} + > + + + Revoke {pendingRevoke?.name}? + + The public preview and import will stop working immediately. Existing local imports + are not removed. + + + {revokeError ?

    {revokeError}

    : null} + + + + +
    +
    +
    + ); +} diff --git a/packages/dashboard-core/src/screens/projects/SkillSetWorkspaceNavigation.tsx b/packages/dashboard-core/src/screens/projects/SkillSetWorkspaceNavigation.tsx index 1c35e73e..7db91f51 100644 --- a/packages/dashboard-core/src/screens/projects/SkillSetWorkspaceNavigation.tsx +++ b/packages/dashboard-core/src/screens/projects/SkillSetWorkspaceNavigation.tsx @@ -1,14 +1,27 @@ "use client"; -import { ActivityIcon, FolderKanbanIcon, FolderPlusIcon, PlusIcon, RadarIcon } from "lucide-react"; +import { + ActivityIcon, + FolderKanbanIcon, + FolderPlusIcon, + LinkIcon, + PackageOpenIcon, + PlusIcon, + RadarIcon, +} from "lucide-react"; import { PageHeader } from "@selftune/ui/components"; import { Badge, Button, Tabs, TabsList, TabsTrigger } from "@selftune/ui/primitives"; -export type SkillSetWorkspaceView = "sets" | "outcomes" | "trace-signals"; +export type SkillSetWorkspaceView = "sets" | "outcomes" | "trace-signals" | "shared-packs"; function isWorkspaceView(value: string): value is SkillSetWorkspaceView { - return value === "sets" || value === "outcomes" || value === "trace-signals"; + return ( + value === "sets" || + value === "outcomes" || + value === "trace-signals" || + value === "shared-packs" + ); } export function SkillSetPageHeader({ @@ -20,6 +33,8 @@ export function SkillSetPageHeader({ onCreate, canSetUpProject = false, onSetUpProject, + canImport = false, + onImport, }: { skillSetCount: number; activeInstallCount: number; @@ -29,6 +44,8 @@ export function SkillSetPageHeader({ onCreate(): void; canSetUpProject?: boolean; onSetUpProject?(): void; + canImport?: boolean; + onImport?(): void; }) { return ( + {canImport && onImport ? ( + + ) : null} {canSetUpProject && onSetUpProject ? ( ) : null} - + {canCreate ? ( + + ) : null} ) : upgradeHref ? ( + + + + ) : ( +
    +
    + + setCopyrightHolder(event.target.value)} + /> +
    +
    + + setLicensedOrganization(event.target.value)} + /> +
    +
    + + setYear(event.target.value)} + /> +
    +

    + The draft permits internal use, modification, and private distribution to + authorized personnel. External redistribution remains prohibited. +

    +
    + )} + + ) : shareUrl ? (
    @@ -119,66 +231,99 @@ export function ShareSkillDialog({ setError(null); }} > - + Copy link - {supportsEmail ? ( - - Email invite - - ) : null} + + Email invite + - {supportsPrivateClaim ? ( - <> - - - - ) : null} + +

    - {!supportsPrivateClaim || mode === "reusable_unlisted" + {mode === "reusable_unlisted" ? "The link works until it expires or you revoke it." : "The first signed-in recipient claims it; it cannot be claimed again."}

    - {supportsEmail ? ( - - - setEmail(event.target.value)} - /> -

    - This private invitation can only be claimed by an account with this email. -

    -
    - ) : null} + + + setEmail(event.target.value)} + /> +

    + This private invitation can only be claimed by an account with this email. +

    +
    )} {error ? ( -

    - {error} -

    +
    +

    + {error} +

    + {!draftingLicense && + previewLicenseAction?.access === "available" && + applyLicenseAction?.access === "available" && + /license/i.test(error) ? ( + + ) : null} +
    ) : null} - - {!shareUrl && !sent ? ( + {draftingLicense ? ( + + ) : ( + + )} + {draftingLicense ? ( + + ) : !shareUrl && !sent ? ( + + ); +} diff --git a/packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.interaction.test.tsx b/packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.interaction.test.tsx new file mode 100644 index 00000000..355c9145 --- /dev/null +++ b/packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.interaction.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DashboardHostProvider, + type DashboardHostModules, + type DashboardTeamCollaborationActions, +} from "../../host"; +import type { TeamCollaborationSnapshotModel } from "../../models"; +import { hostModules } from "../../test/host-modules"; +import { TeamCollaborationScreen } from "./TeamCollaborationScreen"; + +afterEach(cleanup); + +const unavailableAction = { + access: "unavailable", + reason: "Workspace admin permission required.", +} as const; + +const snapshot: TeamCollaborationSnapshotModel = { + entries: [ + { + id: "skill-tdd", + name: "tdd", + rolloutPolicy: "automatic", + currentVersion: "2.4.0", + pendingContributions: 1, + installations: 2, + conflicts: 1, + }, + ], + contributions: [ + { + id: "candidate-pending", + entryId: "skill-tdd", + entryName: "tdd", + baseVersionId: "version-240", + baseVersion: "2.4.0", + candidateVersion: "2.4.1", + candidateContentHash: "c9b0d91adfa27f1b9baea5028e78d3fe8eb612ec099547d3a77d6b741f54db0a", + files: [], + changes: [ + { + path: "SKILL.md", + kind: "modified", + baseHash: "aae2fcbb8193b721ed4acc474b29d4f81f76d798186832b03238a47bc16c3b97", + candidateHash: "9ea539d95f66f41e6ce6ed729ef8dd67074d6e3de8d95ca881319c551067a48b", + }, + { + path: "references/checklist.md", + kind: "added", + baseHash: null, + candidateHash: "397f08c4fab8500c93b744eb647961a448d66ea852e69bec516fa56c2dc30f9b", + }, + ], + summary: "Add a deterministic red-green-refactor checklist.", + submittedBy: "member-1", + submittedByName: "Mira Haddad", + status: "pending", + reviewedBy: null, + adoptedVersionId: null, + createdAt: "2026-07-31T08:14:00.000Z", + reviewedAt: null, + }, + { + id: "candidate-adopted", + entryId: "skill-tdd", + entryName: "tdd", + baseVersionId: "version-230", + baseVersion: "2.3.0", + candidateVersion: "2.4.0", + candidateContentHash: "39f5aa99dfa27f1b9baea5028e78d3fe8eb612ec099547d3a77d6b741f54af25", + files: [], + changes: [], + efficacyEvidence: { + summary: "Passed the attached refund-routing evaluation without a measured regression.", + evaluatedCases: 24, + passedCases: 22, + regressionCount: 0, + }, + summary: "Clarify the regression gate.", + submittedBy: "member-2", + submittedByName: "Rami Odeh", + status: "adopted", + reviewedBy: "admin-1", + adoptedVersionId: "version-240", + createdAt: "2026-07-30T08:14:00.000Z", + reviewedAt: "2026-07-30T09:00:00.000Z", + }, + ], + installations: [ + { + id: "installation-1", + entryId: "skill-tdd", + entryName: "tdd", + deviceId: "design-macbook", + installedVersion: "2.3.0", + installedContentHash: "31ec68004ea5a707977d61e987a57209768585c11066990f9497877ef122f900", + latestVersion: "2.4.0", + latestContentHash: "be0c095b6a3430afde8cbbacb21b2220e256ff1598cc1a5e9c4cff60bbdc9443", + rolloutPolicy: "automatic", + updateStatus: "conflict", + lastSyncedAt: "2026-07-31T08:20:00.000Z", + lastConflictAt: "2026-07-31T08:20:00.000Z", + lastReceiptId: "71b94453-76bb-4a43-91b3-d3ff8433a49d", + }, + ], +}; + +function makeModules( + actions: DashboardTeamCollaborationActions, + refresh = vi.fn(async () => undefined), +): DashboardHostModules { + return hostModules({ + capability: { host: "cloud", plan: "team", features: {} }, + teamCollaboration: { + collaboration: { + access: "available", + useSnapshot: () => ({ + data: snapshot, + isLoading: false, + error: null, + refresh, + }), + useActions: () => actions, + }, + }, + }); +} + +describe("TeamCollaborationScreen", () => { + it("leads with teammate updates and keeps technical identifiers behind details", () => { + render( + + + , + ); + + expect(screen.getByText(/About privacy and sharing/i)).toBeTruthy(); + expect(screen.getByText(/information is included in a team update/i)).toBeTruthy(); + expect(screen.queryByRole("heading", { name: "Review path" })).toBeNull(); + expect(screen.getByRole("heading", { name: "Updates from your team" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Skill" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy(); + expect( + screen.getByText(/Raw prompts, transcripts, and usage content are not included/i), + ).toBeTruthy(); + expect(screen.getByText(/Optional usage signals are separate/i)).toBeTruthy(); + expect(screen.getAllByText("SKILL.md")).toHaveLength(2); + expect(screen.getAllByText("references/checklist.md")).toHaveLength(2); + const packageHash = screen.getByText(snapshot.contributions[0]!.candidateContentHash); + expect(packageHash.closest("details")?.hasAttribute("open")).toBe(false); + expect(screen.getByText("member-1")).toBeTruthy(); + expect(screen.getByText("version-240")).toBeTruthy(); + expect(screen.getByText("Not tested")).toBeTruthy(); + expect(screen.getByText(/No test results were included/i)).toBeTruthy(); + expect(screen.getByText("Tested")).toBeTruthy(); + expect(screen.getByText(/Passed the attached refund-routing evaluation/i)).toBeTruthy(); + expect(screen.getByRole("combobox", { name: "Update setting for tdd" }).textContent).toContain( + "Update automatically", + ); + expect(screen.getByText("Needs attention")).toBeTruthy(); + const receipt = screen.getByText(/71b94453-76/); + expect(receipt.closest("details")?.hasAttribute("open")).toBe(false); + }); + + it("adopts, rejects, and rolls back through review-before-mutation actions", async () => { + const adopt = vi.fn(async () => undefined); + const reject = vi.fn(async () => undefined); + const rollback = vi.fn(async () => undefined); + const refresh = vi.fn(async () => undefined); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept update" })); + await waitFor(() => expect(adopt).toHaveBeenCalledWith("candidate-pending")); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + await waitFor(() => expect(reject).toHaveBeenCalledWith("candidate-pending")); + + expect(screen.getByText(/Team copies set to update automatically receive/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Undo update" })); + await waitFor(() => expect(rollback).toHaveBeenCalledWith("candidate-adopted")); + expect(refresh).toHaveBeenCalledTimes(3); + }); + + it("fails closed when a host has no collaboration contribution", () => { + render( + + + , + ); + + expect(screen.getByText("Team collaboration unavailable")).toBeTruthy(); + expect(screen.getByText(/has not connected team collaboration yet/i)).toBeTruthy(); + }); +}); diff --git a/packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.tsx b/packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.tsx new file mode 100644 index 00000000..ef263f68 --- /dev/null +++ b/packages/dashboard-core/src/screens/team-collaboration/TeamCollaborationScreen.tsx @@ -0,0 +1,820 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + AlertTriangleIcon, + CheckCircle2Icon, + FileDiffIcon, + LaptopIcon, + ShieldCheckIcon, + UsersIcon, +} from "lucide-react"; + +import { + useTeamCollaborationModule, + type DashboardTeamCollaborationContribution, +} from "../../host"; +import type { + TeamContributionChangeModel, + TeamCollaborationSnapshotModel, + TeamManagedInstallationModel, + TeamRevisionContributionModel, + TeamRolloutPolicyModel, +} from "../../models"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + LoadingButton, + PageHeader, + PageScaffold, + SkeletonSwap, + SortableTable, + type SortableColumn, +} from "@selftune/ui/components"; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@selftune/ui/primitives"; + +const rolloutCopy = { + manual: { + label: "Ask first", + detail: "Each person chooses when to install an update.", + }, + notify: { + label: "Notify people", + detail: "Send a notification, then let each person choose.", + }, + automatic: { + label: "Update automatically", + detail: "Update unchanged team copies during scheduled sync.", + }, +} satisfies Record; + +const ROLLOUT_POLICIES = [ + "manual", + "notify", + "automatic", +] as const satisfies readonly TeamRolloutPolicyModel[]; + +function parseRolloutPolicy(value: string | null): TeamRolloutPolicyModel | null { + switch (value) { + case "manual": + case "notify": + case "automatic": + return value; + default: + return null; + } +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +function statusVariant(status: TeamManagedInstallationModel["updateStatus"]) { + if (status === "conflict" || status === "failed") return "destructive" as const; + if (status === "update_available") return "warning" as const; + if (status === "current" || status === "updated") return "secondary" as const; + return "outline" as const; +} + +function statusLabel(status: TeamManagedInstallationModel["updateStatus"]): string { + const labels = { + current: "Up to date", + update_available: "Update available", + updated: "Up to date", + conflict: "Needs attention", + failed: "Update failed", + rolled_back: "Update undone", + } satisfies Record; + return labels[status]; +} + +function contributionStatusLabel(status: TeamRevisionContributionModel["status"]): string { + const labels = { + pending: "Needs review", + rejected: "Dismissed", + adopted: "Accepted", + stale: "Outdated", + rolled_back: "Undone", + } satisfies Record; + return labels[status]; +} + +function changeVariant(kind: TeamContributionChangeModel["kind"]) { + if (kind === "removed") return "destructive" as const; + if (kind === "added") return "secondary" as const; + return "outline" as const; +} + +function changeLabel(kind: TeamContributionChangeModel["kind"]): string { + if (kind === "modified") return "Changed"; + if (kind === "removed") return "Removed"; + return "Added"; +} + +function CollaborationUnavailable({ reason }: { reason: string }) { + return ( + + + + + + Team collaboration unavailable + {reason} + + + + + ); +} + +function CollaborationLoading() { + return ( + +
    + + +
    + +
    + + +
    +
    + ); +} + +function SharingNotice() { + return ( +
    + + + + + About privacy and sharing + + See what information is included in a team update. + + + + Show details + + + Hide details + + +
    +

    + When someone submits an update, teammates receive the skill files, version information, + contributor name, and change summary. +

    +

    + Raw prompts, transcripts, and usage content are not included. Optional usage signals are + separate and stay off until enabled. +

    +
    +
    + ); +} + +function SummaryStrip({ + pending, + managed, + conflicts, + current, +}: { + pending: number; + managed: number; + conflicts: number; + current: number; +}) { + const items = [ + { label: "Updates to review", value: pending, icon: FileDiffIcon }, + { label: "Team installs", value: managed, icon: LaptopIcon }, + { label: "Up to date", value: current, icon: CheckCircle2Icon }, + { label: "Need attention", value: conflicts, icon: AlertTriangleIcon }, + ]; + return ( +
    + {items.map(({ label, value, icon: Icon }, index) => ( +
    0 ? "border-t sm:border-t-0 sm:border-l" : ""} ${index === 2 ? "sm:border-l-0 lg:border-l" : ""}`} + > +
    + ))} +
    + ); +} + +interface CandidateReviewProps { + contribution: TeamRevisionContributionModel; + rolloutPolicy: TeamRolloutPolicyModel; + pendingAction: string | null; + canAdopt: boolean; + canReject: boolean; + canRollback: boolean; + onAdopt(): void; + onReject(): void; + onRollback(): void; +} + +function CandidateReview({ + contribution, + rolloutPolicy, + pendingAction, + canAdopt, + canReject, + canRollback, + onAdopt, + onReject, + onRollback, +}: CandidateReviewProps) { + const isPending = contribution.status === "pending"; + const efficacyEvidence = contribution.efficacyEvidence; + return ( +
    +
    +
    +
    +
    +

    {contribution.entryName}

    + + {contributionStatusLabel(contribution.status)} + +
    +

    + Suggested by {contribution.submittedByName} · {formatDate(contribution.createdAt)} +

    +
    + {isPending ? ( +
    + + +
    + ) : contribution.status === "adopted" ? ( +
    + + {rolloutPolicy === "automatic" ? ( +

    + Team copies set to update automatically receive the previous version at their next + sync. +

    + ) : null} +
    + ) : null} +
    + +

    {contribution.summary}

    + +
    +
    +
    New version
    +
    {contribution.candidateVersion}
    +
    Updates {contribution.baseVersion}
    +
    +
    +
    Files changed
    +
    {contribution.changes.length}
    +
    + {contribution.files.length} files in the shared skill +
    +
    +
    +
    Testing
    +
    + + {efficacyEvidence ? "Tested" : "Not tested"} + +
    +
    +
    + +
    +

    Test results

    + {efficacyEvidence ? ( +
    +

    {efficacyEvidence.summary}

    +

    + {efficacyEvidence.passedCases} of {efficacyEvidence.evaluatedCases} test cases + passed ·{" "} + {efficacyEvidence.regressionCount === 0 + ? "No regressions found" + : `${efficacyEvidence.regressionCount} regression${efficacyEvidence.regressionCount === 1 ? "" : "s"} found`} +

    +
    + ) : ( +

    + No test results were included. Review the changed files before accepting this update. +

    + )} +
    + +
    +

    Files changed

    + {contribution.changes.length === 0 ? ( +

    + No file changes were reported for this update. +

    + ) : ( +
      + {contribution.changes.map((change) => ( +
    • + {changeLabel(change.kind)} + + {change.path} + +
    • + ))} +
    + )} +
    + +
    + + Technical details + +
    +
    +
    +
    Contributor ID
    +
    {contribution.submittedBy}
    +
    +
    +
    Base version ID
    +
    {contribution.baseVersionId}
    +
    +
    +
    Package SHA-256
    +
    {contribution.candidateContentHash}
    +
    +
    + {contribution.changes.length > 0 ? ( +
      + {contribution.changes.map((change) => ( +
    • +

      {change.path}

      +
      +
      +
      Previous SHA-256
      +
      + {change.baseHash ?? "Not present"} +
      +
      +
      +
      New SHA-256
      +
      + {change.candidateHash ?? "Not present"} +
      +
      +
      +
    • + ))} +
    + ) : null} +
    +
    +
    +
    + ); +} + +function TeamCollaborationAvailable({ + contribution, +}: { + contribution: Extract; +}) { + const query = contribution.useSnapshot(); + const actions = contribution.useActions(); + const [pendingAction, setPendingAction] = useState(null); + const [actionError, setActionError] = useState(null); + const snapshot = query.data; + + const summary = useMemo(() => { + const installations = snapshot?.installations ?? []; + return { + pending: snapshot?.contributions.filter(({ status }) => status === "pending").length ?? 0, + managed: installations.length, + conflicts: installations.filter(({ updateStatus }) => updateStatus === "conflict").length, + current: installations.filter( + ({ updateStatus }) => updateStatus === "current" || updateStatus === "updated", + ).length, + }; + }, [snapshot]); + + const rolloutColumns: SortableColumn[] = [ + { + id: "skill", + header: "Skill", + width: "minmax(14rem, 1.5fr)", + value: (entry) => entry.name, + cell: (entry) => ( +
    +
    {entry.name}
    +
    + {entry.pendingContributions} to review · {entry.conflicts} need attention +
    +
    + ), + }, + { + id: "published", + header: "Shared version", + width: "minmax(7rem, 0.7fr)", + value: (entry) => entry.currentVersion ?? "Not published", + cell: (entry) => ( + {entry.currentVersion ?? "Not published"} + ), + }, + { + id: "fleet", + header: "Installs", + width: "minmax(5rem, 0.45fr)", + value: (entry) => entry.installations, + numeric: true, + cell: (entry) => {entry.installations}, + }, + { + id: "policy", + header: "Updates", + width: "minmax(15rem, 1.2fr)", + value: (entry) => rolloutCopy[entry.rolloutPolicy].label, + cell: (entry) => { + const actionKey = `policy:${entry.id}`; + return ( + + ); + }, + }, + ]; + + if (query.isLoading && !snapshot) return ; + if (query.error && !snapshot) { + return ( + + + + + Team updates could not be loaded + {query.error} + + + + + + + ); + } + if (!snapshot) return ; + + async function runAction(key: string, operation: () => Promise) { + setPendingAction(key); + setActionError(null); + try { + await operation(); + await query.refresh(); + } catch (cause) { + setActionError( + cause instanceof Error ? cause.message : "The team change could not be saved.", + ); + } finally { + setPendingAction(null); + } + } + + return ( + + query.refresh()} + pendingLabel="Refreshing…" + successLabel="Refreshed" + errorLabel="Retry" + disabled={query.isLoading || pendingAction !== null} + > + Refresh + + } + /> + + + {query.isLoading ? null : } + + + {query.error || actionError ? ( +
    + {actionError ?? query.error} +
    + ) : null} + +
    +
    +
    +

    + Updates from your team +

    +

    + See what changed, check any test results, and choose whether to accept the update. +

    +
    + {summary.pending} to review +
    + {snapshot.contributions.length === 0 ? ( + + + No updates to review + + New skill updates from your teammates will appear here. + + + + ) : ( + snapshot.contributions.map((item) => ( + id === item.entryId)?.rolloutPolicy ?? "manual" + } + pendingAction={pendingAction} + canAdopt={actions.adoptContribution.access === "available"} + canReject={actions.rejectContribution.access === "available"} + canRollback={actions.rollbackContribution.access === "available"} + onAdopt={() => { + const action = actions.adoptContribution; + if (action.access !== "available") return; + void runAction(`adopt:${item.id}`, () => action.execute(item.id)); + }} + onReject={() => { + const action = actions.rejectContribution; + if (action.access !== "available") return; + void runAction(`reject:${item.id}`, () => action.execute(item.id)); + }} + onRollback={() => { + const action = actions.rollbackContribution; + if (action.access !== "available") return; + void runAction(`rollback:${item.id}`, () => action.execute(item.id)); + }} + /> + )) + )} +
    + +
    +
    +

    + Update settings +

    +

    + Choose how each shared skill reaches the people who use it. +

    +
    + + {query.isLoading ? null : ( + entry.id} + label="Update settings" + defaultSort={{ columnId: "skill", direction: "asc" }} + rowHeight={68} + maxHeight={320} + className="rounded-none border-0 shadow-none" + /> + )} + + {snapshot.entries.length === 0 ? ( +

    + Share a skill with your workspace before choosing how it should update. +

    + ) : null} +
    + +
    +
    +
    +
    + + + + Skill / device + Installed + Available + Updates + Status + Details + + + + {snapshot.installations.map((installation) => ( + + +
    {installation.entryName}
    +
    + {installation.deviceId} +
    +
    + +
    {installation.installedVersion}
    +
    + +
    {installation.latestVersion}
    +
    + {rolloutCopy[installation.rolloutPolicy].label} + + + {statusLabel(installation.updateStatus)} + + {installation.lastConflictAt ? ( +
    + Since {formatDate(installation.lastConflictAt)} +
    + ) : null} +
    + +
    + + View details + +
    +
    +
    Last checked
    +
    + {formatDate(installation.lastSyncedAt)} +
    +
    +
    +
    Installed SHA-256
    +
    + {installation.installedContentHash ?? "Not available"} +
    +
    +
    +
    Available SHA-256
    +
    + {installation.latestContentHash} +
    +
    +
    +
    Update receipt
    +
    + {installation.lastReceiptId ?? "Not available"} +
    +
    +
    +
    +
    +
    + ))} +
    +
    +
    + {snapshot.installations.length === 0 ? ( +

    + Team installs appear after someone installs a shared skill. +

    + ) : null} +
    + + +
    + ); +} + +export function TeamCollaborationScreen() { + const contribution = useTeamCollaborationModule().collaboration; + if (!contribution) { + return ( + + ); + } + if (contribution.access === "upgrade") { + return ( + + ); + } + if (contribution.access === "unavailable") { + return ; + } + return ; +} diff --git a/packages/dashboard-core/src/screens/team-collaboration/index.ts b/packages/dashboard-core/src/screens/team-collaboration/index.ts new file mode 100644 index 00000000..7d20ab4f --- /dev/null +++ b/packages/dashboard-core/src/screens/team-collaboration/index.ts @@ -0,0 +1 @@ +export { TeamCollaborationScreen } from "./TeamCollaborationScreen"; diff --git a/packages/dashboard-core/src/test/host-modules.ts b/packages/dashboard-core/src/test/host-modules.ts new file mode 100644 index 00000000..ee023183 --- /dev/null +++ b/packages/dashboard-core/src/test/host-modules.ts @@ -0,0 +1,15 @@ +import type { DashboardHostModules } from "../host"; + +const unavailable = { access: "unavailable", reason: "Not used by this test." } as const; + +export function hostModules(overrides: Partial = {}): DashboardHostModules { + return { + capability: { host: "local", plan: "oss", features: {} }, + skillSets: { projects: unavailable, library: unavailable }, + skills: { host: "local", library: unavailable, decisions: unavailable }, + plugins: {}, + recipientShares: {}, + teamCollaboration: {}, + ...overrides, + }; +} diff --git a/packages/harnesses/claude-code/CHANGELOG.md b/packages/harnesses/claude-code/CHANGELOG.md deleted file mode 100644 index b293b516..00000000 --- a/packages/harnesses/claude-code/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# @selftune/harness-claude-code - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 diff --git a/packages/harnesses/claude-code/package.json b/packages/harnesses/claude-code/package.json index 2f0071aa..4a837762 100644 --- a/packages/harnesses/claude-code/package.json +++ b/packages/harnesses/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-claude-code", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "Claude Code hooks and transcript ingestion", "license": "MIT", diff --git a/packages/harnesses/cline/CHANGELOG.md b/packages/harnesses/cline/CHANGELOG.md deleted file mode 100644 index 59b27321..00000000 --- a/packages/harnesses/cline/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @selftune/harness-cline - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 diff --git a/packages/harnesses/cline/package.json b/packages/harnesses/cline/package.json index fe5c17d2..5de149c2 100644 --- a/packages/harnesses/cline/package.json +++ b/packages/harnesses/cline/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-cline", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "Cline hook adapter and installer", "license": "MIT", diff --git a/packages/harnesses/codex/CHANGELOG.md b/packages/harnesses/codex/CHANGELOG.md deleted file mode 100644 index dd0fc500..00000000 --- a/packages/harnesses/codex/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @selftune/harness-codex - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 diff --git a/packages/harnesses/codex/package.json b/packages/harnesses/codex/package.json index dc965b9e..60b99787 100644 --- a/packages/harnesses/codex/package.json +++ b/packages/harnesses/codex/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-codex", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "Codex hooks and session ingestion", "license": "MIT", @@ -9,7 +9,7 @@ "./*": "./src/*.ts" }, "scripts": { - "test": "bun test ../../../tests/ingestors/codex-internal-target-safety.test.ts ../../../tests/ingestors/codex-rollout-discovery.test.ts ../../../tests/ingestors/codex-rollout-explicit-skills.test.ts ../../../tests/ingestors/codex-rollout.test.ts ../../../tests/ingestors/codex-source-sync-analytics.test.ts ../../../tests/ingestors/codex-trace-projection.test.ts ../../../tests/ingestors/codex-wrapper.test.ts ../../../tests/ingestors/rollout-line-scanner.test.ts ../../../tests/init/codex-install.test.ts", + "test": "bun test ../../../tests/ingestors/codex-rollout.test.ts ../../../tests/ingestors/codex-source-sync-analytics.test.ts ../../../tests/ingestors/codex-wrapper.test.ts ../../../tests/init/codex-install.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/harnesses/core/package.json b/packages/harnesses/core/package.json index 52d15f0e..d2a3968e 100644 --- a/packages/harnesses/core/package.json +++ b/packages/harnesses/core/package.json @@ -9,7 +9,7 @@ "./*": "./src/*.ts" }, "scripts": { - "test": "bun test ../../../tests/hooks/in-process-dispatch.test.ts ../../../tests/ingestors/harness-source-adapters.test.ts", + "test": "bun test ../../../tests/hooks/in-process-dispatch.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/harnesses/openclaw/CHANGELOG.md b/packages/harnesses/openclaw/CHANGELOG.md deleted file mode 100644 index 1f3870c6..00000000 --- a/packages/harnesses/openclaw/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# @selftune/harness-openclaw - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 diff --git a/packages/harnesses/openclaw/package.json b/packages/harnesses/openclaw/package.json index 4f18f8ad..a010683e 100644 --- a/packages/harnesses/openclaw/package.json +++ b/packages/harnesses/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-openclaw", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "OpenClaw scheduler and session ingestion", "license": "MIT", diff --git a/packages/harnesses/opencode/CHANGELOG.md b/packages/harnesses/opencode/CHANGELOG.md deleted file mode 100644 index e941d3d5..00000000 --- a/packages/harnesses/opencode/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @selftune/harness-opencode - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 diff --git a/packages/harnesses/opencode/package.json b/packages/harnesses/opencode/package.json index a749d078..4d996952 100644 --- a/packages/harnesses/opencode/package.json +++ b/packages/harnesses/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-opencode", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "OpenCode hooks and session ingestion", "license": "MIT", diff --git a/packages/harnesses/pi/CHANGELOG.md b/packages/harnesses/pi/CHANGELOG.md deleted file mode 100644 index 99d603c0..00000000 --- a/packages/harnesses/pi/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @selftune/harness-pi - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 diff --git a/packages/harnesses/pi/package.json b/packages/harnesses/pi/package.json index 735dd5b7..b65e9925 100644 --- a/packages/harnesses/pi/package.json +++ b/packages/harnesses/pi/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-pi", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "Pi hooks and session ingestion", "license": "MIT", diff --git a/packages/harnesses/registry/CHANGELOG.md b/packages/harnesses/registry/CHANGELOG.md deleted file mode 100644 index 366277ae..00000000 --- a/packages/harnesses/registry/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @selftune/harness-registry - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/harness-claude-code@1.0.1 - - @selftune/harness-cline@1.0.1 - - @selftune/harness-codex@1.0.1 - - @selftune/harness-openclaw@1.0.1 - - @selftune/harness-opencode@1.0.1 - - @selftune/harness-pi@1.0.1 diff --git a/packages/harnesses/registry/package.json b/packages/harnesses/registry/package.json index 8ae08832..702fa9c9 100644 --- a/packages/harnesses/registry/package.json +++ b/packages/harnesses/registry/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/harness-registry", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "Aggregate registry for SelfTune harness contributions", "license": "MIT", diff --git a/packages/library/src/hash.ts b/packages/library/src/hash.ts index 12d37a5a..34f5b131 100644 --- a/packages/library/src/hash.ts +++ b/packages/library/src/hash.ts @@ -18,6 +18,7 @@ function isPackageMetadataPath(relativePath: string): boolean { function buildSkillPackageManifest( rootDir: string, base = "", + contentOverrides: ReadonlyMap = new Map(), ): Array<{ path: string; hash: string; size: number }> { const manifest: Array<{ path: string; hash: string; size: number }> = []; for (const entry of readdirSync(rootDir, { withFileTypes: true })) { @@ -25,12 +26,12 @@ function buildSkillPackageManifest( if (isPackageMetadataPath(relativePath)) continue; const fullPath = join(rootDir, entry.name); if (entry.isDirectory()) { - manifest.push(...buildSkillPackageManifest(fullPath, relativePath)); + manifest.push(...buildSkillPackageManifest(fullPath, relativePath, contentOverrides)); continue; } const fileStat = statSync(fullPath); if (!fileStat.isFile()) continue; - const content = readFileSync(fullPath); + const content = contentOverrides.get(fullPath) ?? readFileSync(fullPath); manifest.push({ path: relativePath, hash: createHash("sha256").update(content).digest("hex"), @@ -54,3 +55,32 @@ export function computeSkillVersionHash(skillPath: string): string | undefined { return undefined; } } + +/** + * Computes the exact package revision that would result from replacing only + * SKILL.md content. The package is never copied or mutated, so candidate + * evaluation can pin the same revision a later reviewed deployment will + * produce while scripts and supporting files remain part of the hash. + */ +export function computeSkillVersionHashWithContent( + skillPath: string, + skillContent: string, +): string | undefined { + const trimmedPath = skillPath.trim(); + if (!trimmedPath || basename(trimmedPath).toUpperCase() !== "SKILL.MD") return undefined; + + try { + const content = new TextEncoder().encode(skillContent); + const manifest = buildSkillPackageManifest( + dirname(trimmedPath), + "", + new Map([[trimmedPath, content]]), + ); + if (!manifest.some((entry) => basename(entry.path).toUpperCase() === "SKILL.MD")) { + return undefined; + } + return createHash("sha256").update(canonicalizeSkillPackageManifest(manifest)).digest("hex"); + } catch { + return undefined; + } +} diff --git a/packages/library/src/manifests.ts b/packages/library/src/manifests.ts index ef9802d2..3369f79f 100644 --- a/packages/library/src/manifests.ts +++ b/packages/library/src/manifests.ts @@ -1,7 +1,27 @@ -import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; import { basename, join, resolve } from "node:path"; import { LibraryError as CLIError } from "./errors.js"; +import { + createPortablePluginZip, + decodePortableSkillSetEnvelope, + encodeCanonicalSkillSetSourceManifest, + encodePortablePackageBundle, + encodePortableSkillSetEnvelope, + projectPortablePluginFiles, + type PortablePluginExportFile, + type PortablePluginExportTarget, +} from "@selftune/control-plane"; import { computeSkillVersionHash } from "./hash.js"; import { targetRegistryPath } from "./paths.js"; import { decodeStoredSkillSetManifest } from "./schemas.js"; @@ -9,14 +29,17 @@ import { atomicWriteJson, cacheSkillPackage, canonicalRevisionHash, + configRoot, manifestPath, persistManifestRevision, resolveManifest, revisionsDir, + skillSetTombstonePath, skillSetsDir, slugifySetId, toStoredManifest, } from "./storage.js"; +import * as Effect from "effect/Effect"; import type { CreateSkillSetInput, SkillSetHarnessId, @@ -347,6 +370,139 @@ export function exportPortableSkillSet( return outputPath; } +function pluginSkillFiles(root: string, relative = ""): PortablePluginExportFile[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = relative ? `${relative}/${entry.name}` : entry.name; + const absolute = join(root, entry.name); + const stat = lstatSync(absolute); + if (stat.isSymbolicLink()) { + throw new CLIError(`Plugin export rejects symbolic link: ${path}`, "GUARD_BLOCKED"); + } + if (entry.isDirectory()) return pluginSkillFiles(absolute, path); + return entry.isFile() ? [{ path, content: readFileSync(absolute) }] : []; + }); +} + +function packagedLicense(files: ReadonlyArray): { + readonly licenseExpression: string; + readonly licenseFilePath?: string; + readonly noticePaths: ReadonlyArray; +} { + const skill = files.find((file) => file.path === "SKILL.md"); + const text = skill ? new TextDecoder().decode(skill.content) : ""; + const frontmatter = text.startsWith("---") ? (text.split("---", 3)[1] ?? "") : ""; + const declared = /^license\s*:\s*([^\r\n]+)/im.exec(frontmatter)?.[1]?.trim(); + const licenseFile = files.find((file) => + /^(?:license|copying)(?:\.[A-Za-z0-9_-]+)?$/i.test(file.path), + ); + if (!declared && !licenseFile) { + throw new CLIError( + "Every Pack component requires a license field or bundled LICENSE/COPYING file.", + "GUARD_BLOCKED", + ); + } + return { + licenseExpression: declared || "LicenseRef-Bundled", + ...(licenseFile ? { licenseFilePath: licenseFile.path } : {}), + noticePaths: files + .filter((file) => /^(?:notice)(?:\.[A-Za-z0-9_-]+)?$/i.test(file.path)) + .map((file) => file.path), + }; +} + +export function exportPortableSkillSetPackBytes( + setId: string, + options: SkillSetServiceOptions = {}, +): Uint8Array { + const manifest = getSkillSet(setId, options); + const packaged = manifest.skills.map((skill, ordinal) => { + const files = pluginSkillFiles(skill.library_package_path); + const bytes = Effect.runSync(encodePortablePackageBundle({ files })); + return { + ordinal, + logicalSkillId: skill.name, + sourceRevisionSha256: skill.content_hash, + sourcePackageObjectSha256: createHash("sha256").update(bytes).digest("hex"), + sealedPackageBytes: bytes, + terms: packagedLicense(files), + }; + }); + const source = Effect.runSync( + encodeCanonicalSkillSetSourceManifest({ + skillSetId: manifest.set_id, + name: manifest.name, + description: manifest.description, + harnesses: manifest.harnesses, + components: packaged.map((component) => ({ + ordinal: component.ordinal, + logicalSkillId: component.logicalSkillId, + sourceRevisionSha256: component.sourceRevisionSha256, + sourcePackageObjectSha256: component.sourcePackageObjectSha256, + })), + }), + ); + return Effect.runSync( + encodePortableSkillSetEnvelope({ + sourceManifestBytes: source.bytes, + components: packaged, + }), + ).bytes; +} + +export function exportSkillSetPluginArchive( + setId: string, + target: PortablePluginExportTarget, + options: SkillSetServiceOptions = {}, +): { readonly filename: string; readonly content_base64: string } { + const projected = projectSkillSetPlugin(setId, target, options); + return { + filename: `${projected.pluginName}-${target}.zip`, + content_base64: Buffer.from(createPortablePluginZip(projected.files)).toString("base64"), + }; +} + +export function projectSkillSetPlugin( + setId: string, + target: PortablePluginExportTarget, + options: SkillSetServiceOptions = {}, +): { + readonly setId: string; + readonly setName: string; + readonly revisionHash: string; + readonly pluginName: string; + readonly skillNames: ReadonlyArray; + readonly files: ReadonlyArray; +} { + const manifest = getSkillSet(setId, options); + const skills = manifest.skills.map((skill) => { + const skillFile = join(skill.library_package_path, "SKILL.md"); + if (!existsSync(skillFile) || computeSkillVersionHash(skillFile) !== skill.content_hash) { + throw new CLIError( + `Pinned revision for "${skill.name}" failed verification.`, + "GUARD_BLOCKED", + "Run Sync & Backup, then retry the plugin export.", + ); + } + return { name: skill.name, files: pluginSkillFiles(skill.library_package_path) }; + }); + const projected = projectPortablePluginFiles({ + target, + name: manifest.name, + description: manifest.description, + skillSetId: manifest.set_id, + skillSetRevisionSha256: manifest.revision_hash, + skills, + }); + return { + setId: manifest.set_id, + setName: manifest.name, + revisionHash: manifest.revision_hash, + pluginName: projected.pluginName, + skillNames: skills.map((skill) => skill.name), + files: projected.files, + }; +} + export function importPortableSkillSet( portablePath: string, options: SkillSetServiceOptions & { @@ -385,6 +541,61 @@ export function importPortableSkillSet( return manifest; } +/** + * Imports a sealed Pack envelope only after the control-plane decoder has + * validated its canonical JSON, every hash binding, aggregate limits, safe + * paths, and bundled license/notice references. + */ +export function importPortableSkillSetPack( + bytes: Uint8Array, + options: SkillSetServiceOptions = {}, +): { + readonly manifest: SkillSetManifest; + readonly sourceRevisionSha256: string; + readonly objectSha256: string; +} { + const decoded = Effect.runSync(decodePortableSkillSetEnvelope(bytes)); + const root = configRoot(options); + const stageRoot = join(root, `.pack-import-${randomUUID()}`); + mkdirSync(stageRoot, { recursive: true, mode: 0o700 }); + try { + const skills = decoded.components.map((component, index) => { + const envelopeComponent = decoded.envelope.components[index]; + if (!envelopeComponent) { + throw new CLIError("Pack component bindings are incomplete.", "GUARD_BLOCKED"); + } + const packageRoot = join(stageRoot, envelopeComponent.logicalSkillId); + for (const file of component.package.files) { + const target = join(packageRoot, file.path); + mkdirSync(resolve(target, ".."), { recursive: true, mode: 0o700 }); + writeFileSync(target, file.content, { mode: 0o600, flag: "wx" }); + } + return { name: envelopeComponent.logicalSkillId, package_path: packageRoot }; + }); + const source = decoded.envelope.sourceManifest; + const preferredId = slugifySetId(source.name); + const name = existsSync(manifestPath(preferredId, options)) + ? `${source.name} ${decoded.envelope.skillSetRevisionSha256.slice(0, 8)}` + : source.name; + const manifest = createSkillSet( + { + name, + description: source.description, + harnesses: [...source.harnesses], + skills, + }, + options, + ); + return { + manifest, + sourceRevisionSha256: decoded.envelope.skillSetRevisionSha256, + objectSha256: decoded.portableSkillSetEnvelopeSha256, + }; + } finally { + rmSync(stageRoot, { recursive: true, force: true }); + } +} + export function getSkillSet(setId: string, options: SkillSetServiceOptions = {}): SkillSetManifest { const path = manifestPath(setId, options); if (!existsSync(path)) { @@ -402,6 +613,26 @@ export function getSkillSet(setId: string, options: SkillSetServiceOptions = {}) } } +export function deleteSkillSet( + setId: string, + options: SkillSetServiceOptions = {}, +): { readonly deleted: true } { + const path = manifestPath(setId, options); + const manifest = getSkillSet(setId, options); + atomicWriteJson(skillSetTombstonePath(setId, options), { + schema_version: 1, + set_id: manifest.set_id, + deleted_revision_hash: manifest.revision_hash, + deleted_at: (options.now ?? new Date()).toISOString(), + }); + rmSync(path); + return { deleted: true }; +} + +export function isSkillSetDeleted(setId: string, options: SkillSetServiceOptions = {}): boolean { + return existsSync(skillSetTombstonePath(setId, options)); +} + export function listMissingSkillSetDependencies( setId: string, options: SkillSetServiceOptions = {}, diff --git a/packages/library/src/remote/sharing.ts b/packages/library/src/remote/sharing.ts index 4112a055..bdb758cb 100644 --- a/packages/library/src/remote/sharing.ts +++ b/packages/library/src/remote/sharing.ts @@ -1,4 +1,5 @@ import { LibraryError as CLIError } from "../errors.js"; +import type { SkillSetPackManagementList } from "@selftune/control-plane"; import type { CreateRemoteLibraryShareRequest, RemoteLibraryConnection, @@ -71,16 +72,30 @@ export async function createSkillShareGrant( config: RemoteLibraryConnection, input: CreateSkillShareGrantRequest, ): Promise { - const response = await fetch(`${config.url}/api/v1/share-grants`, { - method: "POST", - headers: { - Authorization: `Bearer ${config.apiKey}`, - "Content-Type": "application/json", + const isSkillSet = "skillSetId" in input; + if (isSkillSet && input.delivery !== "copy_link") { + throw new CLIError( + "Email Skill Set sharing uses the account-to-account private share flow.", + "INVALID_FLAG", + ); + } + const response = await fetch( + `${config.url}${isSkillSet ? "/api/v1/skill-set-packs" : "/api/v1/share-grants"}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(isSkillSet ? { skillSetId: input.skillSetId, mode: input.mode } : input), }, - body: JSON.stringify(input), - }); + ); const payload = (await response.json().catch(() => null)) as - | (SkillShareGrantReceipt & ErrorEnvelope) + | (SkillShareGrantReceipt & + ErrorEnvelope & { + packId?: string; + packUrl?: string; + }) | null; if (!response.ok || !payload) { const message = @@ -89,5 +104,91 @@ export async function createSkillShareGrant( : (payload?.error?.message ?? `Skill sharing failed (${response.status}).`); throw new CLIError(message, response.status === 403 ? "GUARD_BLOCKED" : "OPERATION_FAILED"); } + if (isSkillSet) { + if ( + typeof payload.packId !== "string" || + typeof payload.packUrl !== "string" || + typeof payload.expiresAt !== "string" + ) { + throw new CLIError("Skill Set Pack response was invalid.", "OPERATION_FAILED"); + } + return { + shareId: payload.packId, + mode: input.mode, + delivery: "copy_link", + shareUrl: payload.packUrl, + expiresAt: payload.expiresAt, + }; + } return payload; } + +export async function createSelfHostedSkillSetPack( + config: RemoteLibraryConnection, + input: { + snapshot_id: string; + artifact_id: string; + mode: "reusable_unlisted" | "private_single_claim"; + }, +): Promise { + const result = await request<{ + packId: string; + mode: "reusable_unlisted" | "private_single_claim"; + packUrl: string; + expiresAt: string; + }>(config, "/packs", { method: "POST", body: JSON.stringify(input) }); + return { + shareId: result.packId, + mode: result.mode, + delivery: "copy_link", + shareUrl: result.packUrl, + expiresAt: result.expiresAt, + }; +} + +function isCloudConnection(config: RemoteLibraryConnection): boolean { + const hostname = new URL(config.url).hostname.toLowerCase(); + return hostname === "cloud.selftune.dev" || hostname === "api.selftune.dev"; +} + +export async function listSkillSetPacks( + config: RemoteLibraryConnection, +): Promise { + if (isCloudConnection(config)) { + const response = await fetch(`${config.url}/api/v1/skill-set-packs`, { + headers: { Authorization: `Bearer ${config.apiKey}` }, + }); + const payload = (await response.json().catch(() => null)) as + | (SkillSetPackManagementList & ErrorEnvelope) + | null; + if (!response.ok || !payload) { + const message = + typeof payload?.error === "string" + ? payload.error + : (payload?.error?.message ?? `Pack listing failed (${response.status}).`); + throw new CLIError(message, response.status === 403 ? "GUARD_BLOCKED" : "OPERATION_FAILED"); + } + return payload; + } + return request(config, "/packs"); +} + +export async function revokeSkillSetPack( + config: RemoteLibraryConnection, + packId: string, +): Promise { + const path = isCloudConnection(config) + ? `${config.url}/api/v1/skill-set-packs/${encodeURIComponent(packId)}` + : `${config.url}/api/v1/remote-library/packs/${encodeURIComponent(packId)}`; + const response = await fetch(path, { + method: "DELETE", + headers: { Authorization: `Bearer ${config.apiKey}` }, + }); + if (response.ok) return; + const payload = (await response.json().catch(() => null)) as ErrorEnvelope | null; + const message = + typeof payload?.error === "string" + ? payload.error + : (payload?.error?.message ?? `Pack revocation failed (${response.status}).`); + throw new CLIError(message, response.status === 403 ? "GUARD_BLOCKED" : "OPERATION_FAILED"); +} diff --git a/packages/library/src/remote/types.ts b/packages/library/src/remote/types.ts index 91c48768..7cc0c3ff 100644 --- a/packages/library/src/remote/types.ts +++ b/packages/library/src/remote/types.ts @@ -53,19 +53,33 @@ export type CreateSkillShareGrantRequest = skillId: string; snapshotId: string; artifactId: string; - mode: "reusable_unlisted"; + mode: "reusable_unlisted" | "private_single_claim"; delivery: "copy_link"; } + | { + skillId: string; + snapshotId: string; + artifactId: string; + mode: "private_single_claim"; + delivery: "email"; + recipientEmail: string; + } | { skillSetId: string; - mode: "reusable_unlisted"; + mode: "reusable_unlisted" | "private_single_claim"; delivery: "copy_link"; + } + | { + skillSetId: string; + mode: "private_single_claim"; + delivery: "email"; + recipientEmail: string; }; export interface SkillShareGrantReceipt { shareId: string; - mode: "reusable_unlisted"; - delivery: "copy_link"; + mode: "reusable_unlisted" | "private_single_claim"; + delivery: "copy_link" | "email"; shareUrl: string | null; expiresAt: string; } @@ -111,3 +125,39 @@ export interface WorkspaceMembersResponse { invited_at: string; }>; } + +export interface WorkspaceTeamOverview { + current_user_id: string; + current_role: WorkspaceMemberRole; + reporting: { privacy: "metadata_only"; raw_sessions_uploaded: false }; + members: Array<{ + user_id: string; + email: string; + name: string | null; + role: WorkspaceMemberRole; + devices: Array<{ + device_id: string; + name: string; + platform: string; + last_seen_at: string; + installed_skills: number; + }>; + }>; + skills: Array<{ + identity: string; + installed_by_user_ids: string[]; + installations: Array<{ + user_id: string; + device_id: string; + device_name: string; + revision_hash: string; + scope: string; + connections: string[]; + update_status: "current" | "available" | "unknown"; + usage_status: "recent" | "stale" | "none"; + }>; + usage_status: "recent" | "stale" | "none"; + update_available_count: number; + recommendation: "update" | "review_usage" | "healthy"; + }>; +} diff --git a/packages/library/src/remote/workspace.ts b/packages/library/src/remote/workspace.ts index 36475767..ddc2835b 100644 --- a/packages/library/src/remote/workspace.ts +++ b/packages/library/src/remote/workspace.ts @@ -3,6 +3,7 @@ import type { RemoteLibraryConnection, WorkspaceMemberRole, WorkspaceMembersResponse, + WorkspaceTeamOverview, } from "./types.js"; interface ErrorEnvelope { @@ -16,6 +17,7 @@ async function request( ): Promise { const response = await fetch(`${config.url}/api/v1/teams${path}`, { ...init, + signal: init?.signal ?? AbortSignal.timeout(5_000), headers: { Authorization: `Bearer ${config.apiKey}`, ...(init?.body ? { "Content-Type": "application/json" } : {}), @@ -43,6 +45,12 @@ export function listWorkspaceMembers( return request(config, "/members"); } +export function getWorkspaceTeamOverview( + config: RemoteLibraryConnection, +): Promise { + return request(config, "/overview"); +} + export function inviteWorkspaceMember( config: RemoteLibraryConnection, input: { email: string; role: WorkspaceMemberRole }, diff --git a/packages/library/src/storage.ts b/packages/library/src/storage.ts index ecf459bb..74552ddc 100644 --- a/packages/library/src/storage.ts +++ b/packages/library/src/storage.ts @@ -45,6 +45,14 @@ export function revisionsDir(setId: string, options: SkillSetServiceOptions): st return join(configRoot(options), "skill-set-history", assertSafeSegment(setId, "Skill Set ID")); } +export function skillSetTombstonePath(setId: string, options: SkillSetServiceOptions): string { + return join( + configRoot(options), + "skill-set-tombstones", + `${assertSafeSegment(setId, "Skill Set ID")}.json`, + ); +} + export function assertSafeSegment(value: string, label: string): string { const trimmed = value.trim(); if (!trimmed || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) { @@ -57,21 +65,12 @@ export function assertSafeSegment(value: string, label: string): string { } export function slugifySetId(name: string): string { - const slug: string[] = []; - let separatorPending = false; - for (const character of name.trim().toLowerCase()) { - const codePoint = character.codePointAt(0); - const isAsciiLetter = codePoint !== undefined && codePoint >= 97 && codePoint <= 122; - const isAsciiDigit = codePoint !== undefined && codePoint >= 48 && codePoint <= 57; - if (!isAsciiLetter && !isAsciiDigit) { - separatorPending = slug.length > 0; - continue; - } - if (separatorPending) slug.push("-"); - slug.push(character); - separatorPending = false; - } - return assertSafeSegment(slug.join(""), "Skill Set name"); + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return assertSafeSegment(slug, "Skill Set name"); } export function manifestPath(setId: string, options: SkillSetServiceOptions): string { @@ -123,6 +122,7 @@ export function persistManifestRevision( ); if (!existsSync(revisionPath)) atomicWriteJson(revisionPath, stored); atomicWriteJson(manifestPath(manifest.set_id, options), stored); + rmSync(skillSetTombstonePath(manifest.set_id, options), { force: true }); } export function entryExists(path: string): boolean { diff --git a/packages/local-store/src/drizzle/0018_drop_legacy_evaluation_draft_identity.sql b/packages/local-store/src/drizzle/0018_drop_legacy_evaluation_draft_identity.sql new file mode 100644 index 00000000..6e19477b --- /dev/null +++ b/packages/local-store/src/drizzle/0018_drop_legacy_evaluation_draft_identity.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS `evaluation_submission_drafts_identity_unique`; diff --git a/packages/local-store/src/drizzle/meta/_journal.json b/packages/local-store/src/drizzle/meta/_journal.json index 63d47fc1..2d0d27d2 100644 --- a/packages/local-store/src/drizzle/meta/_journal.json +++ b/packages/local-store/src/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1785366000000, "tag": "0017_promoted_case_retirements", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1786088000000, + "tag": "0018_drop_legacy_evaluation_draft_identity", + "breakpoints": true } ] } diff --git a/packages/local-store/src/embedded-migrations.gen.ts b/packages/local-store/src/embedded-migrations.gen.ts index 877bec1a..43e17b70 100644 --- a/packages/local-store/src/embedded-migrations.gen.ts +++ b/packages/local-store/src/embedded-migrations.gen.ts @@ -2,7 +2,7 @@ export default { "meta/_journal.json": - '{\n "version": "7",\n "dialect": "sqlite",\n "entries": [\n {\n "idx": 0,\n "version": "6",\n "when": 1784164687605,\n "tag": "0000_local_runtime_baseline",\n "breakpoints": true\n },\n {\n "idx": 1,\n "version": "6",\n "when": 1784187325823,\n "tag": "0001_fast_zombie",\n "breakpoints": true\n },\n {\n "idx": 2,\n "version": "6",\n "when": 1784191026725,\n "tag": "0002_great_bloodstrike",\n "breakpoints": true\n },\n {\n "idx": 3,\n "version": "6",\n "when": 1784193433052,\n "tag": "0003_silky_luckman",\n "breakpoints": true\n },\n {\n "idx": 4,\n "version": "6",\n "when": 1784283479072,\n "tag": "0004_volatile_cerebro",\n "breakpoints": true\n },\n {\n "idx": 5,\n "version": "6",\n "when": 1784373361781,\n "tag": "0005_naive_malcolm_colcord",\n "breakpoints": true\n },\n {\n "idx": 6,\n "version": "6",\n "when": 1784617671302,\n "tag": "0006_robust_wolfsbane",\n "breakpoints": true\n },\n {\n "idx": 7,\n "version": "6",\n "when": 1784808146888,\n "tag": "0007_yellow_phil_sheldon",\n "breakpoints": true\n },\n {\n "idx": 8,\n "version": "6",\n "when": 1784809896448,\n "tag": "0008_lame_purple_man",\n "breakpoints": true\n },\n {\n "idx": 9,\n "version": "6",\n "when": 1784813701790,\n "tag": "0009_naive_prima",\n "breakpoints": true\n },\n {\n "idx": 10,\n "version": "6",\n "when": 1784827767707,\n "tag": "0010_sleepy_captain_universe",\n "breakpoints": true\n },\n {\n "idx": 11,\n "version": "6",\n "when": 1784837254256,\n "tag": "0011_third_hannibal_king",\n "breakpoints": true\n },\n {\n "idx": 12,\n "version": "6",\n "when": 1784918687054,\n "tag": "0012_dizzy_madrox",\n "breakpoints": true\n },\n {\n "idx": 13,\n "version": "6",\n "when": 1785267461030,\n "tag": "0013_unique_micromax",\n "breakpoints": true\n },\n {\n "idx": 14,\n "version": "6",\n "when": 1785355200000,\n "tag": "0014_forward_correction_study_preview",\n "breakpoints": true\n },\n {\n "idx": 15,\n "version": "6",\n "when": 1785358800000,\n "tag": "0015_e0_signal_study_drafts",\n "breakpoints": true\n },\n {\n "idx": 16,\n "version": "6",\n "when": 1785362400000,\n "tag": "0016_regular_doctor_strange",\n "breakpoints": true\n },\n {\n "idx": 17,\n "version": "6",\n "when": 1785366000000,\n "tag": "0017_promoted_case_retirements",\n "breakpoints": true\n }\n ]\n}\n', + '{\n "version": "7",\n "dialect": "sqlite",\n "entries": [\n {\n "idx": 0,\n "version": "6",\n "when": 1784164687605,\n "tag": "0000_local_runtime_baseline",\n "breakpoints": true\n },\n {\n "idx": 1,\n "version": "6",\n "when": 1784187325823,\n "tag": "0001_fast_zombie",\n "breakpoints": true\n },\n {\n "idx": 2,\n "version": "6",\n "when": 1784191026725,\n "tag": "0002_great_bloodstrike",\n "breakpoints": true\n },\n {\n "idx": 3,\n "version": "6",\n "when": 1784193433052,\n "tag": "0003_silky_luckman",\n "breakpoints": true\n },\n {\n "idx": 4,\n "version": "6",\n "when": 1784283479072,\n "tag": "0004_volatile_cerebro",\n "breakpoints": true\n },\n {\n "idx": 5,\n "version": "6",\n "when": 1784373361781,\n "tag": "0005_naive_malcolm_colcord",\n "breakpoints": true\n },\n {\n "idx": 6,\n "version": "6",\n "when": 1784617671302,\n "tag": "0006_robust_wolfsbane",\n "breakpoints": true\n },\n {\n "idx": 7,\n "version": "6",\n "when": 1784808146888,\n "tag": "0007_yellow_phil_sheldon",\n "breakpoints": true\n },\n {\n "idx": 8,\n "version": "6",\n "when": 1784809896448,\n "tag": "0008_lame_purple_man",\n "breakpoints": true\n },\n {\n "idx": 9,\n "version": "6",\n "when": 1784813701790,\n "tag": "0009_naive_prima",\n "breakpoints": true\n },\n {\n "idx": 10,\n "version": "6",\n "when": 1784827767707,\n "tag": "0010_sleepy_captain_universe",\n "breakpoints": true\n },\n {\n "idx": 11,\n "version": "6",\n "when": 1784837254256,\n "tag": "0011_third_hannibal_king",\n "breakpoints": true\n },\n {\n "idx": 12,\n "version": "6",\n "when": 1784918687054,\n "tag": "0012_dizzy_madrox",\n "breakpoints": true\n },\n {\n "idx": 13,\n "version": "6",\n "when": 1785267461030,\n "tag": "0013_unique_micromax",\n "breakpoints": true\n },\n {\n "idx": 14,\n "version": "6",\n "when": 1785355200000,\n "tag": "0014_forward_correction_study_preview",\n "breakpoints": true\n },\n {\n "idx": 15,\n "version": "6",\n "when": 1785358800000,\n "tag": "0015_e0_signal_study_drafts",\n "breakpoints": true\n },\n {\n "idx": 16,\n "version": "6",\n "when": 1785362400000,\n "tag": "0016_regular_doctor_strange",\n "breakpoints": true\n },\n {\n "idx": 17,\n "version": "6",\n "when": 1785366000000,\n "tag": "0017_promoted_case_retirements",\n "breakpoints": true\n },\n {\n "idx": 18,\n "version": "6",\n "when": 1786088000000,\n "tag": "0018_drop_legacy_evaluation_draft_identity",\n "breakpoints": true\n }\n ]\n}\n', "0000_local_runtime_baseline.sql": "CREATE TABLE `_meta` (\n\t`key` text PRIMARY KEY NOT NULL,\n\t`value` text\n);\n--> statement-breakpoint\nCREATE TABLE `canonical_eval_sets` (\n\t`skill_name` text PRIMARY KEY NOT NULL,\n\t`stored_at` text NOT NULL,\n\t`eval_set_json` text NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `idx_canonical_eval_sets_stored_at` ON `canonical_eval_sets` (`stored_at`);--> statement-breakpoint\nCREATE TABLE `canonical_upload_staging` (\n\t`local_seq` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`record_kind` text NOT NULL,\n\t`record_id` text NOT NULL,\n\t`record_json` text NOT NULL,\n\t`session_id` text,\n\t`prompt_id` text,\n\t`normalized_at` text,\n\t`staged_at` text NOT NULL,\n\t`content_sha256` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_staging_sha256` ON `canonical_upload_staging` (`content_sha256`);--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_staging_dedup` ON `canonical_upload_staging` (`record_kind`,`record_id`);--> statement-breakpoint\nCREATE INDEX `idx_staging_session` ON `canonical_upload_staging` (`session_id`);--> statement-breakpoint\nCREATE INDEX `idx_staging_kind` ON `canonical_upload_staging` (`record_kind`);--> statement-breakpoint\nCREATE TABLE `commit_tracking` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`session_id` text NOT NULL,\n\t`commit_sha` text NOT NULL,\n\t`commit_title` text,\n\t`branch` text,\n\t`repo_remote` text,\n\t`timestamp` text NOT NULL,\n\t`created_at` text DEFAULT (datetime('now')) NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_commit_dedup` ON `commit_tracking` (`session_id`,`commit_sha`);--> statement-breakpoint\nCREATE INDEX `idx_commit_ts` ON `commit_tracking` (`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_commit_session` ON `commit_tracking` (`session_id`);--> statement-breakpoint\nCREATE INDEX `idx_commit_sha` ON `commit_tracking` (`commit_sha`);--> statement-breakpoint\nCREATE TABLE `creator_contribution_staging` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`dedupe_key` text NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`creator_id` text NOT NULL,\n\t`payload_json` text NOT NULL,\n\t`status` text DEFAULT 'pending' NOT NULL,\n\t`staged_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\t`last_error` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_creator_contrib_dedup` ON `creator_contribution_staging` (`dedupe_key`);--> statement-breakpoint\nCREATE INDEX `idx_creator_contrib_skill` ON `creator_contribution_staging` (`skill_name`);--> statement-breakpoint\nCREATE INDEX `idx_creator_contrib_status` ON `creator_contribution_staging` (`status`);--> statement-breakpoint\nCREATE TABLE `cron_runs` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`job_name` text NOT NULL,\n\t`started_at` text NOT NULL,\n\t`elapsed_ms` integer NOT NULL,\n\t`status` text NOT NULL,\n\t`metrics_json` text,\n\t`error` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `cron_runs_job_started_unique` ON `cron_runs` (`job_name`,`started_at`);--> statement-breakpoint\nCREATE INDEX `idx_cron_runs_job_ts` ON `cron_runs` (`job_name`,`started_at`);--> statement-breakpoint\nCREATE TABLE `evolution_audit` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`proposal_id` text NOT NULL,\n\t`skill_name` text,\n\t`action` text NOT NULL,\n\t`details` text,\n\t`eval_snapshot_json` text,\n\t`iterations_used` integer,\n\t`validation_mode` text,\n\t`validation_agent` text,\n\t`validation_fixture_id` text,\n\t`validation_evidence_ref` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_evo_audit_dedup` ON `evolution_audit` (`proposal_id`,`action`,`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_evo_audit_action` ON `evolution_audit` (`action`);--> statement-breakpoint\nCREATE INDEX `idx_evo_audit_ts` ON `evolution_audit` (`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_evo_audit_proposal` ON `evolution_audit` (`proposal_id`);--> statement-breakpoint\nCREATE TABLE `evolution_evidence` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`proposal_id` text NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`skill_path` text,\n\t`target` text,\n\t`stage` text,\n\t`rationale` text,\n\t`confidence` real,\n\t`details` text,\n\t`original_text` text,\n\t`proposed_text` text,\n\t`eval_set_json` text,\n\t`validation_json` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_evo_evidence_dedup` ON `evolution_evidence` (`proposal_id`,`stage`,`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_evo_evidence_ts` ON `evolution_evidence` (`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_evo_evidence_skill` ON `evolution_evidence` (`skill_name`);--> statement-breakpoint\nCREATE INDEX `idx_evo_evidence_proposal` ON `evolution_evidence` (`proposal_id`);--> statement-breakpoint\nCREATE TABLE `execution_facts` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`session_id` text NOT NULL,\n\t`occurred_at` text,\n\t`prompt_id` text,\n\t`tool_calls_json` text,\n\t`total_tool_calls` integer,\n\t`assistant_turns` integer,\n\t`errors_encountered` integer,\n\t`input_tokens` integer,\n\t`output_tokens` integer,\n\t`duration_ms` integer,\n\t`completion_status` text,\n\t`schema_version` text,\n\t`platform` text,\n\t`normalized_at` text,\n\t`normalizer_version` text,\n\t`capture_mode` text,\n\t`raw_source_ref` text,\n\t`files_changed` integer,\n\t`lines_added` integer,\n\t`lines_removed` integer,\n\t`lines_modified` integer,\n\t`cached_input_tokens` integer,\n\t`reasoning_output_tokens` integer,\n\t`cost_usd` real,\n\t`artifact_count` integer,\n\t`session_type` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`session_id`) ON UPDATE no action ON DELETE no action\n);\n--> statement-breakpoint\nCREATE INDEX `idx_exec_facts_session` ON `execution_facts` (`session_id`);--> statement-breakpoint\nCREATE TABLE `grading_baselines` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`proposal_id` text,\n\t`measured_at` text NOT NULL,\n\t`pass_rate` real NOT NULL,\n\t`mean_score` real,\n\t`sample_size` integer NOT NULL,\n\t`grading_results_json` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_grading_bl_skill_proposal` ON `grading_baselines` (`skill_name`,`proposal_id`,`measured_at`);--> statement-breakpoint\nCREATE INDEX `idx_grading_bl_ts` ON `grading_baselines` (`measured_at`);--> statement-breakpoint\nCREATE INDEX `idx_grading_bl_proposal` ON `grading_baselines` (`proposal_id`);--> statement-breakpoint\nCREATE INDEX `idx_grading_bl_skill` ON `grading_baselines` (`skill_name`);--> statement-breakpoint\nCREATE TABLE `grading_results` (\n\t`grading_id` text PRIMARY KEY NOT NULL,\n\t`session_id` text NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`transcript_path` text,\n\t`graded_at` text NOT NULL,\n\t`pass_rate` real,\n\t`mean_score` real,\n\t`score_std_dev` real,\n\t`passed_count` integer,\n\t`failed_count` integer,\n\t`total_count` integer,\n\t`expectations_json` text,\n\t`claims_json` text,\n\t`eval_feedback_json` text,\n\t`failure_feedback_json` text,\n\t`execution_metrics_json` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_grading_dedup` ON `grading_results` (`session_id`,`skill_name`,`graded_at`);--> statement-breakpoint\nCREATE INDEX `idx_grading_ts` ON `grading_results` (`graded_at`);--> statement-breakpoint\nCREATE INDEX `idx_grading_skill` ON `grading_results` (`skill_name`);--> statement-breakpoint\nCREATE INDEX `idx_grading_session` ON `grading_results` (`session_id`);--> statement-breakpoint\nCREATE TABLE `improvement_signals` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`session_id` text NOT NULL,\n\t`query` text NOT NULL,\n\t`signal_type` text NOT NULL,\n\t`mentioned_skill` text,\n\t`consumed` integer DEFAULT 0 NOT NULL,\n\t`consumed_at` text,\n\t`consumed_by_run` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_signals_dedup` ON `improvement_signals` (`session_id`,`query`,`signal_type`,`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_signals_ts` ON `improvement_signals` (`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_signals_consumed` ON `improvement_signals` (`consumed`);--> statement-breakpoint\nCREATE INDEX `idx_signals_session` ON `improvement_signals` (`session_id`);--> statement-breakpoint\nCREATE TABLE `orchestrate_runs` (\n\t`run_id` text PRIMARY KEY NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`elapsed_ms` integer NOT NULL,\n\t`dry_run` integer NOT NULL,\n\t`approval_mode` text NOT NULL,\n\t`total_skills` integer NOT NULL,\n\t`evaluated` integer NOT NULL,\n\t`evolved` integer NOT NULL,\n\t`deployed` integer NOT NULL,\n\t`watched` integer NOT NULL,\n\t`skipped` integer NOT NULL,\n\t`skill_actions_json` text NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `idx_orchestrate_runs_ts` ON `orchestrate_runs` (`timestamp`);--> statement-breakpoint\nCREATE TABLE `package_candidates` (\n\t`candidate_id` text PRIMARY KEY NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`skill_path` text NOT NULL,\n\t`package_fingerprint` text NOT NULL,\n\t`parent_candidate_id` text,\n\t`candidate_generation` integer DEFAULT 0 NOT NULL,\n\t`evaluation_count` integer DEFAULT 0 NOT NULL,\n\t`first_evaluated_at` text NOT NULL,\n\t`last_evaluated_at` text NOT NULL,\n\t`latest_status` text NOT NULL,\n\t`latest_evaluation_source` text,\n\t`latest_acceptance_decision` text,\n\t`artifact_path` text,\n\t`summary_json` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `package_candidates_skill_fingerprint_unique` ON `package_candidates` (`skill_name`,`package_fingerprint`);--> statement-breakpoint\nCREATE INDEX `idx_package_candidates_parent` ON `package_candidates` (`parent_candidate_id`);--> statement-breakpoint\nCREATE INDEX `idx_package_candidates_skill_ts` ON `package_candidates` (`skill_name`,`last_evaluated_at`);--> statement-breakpoint\nCREATE TABLE `package_evaluation_reports` (\n\t`skill_name` text PRIMARY KEY NOT NULL,\n\t`stored_at` text NOT NULL,\n\t`summary_json` text NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `idx_package_evaluation_reports_stored_at` ON `package_evaluation_reports` (`stored_at`);--> statement-breakpoint\nCREATE TABLE `package_search_runs` (\n\t`search_id` text PRIMARY KEY NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`parent_candidate_id` text,\n\t`winner_candidate_id` text,\n\t`winner_rationale` text,\n\t`candidates_evaluated` integer NOT NULL,\n\t`provenance_json` text NOT NULL,\n\t`started_at` text NOT NULL,\n\t`completed_at` text NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `idx_pkg_search_ts` ON `package_search_runs` (`started_at`);--> statement-breakpoint\nCREATE INDEX `idx_pkg_search_skill` ON `package_search_runs` (`skill_name`);--> statement-breakpoint\nCREATE TABLE `prompts` (\n\t`prompt_id` text PRIMARY KEY NOT NULL,\n\t`session_id` text NOT NULL,\n\t`occurred_at` text,\n\t`prompt_kind` text,\n\t`is_actionable` integer,\n\t`prompt_index` integer,\n\t`prompt_text` text,\n\t`schema_version` text,\n\t`platform` text,\n\t`normalized_at` text,\n\t`normalizer_version` text,\n\t`capture_mode` text,\n\t`raw_source_ref` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`session_id`) ON UPDATE no action ON DELETE no action\n);\n--> statement-breakpoint\nCREATE INDEX `idx_prompts_occurred` ON `prompts` (`occurred_at`);--> statement-breakpoint\nCREATE INDEX `idx_prompts_session` ON `prompts` (`session_id`);--> statement-breakpoint\nCREATE TABLE `queries` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`session_id` text NOT NULL,\n\t`query` text NOT NULL,\n\t`source` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_queries_dedup` ON `queries` (`session_id`,`query`,`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_queries_ts` ON `queries` (`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_queries_session` ON `queries` (`session_id`);--> statement-breakpoint\nCREATE TABLE `replay_entry_results` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`proposal_id` text NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`validation_mode` text NOT NULL,\n\t`phase` text NOT NULL,\n\t`query` text NOT NULL,\n\t`should_trigger` integer NOT NULL,\n\t`triggered` integer NOT NULL,\n\t`passed` integer NOT NULL,\n\t`evidence` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_replay_entry_proposal_phase` ON `replay_entry_results` (`proposal_id`,`phase`);--> statement-breakpoint\nCREATE INDEX `idx_replay_entry_passed` ON `replay_entry_results` (`passed`);--> statement-breakpoint\nCREATE INDEX `idx_replay_entry_skill` ON `replay_entry_results` (`skill_name`);--> statement-breakpoint\nCREATE INDEX `idx_replay_entry_proposal` ON `replay_entry_results` (`proposal_id`);--> statement-breakpoint\nCREATE TABLE `session_telemetry` (\n\t`session_id` text PRIMARY KEY NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`cwd` text,\n\t`transcript_path` text,\n\t`tool_calls_json` text,\n\t`total_tool_calls` integer,\n\t`bash_commands_json` text,\n\t`skills_triggered_json` text,\n\t`skills_invoked_json` text,\n\t`assistant_turns` integer,\n\t`errors_encountered` integer,\n\t`transcript_chars` integer,\n\t`last_user_query` text,\n\t`source` text,\n\t`input_tokens` integer,\n\t`output_tokens` integer,\n\t`files_changed` integer,\n\t`lines_added` integer,\n\t`lines_removed` integer,\n\t`lines_modified` integer,\n\t`cached_input_tokens` integer,\n\t`reasoning_output_tokens` integer,\n\t`cost_usd` real,\n\t`artifact_count` integer,\n\t`session_type` text,\n\t`agent_summary` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_session_tel_ts` ON `session_telemetry` (`timestamp`);--> statement-breakpoint\nCREATE TABLE `sessions` (\n\t`session_id` text PRIMARY KEY NOT NULL,\n\t`started_at` text,\n\t`ended_at` text,\n\t`platform` text,\n\t`model` text,\n\t`completion_status` text,\n\t`source_session_kind` text,\n\t`agent_cli` text,\n\t`workspace_path` text,\n\t`repo_remote` text,\n\t`branch` text,\n\t`schema_version` text,\n\t`normalized_at` text,\n\t`normalizer_version` text,\n\t`capture_mode` text,\n\t`raw_source_ref` text\n);\n--> statement-breakpoint\nCREATE TABLE `skill_invocations` (\n\t`skill_invocation_id` text PRIMARY KEY NOT NULL,\n\t`session_id` text NOT NULL,\n\t`occurred_at` text,\n\t`skill_name` text NOT NULL,\n\t`invocation_mode` text,\n\t`triggered` integer,\n\t`confidence` real,\n\t`tool_name` text,\n\t`matched_prompt_id` text,\n\t`agent_type` text,\n\t`query` text,\n\t`skill_path` text,\n\t`skill_version_hash` text,\n\t`skill_scope` text,\n\t`source` text,\n\t`schema_version` text,\n\t`platform` text,\n\t`normalized_at` text,\n\t`normalizer_version` text,\n\t`capture_mode` text,\n\t`raw_source_ref` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`session_id`) ON UPDATE no action ON DELETE no action\n);\n--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_dedup` ON `skill_invocations` (`session_id`,`skill_name`,`query`,`occurred_at`,`triggered`);--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_version` ON `skill_invocations` (`skill_name`,`skill_version_hash`,`occurred_at`);--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_scope` ON `skill_invocations` (`skill_name`,`skill_scope`,`occurred_at`);--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_query_triggered` ON `skill_invocations` (`query`,`triggered`);--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_ts` ON `skill_invocations` (`occurred_at`);--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_name` ON `skill_invocations` (`skill_name`);--> statement-breakpoint\nCREATE INDEX `idx_skill_inv_session` ON `skill_invocations` (`session_id`);--> statement-breakpoint\nCREATE TABLE `skill_usage` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`timestamp` text NOT NULL,\n\t`session_id` text NOT NULL,\n\t`skill_name` text NOT NULL,\n\t`skill_path` text,\n\t`skill_scope` text,\n\t`query` text,\n\t`triggered` integer,\n\t`source` text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_skill_usage_dedup` ON `skill_usage` (`session_id`,`skill_name`,`query`,`timestamp`,`triggered`);--> statement-breakpoint\nCREATE INDEX `idx_skill_usage_scope` ON `skill_usage` (`skill_name`,`skill_scope`,`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_skill_usage_query_triggered` ON `skill_usage` (`query`,`triggered`);--> statement-breakpoint\nCREATE INDEX `idx_skill_usage_ts` ON `skill_usage` (`timestamp`);--> statement-breakpoint\nCREATE INDEX `idx_skill_usage_name` ON `skill_usage` (`skill_name`);--> statement-breakpoint\nCREATE INDEX `idx_skill_usage_session` ON `skill_usage` (`session_id`);--> statement-breakpoint\nCREATE TABLE `unit_test_files` (\n\t`skill_name` text PRIMARY KEY NOT NULL,\n\t`stored_at` text NOT NULL,\n\t`tests_json` text NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `idx_unit_test_files_stored_at` ON `unit_test_files` (`stored_at`);--> statement-breakpoint\nCREATE TABLE `unit_test_run_results` (\n\t`skill_name` text PRIMARY KEY NOT NULL,\n\t`run_at` text NOT NULL,\n\t`total` integer NOT NULL,\n\t`passed` integer NOT NULL,\n\t`failed` integer NOT NULL,\n\t`pass_rate` real NOT NULL,\n\t`result_json` text NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `idx_unit_test_run_results_run_at` ON `unit_test_run_results` (`run_at`);--> statement-breakpoint\nCREATE TABLE `upload_queue` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`payload_type` text NOT NULL,\n\t`payload_json` text NOT NULL,\n\t`status` text DEFAULT 'pending' NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\t`last_error` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_upload_queue_type_status` ON `upload_queue` (`payload_type`,`status`);--> statement-breakpoint\nCREATE INDEX `idx_upload_queue_status` ON `upload_queue` (`status`);--> statement-breakpoint\nCREATE TABLE `upload_watermarks` (\n\t`payload_type` text PRIMARY KEY NOT NULL,\n\t`last_uploaded_id` integer NOT NULL,\n\t`updated_at` text NOT NULL\n);\n", "0001_fast_zombie.sql": @@ -38,4 +38,6 @@ export default { "CREATE TABLE `correction_learning_policies` (\n\t`workspace_id` text PRIMARY KEY NOT NULL,\n\t`capture_enabled` text NOT NULL,\n\t`proactive_generation_enabled` text NOT NULL,\n\t`managed_execution_enabled` text NOT NULL,\n\t`kill_switch_enabled` text NOT NULL,\n\t`workspace_budget` text NOT NULL,\n\t`max_concurrency` text NOT NULL,\n\t`retention_e0_days` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE `correction_raw_source_material` (\n\t`source_id` text PRIMARY KEY NOT NULL,\n\t`candidate_id` text NOT NULL,\n\t`evidence_level` text NOT NULL,\n\t`payload_json` text NOT NULL,\n\t`expires_at` text NOT NULL,\n\t`created_at` text NOT NULL,\n\tFOREIGN KEY (`candidate_id`) REFERENCES `correction_signal_candidates`(`candidate_id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE INDEX `idx_correction_raw_source_material_expiry` ON `correction_raw_source_material` (`evidence_level`,`expires_at`);--> statement-breakpoint\nCREATE INDEX `idx_correction_raw_source_material_candidate` ON `correction_raw_source_material` (`candidate_id`);--> statement-breakpoint\nCREATE TABLE `correction_review_decisions` (\n\t`decision_id` text PRIMARY KEY NOT NULL,\n\t`candidate_id` text NOT NULL,\n\t`replacement_candidate_id` text,\n\t`action` text NOT NULL,\n\t`actor` text NOT NULL,\n\t`reason` text NOT NULL,\n\t`manifest_digest` text NOT NULL,\n\t`decided_at` text NOT NULL,\n\tFOREIGN KEY (`candidate_id`) REFERENCES `correction_signal_candidates`(`candidate_id`) ON UPDATE no action ON DELETE restrict,\n\tFOREIGN KEY (`replacement_candidate_id`) REFERENCES `correction_signal_candidates`(`candidate_id`) ON UPDATE no action ON DELETE restrict\n);\n--> statement-breakpoint\nCREATE INDEX `idx_correction_review_decisions_candidate` ON `correction_review_decisions` (`candidate_id`,`decided_at`);--> statement-breakpoint\nCREATE INDEX `idx_correction_review_decisions_replacement` ON `correction_review_decisions` (`replacement_candidate_id`);\n", "0017_promoted_case_retirements.sql": "CREATE TABLE `promoted_study_case_retirements` (\n\t`retirement_id` text PRIMARY KEY NOT NULL,\n\t`case_id` text NOT NULL,\n\t`actor` text NOT NULL,\n\t`reason` text NOT NULL,\n\t`prior_manifest_digest` text NOT NULL,\n\t`retired_at` text NOT NULL,\n\tFOREIGN KEY (`case_id`) REFERENCES `promoted_study_cases`(`case_id`) ON UPDATE no action ON DELETE restrict\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `promoted_study_case_retirements_case_unique` ON `promoted_study_case_retirements` (`case_id`);--> statement-breakpoint\nCREATE INDEX `idx_promoted_study_case_retirements_case` ON `promoted_study_case_retirements` (`case_id`,`retired_at`);\n--> statement-breakpoint\nCREATE TABLE `correction_candidate_evaluations` (\n\t`evaluation_id` text PRIMARY KEY NOT NULL, `candidate_id` text NOT NULL, `current_revision` text NOT NULL, `candidate_revision` text NOT NULL,\n\t`evidence_level` text NOT NULL, `status` text NOT NULL, `reason` text, `blind_manifest_json` text NOT NULL, `blind_result_json` text NOT NULL,\n\t`verifier_provenance` text NOT NULL, `runtime_provenance` text NOT NULL, `cost_estimate` text, `cost_actual` text, `applies_change` text NOT NULL, `recorded_at` text NOT NULL,\n\tFOREIGN KEY (`candidate_id`) REFERENCES `correction_signal_candidates`(`candidate_id`) ON UPDATE no action ON DELETE restrict\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `correction_candidate_evaluations_candidate_unique` ON `correction_candidate_evaluations` (`evaluation_id`,`candidate_id`);--> statement-breakpoint\nCREATE INDEX `idx_correction_candidate_evaluations_candidate` ON `correction_candidate_evaluations` (`candidate_id`,`recorded_at`);\n", + "0018_drop_legacy_evaluation_draft_identity.sql": + "DROP INDEX IF EXISTS `evaluation_submission_drafts_identity_unique`;\n", } as const; diff --git a/packages/observability/package.json b/packages/observability/package.json index 9bd890f6..cca2f95a 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -28,7 +28,11 @@ }, "devDependencies": { "@types/bun": "^1.3.11", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", "bun-types": "^1.3.11", + "react": "^19.1.0", + "react-dom": "^19.1.0", "typescript": "^5.9.3" } } diff --git a/packages/observability/src/duckdb-node-api.ts b/packages/observability/src/duckdb-node-api.ts index 72bcde41..64c3cf81 100644 --- a/packages/observability/src/duckdb-node-api.ts +++ b/packages/observability/src/duckdb-node-api.ts @@ -1,4 +1,7 @@ -import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { copyFile, lstat, mkdir, rename } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { @@ -26,6 +29,125 @@ const { DuckDBInstance, DuckDBTimestampValue } = duckDbModule as typeof import(" /** Bounded desktop analytical-store memory; DuckDB never receives an unlimited process budget. */ export const DUCKDB_LOCAL_MEMORY_LIMIT = "512MB"; +interface DuckDbWalRecoveryFileStat { + readonly isFile: () => boolean; + readonly isSymbolicLink: () => boolean; +} + +export interface DuckDbWalRecoveryDependencies { + readonly copyFile: (source: string, destination: string, mode?: number) => Promise; + readonly lstat: (path: string) => Promise; + readonly mkdir: ( + path: string, + options: { readonly mode: number; readonly recursive: boolean }, + ) => Promise; + readonly now: () => Date; + readonly open: (databasePath: string) => Promise; + readonly randomUuid: () => string; + readonly rename: (source: string, destination: string) => Promise; + readonly warn: (message: string) => void; +} + +const WAL_REPLAY_FAILURE_PREFIX = "Failure while replaying WAL file"; +const UNBOUND_INDEX_REPLAY_FAILURE = + "Failed to commit: Unbound index found in DataTable::RemoveFromIndexes"; +const CORRUPTED_UNIQUE_ART_REPLAY_FAILURE = "Corrupted unique ART index"; +const EXISTING_GATED_LEAF_REPLAY_FAILURE = "encountered an existing gated leaf"; + +function isKnownWalReplayIndexFailure(cause: unknown): boolean { + const message = cause instanceof Error ? cause.message : String(cause); + return ( + message.includes(WAL_REPLAY_FAILURE_PREFIX) && + (message.includes(UNBOUND_INDEX_REPLAY_FAILURE) || + (message.includes(CORRUPTED_UNIQUE_ART_REPLAY_FAILURE) && + message.includes(EXISTING_GATED_LEAF_REPLAY_FAILURE))) + ); +} + +function hasErrorCode(cause: unknown, code: string): boolean { + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === code; +} + +function assertRegularNonSymlinkFile(path: string, stat: DuckDbWalRecoveryFileStat): void { + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Refusing automatic DuckDB WAL recovery because "${path}" is not a regular non-symlink file.`, + ); + } +} + +function backupStamp(now: Date): string { + return now.toISOString().replaceAll("-", "").replaceAll(":", "").replaceAll(".", ""); +} + +/** + * Recovers only DuckDB's known WAL-replay/index failure. The checkpoint remains + * live, while an exclusive backup retains both the checkpoint snapshot and the + * unreplayable WAL for future repair. Every other open or filesystem failure is + * returned unchanged instead of widening recovery into a destructive reset. + */ +export async function openDuckDbWithWalRecovery( + databasePath: string, + dependencies: DuckDbWalRecoveryDependencies, +): Promise { + try { + return await dependencies.open(databasePath); + } catch (cause) { + if (!isKnownWalReplayIndexFailure(cause)) throw cause; + } + + const walPath = `${databasePath}.wal`; + const databaseStat = await dependencies.lstat(databasePath); + assertRegularNonSymlinkFile(databasePath, databaseStat); + + let walStat: DuckDbWalRecoveryFileStat; + try { + walStat = await dependencies.lstat(walPath); + } catch (cause) { + if (hasErrorCode(cause, "ENOENT")) return dependencies.open(databasePath); + throw cause; + } + assertRegularNonSymlinkFile(walPath, walStat); + + const backupRoot = join(dirname(databasePath), "backups"); + await dependencies.mkdir(backupRoot, { recursive: true, mode: 0o700 }); + const backupDirectory = join( + backupRoot, + `duckdb-wal-recovery-${backupStamp(dependencies.now())}-${dependencies.randomUuid()}`, + ); + await dependencies.mkdir(backupDirectory, { recursive: false, mode: 0o700 }); + await dependencies.copyFile( + databasePath, + join(backupDirectory, basename(databasePath)), + constants.COPYFILE_EXCL, + ); + + try { + await dependencies.rename(walPath, join(backupDirectory, basename(walPath))); + } catch (cause) { + if (hasErrorCode(cause, "ENOENT")) return dependencies.open(databasePath); + throw cause; + } + + dependencies.warn( + `[selftune] DuckDB quarantined an unreplayable observability WAL. Recovery backup: ${backupDirectory}`, + ); + return dependencies.open(databasePath); +} + +const liveWalRecoveryDependencies = ( + open: (databasePath: string) => Promise, +): DuckDbWalRecoveryDependencies => ({ + copyFile, + lstat, + mkdir, + now: () => new Date(), + open, + randomUuid: randomUUID, + rename, + warn: (message) => process.stderr.write(`${message}\n`), +}); + function appendScalar( appender: import("@duckdb/node-api").DuckDBAppender, value: DuckDbAppendValue, @@ -52,11 +174,16 @@ function appendScalar( */ const duckDbNodeApiFactory: DuckDbInstanceFactory = { open: async (databasePath) => { - const instance = await DuckDBInstance.create(databasePath, { - memory_limit: DUCKDB_LOCAL_MEMORY_LIMIT, - preserve_insertion_order: "false", - threads: "2", - }); + const instance = await openDuckDbWithWalRecovery( + databasePath, + liveWalRecoveryDependencies((path) => + DuckDBInstance.create(path, { + memory_limit: DUCKDB_LOCAL_MEMORY_LIMIT, + preserve_insertion_order: "false", + threads: "2", + }), + ), + ); return { closeSync: () => instance.closeSync(), connect: async () => { diff --git a/packages/observability/src/duckdb-store.ts b/packages/observability/src/duckdb-store.ts index dff654c8..e89e8693 100644 --- a/packages/observability/src/duckdb-store.ts +++ b/packages/observability/src/duckdb-store.ts @@ -292,6 +292,33 @@ export class DuckDbEvidenceCohortQuery extends Schema.Class( + "DuckDbHistoricalSkillTaskReference", +)({ + trace_id: TraceId, + span_id: SpanId, + skill_invocation_id: BoundedText, + source_id: BoundedText, + source_revision: BoundedText, + trace_boundary: TraceBoundary, + capture_mode: CaptureMode, + source_authority: SourceAuthority, + evidence_quality: Schema.optionalKey(EvidenceQuality), + model: Schema.optionalKey(BoundedText), +}) {} + +export class DuckDbHistoricalSkillTaskQuery extends Schema.Class( + "DuckDbHistoricalSkillTaskQuery", +)({ + skill_id: BoundedText, + limit: Schema.Number.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(512), + ), +}) {} + export class DuckDbAnalyticalStoreHealth extends Schema.Class( "DuckDbAnalyticalStoreHealth", )({ @@ -366,6 +393,12 @@ export interface DuckDbAnalyticalStoreService { readonly queryEvidenceCohortCandidates: ( input: unknown, ) => Effect.Effect, DuckDbAnalyticalStoreFailure>; + readonly queryHistoricalSkillTaskReferences: ( + input: unknown, + ) => Effect.Effect< + ReadonlyArray, + DuckDbAnalyticalStoreFailure + >; readonly queryHistoricalMetricRollups: ( input: unknown, ) => Effect.Effect; @@ -1338,6 +1371,12 @@ const ingestBatch = Effect.fn("DuckDbAnalyticalStore.ingest")(function* ( ]), ); yield* runStatement(connection, "COMMIT"); + if (disposition === "revised") { + // Revised batches delete and reinsert the same indexed identities. Persist that uncommon + // replacement transaction immediately so a process crash cannot strand DuckDB's ART index + // delete/reinsert sequence in the WAL; affected 1.4.x and 1.5.x builds cannot replay it. + yield* runStatement(connection, "CHECKPOINT"); + } return DuckDbAnalyticalIngestReceipt.make({ batch_id: batch.batch_id, disposition: "accepted", @@ -1483,6 +1522,7 @@ const queryEvidenceCohortCandidates = Effect.fn( LEFT JOIN metric_rollup AS metric ON metric.trace_id = span.trace_id AND metric.span_id = span.span_id WHERE LOWER(TRIM(link.skill_name)) = $skill_id + AND span.trace_boundary IN ('actionable_turn', 'autonomous_task') ORDER BY span.trace_id, span.span_id, link.skill_invocation_id`, { skill_id: query.pattern.skill_id.trim().toLowerCase() }, ); @@ -1500,6 +1540,66 @@ const queryEvidenceCohortCandidates = Effect.fn( ); }); +const queryHistoricalSkillTaskReferences = Effect.fn( + "DuckDbAnalyticalStore.queryHistoricalSkillTaskReferences", +)(function* (connection: DuckDbConnection, input: unknown) { + const query = yield* Schema.decodeUnknownEffect(DuckDbHistoricalSkillTaskQuery)(input).pipe( + Effect.catchTag("SchemaError", (error) => + Effect.fail( + DuckDbAnalyticalStoreFailure.make({ + operation: "decode historical skill task query", + message: error.message, + }), + ), + ), + ); + const result = yield* runStatement( + connection, + `SELECT DISTINCT + span.trace_id, + span.span_id, + link.skill_invocation_id, + span.source_id, + batch.source_revision, + span.trace_boundary, + span.capture_mode, + span.source_authority, + span.evidence_quality, + span.model + FROM observability_trace_skill_links AS link + INNER JOIN observability_spans AS span + ON span.trace_id = link.trace_id AND span.span_id = link.span_id + INNER JOIN observability_ingested_batches AS batch ON batch.batch_id = span.batch_id + WHERE LOWER(TRIM(link.skill_name)) = $skill_id + ORDER BY span.trace_id, span.span_id, link.skill_invocation_id + LIMIT $limit`, + { skill_id: query.skill_id.trim().toLowerCase(), limit: query.limit }, + ); + const rows = (yield* readRows(result, "read DuckDB historical skill task references")).map( + (row) => { + const normalized = { ...row }; + if (normalized.evidence_quality === null) delete normalized.evidence_quality; + if (normalized.model === null) delete normalized.model; + return normalized; + }, + ); + return yield* Schema.decodeUnknownEffect(Schema.Array(DuckDbHistoricalSkillTaskReference))( + rows, + ).pipe( + Effect.map((decoded) => + decoded.map((reference) => DuckDbHistoricalSkillTaskReference.make(reference)), + ), + Effect.catchTag("SchemaError", (error) => + Effect.fail( + DuckDbAnalyticalStoreFailure.make({ + operation: "decode DuckDB historical skill task references", + message: error.message, + }), + ), + ), + ); +}); + /** Latest source snapshot per trace/metric. Cumulative points are never summed. */ const queryHistoricalMetricRollups = Effect.fn( "DuckDbAnalyticalStore.queryHistoricalMetricRollups", @@ -1680,6 +1780,8 @@ export function makeDuckDbAnalyticalStoreLive( querySkillSignals: () => semaphore.withPermit(querySkillSignals(connection)), queryEvidenceCohortCandidates: (input) => semaphore.withPermit(queryEvidenceCohortCandidates(connection, input)), + queryHistoricalSkillTaskReferences: (input) => + semaphore.withPermit(queryHistoricalSkillTaskReferences(connection, input)), queryHistoricalMetricRollups: (input) => semaphore.withPermit(queryHistoricalMetricRollups(connection, input)), health: () => semaphore.withPermit(health(connection, databasePath)), diff --git a/packages/orchestration/CHANGELOG.md b/packages/orchestration/CHANGELOG.md deleted file mode 100644 index 416674e2..00000000 --- a/packages/orchestration/CHANGELOG.md +++ /dev/null @@ -1,11 +0,0 @@ -# @selftune/orchestration - -## 1.0.1 - -### Patch Changes - -- Updated dependencies []: - - @selftune/runtime@1.0.1 - - @selftune/harness-claude-code@1.0.1 - - @selftune/harness-codex@1.0.1 - - @selftune/harness-registry@1.0.1 diff --git a/packages/orchestration/package.json b/packages/orchestration/package.json index cabf63ee..6a0f2628 100644 --- a/packages/orchestration/package.json +++ b/packages/orchestration/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/orchestration", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "SelfTune multi-step improvement and ingestion workflows", "license": "MIT", diff --git a/packages/orchestration/src/improve.ts b/packages/orchestration/src/improve.ts index fbf3bea1..d40153aa 100644 --- a/packages/orchestration/src/improve.ts +++ b/packages/orchestration/src/improve.ts @@ -12,6 +12,11 @@ export interface ImproveDeps { evolveCliMain?: () => Promise; evolveBodyCliMain?: () => Promise; searchRunCliMain?: () => Promise; + historicalImprove?: (input: { + readonly skill: string; + readonly skillPath: string; + readonly agent?: string; + }) => Promise<{ readonly handled: boolean; readonly result?: unknown }>; } function readOptionValue(args: readonly string[], flag: string): string | undefined { @@ -103,7 +108,7 @@ export async function runImprove( rawArgs: readonly string[], deps: ImproveDeps = {}, ): Promise { - const { evolveCliMain, evolveBodyCliMain, searchRunCliMain } = deps; + const { evolveCliMain, evolveBodyCliMain, searchRunCliMain, historicalImprove } = deps; if (rawArgs.includes("--help") || rawArgs.includes("-h")) { console.log(renderCommandHelp(PUBLIC_COMMAND_SURFACES.improve)); @@ -132,6 +137,22 @@ export async function runImprove( ); } + if (effectiveScope === "auto" && hasOption(delegatedArgs, "--dry-run") && historicalImprove) { + const skill = readOptionValue(delegatedArgs, "--skill"); + const skillPath = readOptionValue(delegatedArgs, "--skill-path"); + if (skill && skillPath) { + const historical = await historicalImprove({ + skill, + skillPath, + agent: readOptionValue(delegatedArgs, "--agent"), + }); + if (historical.handled) { + console.log(JSON.stringify(historical.result, null, 2)); + return; + } + } + } + if (effectiveScope === "package") { const dryRunRequested = hasOption(delegatedArgs, "--dry-run"); const validationMode = readOptionValue(delegatedArgs, "--validation-mode"); diff --git a/packages/orchestration/vitest.config.ts b/packages/orchestration/vitest.config.ts new file mode 100644 index 00000000..28e3aea5 --- /dev/null +++ b/packages/orchestration/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md deleted file mode 100644 index 932ea16b..00000000 --- a/packages/runtime/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# @selftune/runtime - -## 1.0.1 - -### Patch Changes - -- Updated dependencies [[`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262)]: - - @selftune/dashboard-core@1.1.0 diff --git a/packages/runtime/auth/device-code.ts b/packages/runtime/auth/device-code.ts index 69947444..6eb1014f 100644 --- a/packages/runtime/auth/device-code.ts +++ b/packages/runtime/auth/device-code.ts @@ -23,7 +23,7 @@ export interface DeviceCodeResult { org_id: string; } -export const DEFAULT_CLOUD_API_URL = "https://api.selftune.dev"; +export const DEFAULT_CLOUD_API_URL = "https://cloud.selftune.dev"; export function tryOpenUrl(url: string): boolean { const command = @@ -59,7 +59,7 @@ export function buildVerificationUrl(verificationUrl: string, userCode: string): /** * Derive the cloud API base URL from SELFTUNE_ALPHA_ENDPOINT. - * The endpoint is the push URL (e.g., https://api.selftune.dev/api/v1/push). + * The endpoint is the device-link URL (e.g., https://cloud.selftune.dev/api/device). * Strip /push to get the base. */ export function getBaseUrl(): string { diff --git a/packages/runtime/command-surface/improvement.ts b/packages/runtime/command-surface/improvement.ts index f70337ac..babfdfcf 100644 --- a/packages/runtime/command-surface/improvement.ts +++ b/packages/runtime/command-surface/improvement.ts @@ -136,15 +136,22 @@ export const IMPROVEMENT_COMMAND_SURFACES = { "selftune improve --skill --skill-path [--scope auto|description|routing|body|package] [--dry-run] [--validation-mode auto|replay|judge]", extraHelpSections: [ `Scope mapping: - auto|description -> \`selftune evolve\` + auto -> historical DuckDB replay with \`--dry-run\` when eligible, + otherwise \`selftune evolve\` + description -> \`selftune evolve\` routing -> \`selftune evolve body --target routing\` body -> \`selftune evolve body --target body\` package -> \`selftune search-run\` -Today \`auto\` defaults to description-surface evolution unless you pick a -broader scope explicitly. Package scope runs bounded search as a measured -review loop; without \`--dry-run\` it also promotes the winning candidate back -into the draft package.`, +With \`--scope auto --dry-run\`, SelfTune first looks for historical skill/task +provenance in local DuckDB. Invocation-local outcomes may form a contrastive +cohort; otherwise exact explicit tasks form a neutral quality cohort and session +errors are ignored. Candidate generation sees calibration only. Codex execution +replay uses \`gpt-5.6-luna\` at max reasoning across isolated no-skill, current, +candidate, selection, and audit arms. This path is review-only and never edits +the installed skill. If no eligible cohort exists, auto falls back to description +evolution. Package scope runs bounded search as a measured review loop; without +\`--dry-run\` it also promotes the winning candidate back into the draft package.`, ], }, searchRun: { @@ -244,7 +251,7 @@ into the draft package.`, { flag: "--output", helpLabel: "--output, --out", - description: "Output file path (default: _trigger_eval.json)", + description: "Additional output copy (package default: evals/routing.json)", }, { flag: "--no-negatives", @@ -317,7 +324,7 @@ into the draft package.`, 3. selftune evolve --skill --skill-path --dry-run --validation-mode replay 4. selftune grade baseline --skill --skill-path -Generated evals are stored canonically in SQLite and mirrored into ~/.selftune/eval-sets/.json for compatibility with file-based workflows.`, +Generated evals live with the skill at evals/routing.json. SQLite indexes the contract and ~/.selftune/eval-sets/.json remains a compatibility mirror.`, ], }, } satisfies Record; diff --git a/packages/runtime/constants.ts b/packages/runtime/constants.ts index 981de0ca..dcb60848 100644 --- a/packages/runtime/constants.ts +++ b/packages/runtime/constants.ts @@ -187,11 +187,11 @@ export const DASHBOARD_ACTION_STREAM_LOG = join( ); /** Creator-directed relay endpoint for staged contribution signals. */ export const CONTRIBUTION_RELAY_ENDPOINT = - process.env.SELFTUNE_CONTRIBUTION_RELAY_ENDPOINT ?? "https://api.selftune.dev/api/v1/signals"; + process.env.SELFTUNE_CONTRIBUTION_RELAY_ENDPOINT?.trim() ?? + "https://cloud.selftune.dev/api/v1/contributions/relay"; /** Public creator-directed relay endpoint for portable feedback helpers bundled with skills. */ export const CONTRIBUTION_PUBLIC_RELAY_ENDPOINT = - process.env.SELFTUNE_CONTRIBUTION_PUBLIC_RELAY_ENDPOINT ?? - "https://api.selftune.dev/api/v1/public/signals"; + process.env.SELFTUNE_CONTRIBUTION_PUBLIC_RELAY_ENDPOINT?.trim() ?? ""; // --------------------------------------------------------------------------- // Sanitization constants (for contribute command) diff --git a/packages/runtime/contribution-config.ts b/packages/runtime/contribution-config.ts index 7f78dcf0..194fe482 100644 --- a/packages/runtime/contribution-config.ts +++ b/packages/runtime/contribution-config.ts @@ -9,7 +9,8 @@ import { /** * The canonical UUID pattern for `creator_id`. This field must always be the - * creator's cloud user UUID (the `cloud_user_id` from alpha enrollment), e.g. + * creator's public Creator ID (returned as `cloud_user_id` by device linking for + * backward compatibility), e.g. * "550e8400-e29b-41d4-a716-446655440000". Non-UUID values are accepted during * local development but will be rejected by the relay endpoint. */ @@ -48,7 +49,7 @@ export function normalizeSupportedContributionSignals( export interface CreatorContributionConfig { version: 1; - /** Must be the creator's cloud user UUID (`cloud_user_id`). */ + /** Must be the creator's public Creator ID. */ creator_id: string; skill_name: string; config_path: string; @@ -62,7 +63,7 @@ export interface CreatorContributionConfig { } export interface CreatorContributionConfigInput { - /** Must be the creator's cloud user UUID (`cloud_user_id`). */ + /** Must be the creator's public Creator ID. */ creator_id: string; skill_name: string; skill_path: string; @@ -139,7 +140,7 @@ function normalizeContributionConfig( if (!isValidCreatorUUID(creatorId)) { process.stderr.write( `[selftune] warning: creator_id "${creatorId}" is not a valid UUID. ` + - `Expected a cloud user UUID (e.g. "550e8400-e29b-41d4-a716-446655440000").\n`, + `Expected a public Creator UUID (e.g. "550e8400-e29b-41d4-a716-446655440000").\n`, ); } @@ -200,7 +201,7 @@ export function writeCreatorContributionConfig( ): CreatorContributionConfig { if (!isValidCreatorUUID(input.creator_id)) { throw new Error( - `creator_id must be the creator's cloud user UUID. Received "${input.creator_id}".`, + `creator_id must be the creator's public Creator UUID. Received "${input.creator_id}".`, ); } const signals = normalizeSupportedContributionSignals(input.signals); diff --git a/packages/runtime/contribution-relay.ts b/packages/runtime/contribution-relay.ts index 0a10ecb2..30c7476b 100644 --- a/packages/runtime/contribution-relay.ts +++ b/packages/runtime/contribution-relay.ts @@ -4,6 +4,7 @@ import { loadConfigSync } from "@selftune/config"; import { resolveCloudCredential } from "./auth/cloud-credential.js"; import { CONTRIBUTION_RELAY_ENDPOINT, SELFTUNE_CONFIG_PATH } from "./constants.js"; +import { loadRemoteLibraryConfig } from "./remote-library-config.js"; import type { CreatorContributionRelayPayload } from "./contribution-signals.js"; import { markCreatorContributionFailed, @@ -53,11 +54,22 @@ function isAcceptedContributionResponse( } export function resolveContributionRelayEndpoint(explicit?: string): string { - return explicit?.trim() || CONTRIBUTION_RELAY_ENDPOINT; + if (explicit?.trim()) return explicit.trim(); + try { + const remote = loadRemoteLibraryConfig(); + return `${remote.url.replace(/\/$/, "")}/api/v1/contributions/relay`; + } catch { + return CONTRIBUTION_RELAY_ENDPOINT; + } } export function resolveContributionRelayApiKey(explicit?: string): string | null { if (explicit?.trim()) return explicit.trim(); + try { + return loadRemoteLibraryConfig().apiKey; + } catch { + // Fall through for pre-Remote-Library linked Cloud accounts. + } const config = loadConfigSync(SELFTUNE_CONFIG_PATH); return resolveCloudCredential(config, { configPath: SELFTUNE_CONFIG_PATH }); } @@ -130,6 +142,12 @@ export async function flushCreatorContributionSignals( }; } + if (!endpoint) { + throw new Error( + "Creator contribution upload is not hosted by SelfTune. Pass --endpoint for a creator-operated relay.", + ); + } + const requeued = requeueSendingCreatorContributionSignals(db); const retriedFailed = options.retryFailed ? requeueFailedCreatorContributionSignals(db) : 0; const pendingRows = getPendingCreatorContributionRows(db, limit); @@ -137,7 +155,7 @@ export async function flushCreatorContributionSignals( const apiKey = resolveContributionRelayApiKey(options.apiKey); if (!apiKey) { throw new Error( - "Creator contribution relay upload requires a cloud API key. Run `selftune init --alpha` or pass --api-key.", + "Creator contribution relay upload requires the creator relay API key. Pass --api-key.", ); } diff --git a/packages/runtime/contribution-signals.ts b/packages/runtime/contribution-signals.ts index 595c2411..a39568c3 100644 --- a/packages/runtime/contribution-signals.ts +++ b/packages/runtime/contribution-signals.ts @@ -12,6 +12,7 @@ export type ContributionSignal = "trigger" | "grade" | "miss_category"; export interface CreatorContributionRelayPayload { version: 1; signal_type: "skill_session"; + source_key: string; skill_name?: string; relay_destination: string; skill_hash: string; @@ -62,9 +63,13 @@ function resolveContributionCohortSeed(explicitSeed?: string): string { return alphaIdentity?.cloud_user_id || alphaIdentity?.user_id || hostname() || "selftune-local"; } -export function buildContributionUserCohort(now: Date = new Date(), explicitSeed?: string): string { +export function buildContributionUserCohort( + now: Date = new Date(), + explicitSeed?: string, + relayDestination = "local", +): string { const monthBucket = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; - const basis = `${resolveContributionCohortSeed(explicitSeed)}:${monthBucket}`; + const basis = `${resolveContributionCohortSeed(explicitSeed)}:${relayDestination}:${monthBucket}`; return `uc_sha256_${createHash("sha256").update(basis).digest("hex").slice(0, 12)}`; } @@ -124,7 +129,6 @@ export function buildCreatorDirectedContributionSignals( } } - const cohort = buildContributionUserCohort(options.now ?? new Date(), options.cohortSeed); const clientVersion = options.clientVersion ?? "local-preview"; return queryTrustedSkillObservationRows(db) @@ -145,29 +149,35 @@ export function buildCreatorDirectedContributionSignals( signals.query_bucket = classifyContributionQueryBucket(row.query_text); } + const sourceKey = createHash("sha256") + .update( + [ + row.skill_name, + row.session_id, + row.occurred_at ?? "", + row.query_text, + String(row.triggered), + row.invocation_mode ?? "", + ].join("::"), + ) + .digest("hex") + .slice(0, 16); return { skill_name: row.skill_name, creator_id: config.creator_id, - source_key: createHash("sha256") - .update( - [ - row.skill_name, - row.session_id, - row.occurred_at ?? "", - row.query_text, - String(row.triggered), - row.invocation_mode ?? "", - ].join("::"), - ) - .digest("hex") - .slice(0, 16), + source_key: sourceKey, payload: { version: 1 as const, signal_type: "skill_session" as const, + source_key: sourceKey, skill_name: config.skill_name, relay_destination: config.creator_id, skill_hash: buildContributionSkillHash(config.skill_name), - user_cohort: cohort, + user_cohort: buildContributionUserCohort( + options.now ?? new Date(), + options.cohortSeed, + config.creator_id, + ), signals, timestamp_bucket: bucketWeek( row.occurred_at ? new Date(row.occurred_at) : (options.now ?? new Date()), @@ -209,10 +219,15 @@ export function buildContributionPreview( samplePayload: payloads[0]?.payload ?? { version: 1, signal_type: "skill_session", + source_key: "0000000000000000", skill_name: config.skill_name, relay_destination: config.creator_id, skill_hash: buildContributionSkillHash(config.skill_name), - user_cohort: buildContributionUserCohort(options.now ?? new Date(), options.cohortSeed), + user_cohort: buildContributionUserCohort( + options.now ?? new Date(), + options.cohortSeed, + config.creator_id, + ), signals: { query_bucket: "other", }, diff --git a/packages/runtime/contributions/help.ts b/packages/runtime/contributions/help.ts index 83694b76..8d6037c0 100644 --- a/packages/runtime/contributions/help.ts +++ b/packages/runtime/contributions/help.ts @@ -31,8 +31,8 @@ Options: --dry-run Preview how many staged signals would upload --retry-failed Requeue previously failed rows before attempting upload --limit Max number of staged rows to attempt (default: 50) - --endpoint Override relay endpoint (default: ${endpoint}) - --api-key Override the linked cloud account credential`; + --endpoint Creator-operated relay endpoint${endpoint ? ` (default: ${endpoint})` : ""} + --api-key Creator relay credential`; } export const CONTRIBUTIONS_UPLOAD_HELP = formatContributionsUploadHelp(CONTRIBUTION_RELAY_ENDPOINT); diff --git a/packages/runtime/creator-contributions-help.ts b/packages/runtime/creator-contributions-help.ts index 3206b558..c7d8e236 100644 --- a/packages/runtime/creator-contributions-help.ts +++ b/packages/runtime/creator-contributions-help.ts @@ -10,7 +10,7 @@ Usage: Purpose: Manage the local selftune.contribute.json creator sharing setup file that a skill creator bundles with a skill package. The --creator-id must be the - creator's cloud user UUID (the cloud_user_id from alpha enrollment). + creator's public Creator ID (returned as cloud_user_id by current device linking). By default, enable also writes a portable selftune-feedback.mjs helper so downstream agents can send privacy-safe signals without installing selftune. This is separate from: diff --git a/packages/runtime/creator-contributions.ts b/packages/runtime/creator-contributions.ts index 0a7c5370..71888088 100644 --- a/packages/runtime/creator-contributions.ts +++ b/packages/runtime/creator-contributions.ts @@ -136,14 +136,14 @@ export function enableCreatorContributionConfigs(options: { const creatorId = inferCreatorId(options.explicitCreatorId); if (!creatorId) { throw new CLIError( - "Creator ID is required. Must be the creator's cloud user UUID.", + "Creator ID is required. Use the creator's public Creator ID.", "MISSING_FLAG", "Pass --creator-id or enroll alpha so cloud_user_id is available.", ); } if (!isValidCreatorUUID(creatorId)) { throw new CLIError( - `Creator ID must be a cloud user UUID. Received "${creatorId}".`, + `Creator ID must be a public Creator UUID. Received "${creatorId}".`, "INVALID_FLAG", "Pass --creator-id or enroll alpha so cloud_user_id is available.", ); @@ -263,7 +263,7 @@ export function runCreatorContributionsEnableProgram( signals, message: options.message, privacyUrl: options.privacyUrl, - helper: options.helper ?? true, + helper: options.helper ?? Boolean(options.feedbackEndpoint), feedbackEndpoint: options.feedbackEndpoint, }); diff --git a/packages/runtime/dashboard-contract/local-management.ts b/packages/runtime/dashboard-contract/local-management.ts index f3260bba..bd5e6c2b 100644 --- a/packages/runtime/dashboard-contract/local-management.ts +++ b/packages/runtime/dashboard-contract/local-management.ts @@ -163,6 +163,11 @@ export interface DesktopScheduleJob { export interface DesktopSettingsResponse { harnesses: HarnessConnection[]; + agent_skill: { + installed: boolean; + locations: string[]; + install_command: string; + }; onboarding: OnboardingPreferences; cloud_account: { linked: boolean; @@ -234,4 +239,5 @@ export type { WorkspaceMember, WorkspaceMemberRole, WorkspaceMembersResponse, + WorkspaceTeamOverview, } from "@selftune/library/remote/types"; diff --git a/packages/runtime/dashboard-reactivity.ts b/packages/runtime/dashboard-reactivity.ts index 146c92ea..84837042 100644 --- a/packages/runtime/dashboard-reactivity.ts +++ b/packages/runtime/dashboard-reactivity.ts @@ -74,6 +74,7 @@ export const durableDecisionResources = { export type ProjectSkillSetMutation = | "create" | "update" + | "remove" | "derive" | "export" | "plan" @@ -91,6 +92,7 @@ const projectMaterializationResources = [ export const projectSkillSetResources = { create: [DashboardResource.projects], update: [DashboardResource.projects], + remove: [DashboardResource.projects], derive: [DashboardResource.projects], export: [DashboardResource.projects], plan: [DashboardResource.projects], diff --git a/packages/runtime/desktop-settings.ts b/packages/runtime/desktop-settings.ts index 24789e37..ef49d5bc 100644 --- a/packages/runtime/desktop-settings.ts +++ b/packages/runtime/desktop-settings.ts @@ -23,6 +23,7 @@ import { } from "./scheduling.js"; import { loadOnboardingPreferences } from "./onboarding-preferences.js"; import { remoteLibrarySettings, updateRemoteLibraryConfig } from "./remote-library/config.js"; +import { getInstalledSkillDirs } from "./auto-update.js"; const SETTINGS_VERSION = 1; const SETTINGS_FILENAME = "desktop-settings.json"; @@ -172,6 +173,7 @@ export function loadDesktopSettings(options: SettingsEnvironment = {}): DesktopS }, ); const harnesses = detectHarnessConnections(options); + const agentSkillLocations = getInstalledSkillDirs(home); const onboarding = loadOnboardingPreferences(options.configDir); const configRoot = options.configDir ?? SELFTUNE_CONFIG_DIR; const alpha = (() => { @@ -188,6 +190,11 @@ export function loadDesktopSettings(options: SettingsEnvironment = {}): DesktopS return { harnesses, + agent_skill: { + installed: agentSkillLocations.length > 0, + locations: agentSkillLocations, + install_command: "npx skills add selftune-dev/selftune", + }, onboarding, cloud_account: { linked: Boolean(alpha?.enrolled && alpha.cloud_user_id?.trim() && alpha.cloud_org_id?.trim()), diff --git a/packages/runtime/eval/cli-contract.ts b/packages/runtime/eval/cli-contract.ts index 91e3e105..c9f92c1f 100644 --- a/packages/runtime/eval/cli-contract.ts +++ b/packages/runtime/eval/cli-contract.ts @@ -30,6 +30,17 @@ export interface EvalUnitTestInput { readonly model?: string; } +export interface EvalRunInput { + readonly skillPath?: string; + readonly evals?: string; + readonly workspace?: string; + readonly baselineSkillPath?: string; + readonly feedback?: string; + readonly agent?: string; + readonly model?: string; + readonly json: boolean; +} + export interface EvalImportInput { readonly dir?: string; readonly skill?: string; @@ -48,6 +59,7 @@ export interface EvalFamilyOverlapInput { export type EvalCommandRequest = | { readonly action: "generate"; readonly input: EvalGenerateInput } | { readonly action: "unit-test"; readonly input: EvalUnitTestInput } + | { readonly action: "run"; readonly input: EvalRunInput } | { readonly action: "import"; readonly input: EvalImportInput } | { readonly action: "composability"; readonly input: ComposabilityInput } | { readonly action: "family-overlap"; readonly input: EvalFamilyOverlapInput }; diff --git a/packages/runtime/eval/hooks-to-evals.ts b/packages/runtime/eval/hooks-to-evals.ts index 24b8f7f2..c5b10d01 100644 --- a/packages/runtime/eval/hooks-to-evals.ts +++ b/packages/runtime/eval/hooks-to-evals.ts @@ -54,7 +54,7 @@ import { isHighConfidencePositiveSkillRecord } from "../utils/skill-usage-confid import { readJsonl } from "../utils/jsonl.js"; import { classifyInvocation } from "./invocation-classifier.js"; import { generateSyntheticEvals } from "./synthetic-evals.js"; -import { writeCanonicalEvalSet } from "../testing-readiness.js"; +import { getPackageEvalSetPath, writeCanonicalEvalSet } from "../testing-readiness.js"; import type { EvalGenerateInput } from "./cli-contract.js"; import { getEvalSkillSearchDirs, @@ -409,7 +409,8 @@ export async function runEvalGenerate(input: EvalGenerateInput): Promise { evidence: values["skill-path"], }); - const outputPath = values.output ?? values.out ?? `${values.skill}_trigger_eval.json`; + const packageEvalPath = getPackageEvalSetPath(values["skill-path"]); + const outputPath = values.output ?? values.out ?? packageEvalPath; emitDashboardStepProgress({ current: 4, total: 4, @@ -417,8 +418,10 @@ export async function runEvalGenerate(input: EvalGenerateInput): Promise { phase: "write_eval_set", label: "Write eval set", }); - writeFileSync(outputPath, JSON.stringify(evalSet, null, 2), "utf-8"); - const canonicalPath = writeCanonicalEvalSet(values.skill, evalSet); + const canonicalPath = writeCanonicalEvalSet(values.skill, evalSet, values["skill-path"]); + if (outputPath !== canonicalPath) { + writeFileSync(outputPath, JSON.stringify(evalSet, null, 2), "utf-8"); + } emitDashboardStepProgress({ current: 4, total: 4, @@ -584,7 +587,8 @@ export async function runEvalGenerate(input: EvalGenerateInput): Promise { passed: true, evidence: skillPath, }); - const outputPath = values.output ?? values.out ?? `${values.skill}_trigger_eval.json`; + const packageEvalPath = getPackageEvalSetPath(skillPath); + const outputPath = values.output ?? values.out ?? packageEvalPath; emitDashboardStepProgress({ current: 4, total: 4, @@ -592,8 +596,10 @@ export async function runEvalGenerate(input: EvalGenerateInput): Promise { phase: "write_eval_set", label: "Write eval set", }); - writeFileSync(outputPath, JSON.stringify(syntheticEvalSet, null, 2), "utf-8"); - const canonicalPath = writeCanonicalEvalSet(values.skill, syntheticEvalSet); + const canonicalPath = writeCanonicalEvalSet(values.skill, syntheticEvalSet, skillPath); + if (outputPath !== canonicalPath) { + writeFileSync(outputPath, JSON.stringify(syntheticEvalSet, null, 2), "utf-8"); + } emitDashboardStepProgress({ current: 4, total: 4, @@ -701,7 +707,13 @@ export async function runEvalGenerate(input: EvalGenerateInput): Promise { ); } - const outputPath = values.output ?? values.out ?? `${values.skill}_trigger_eval.json`; + const resolvedSkillPath = values["skill-path"] ?? detectedSkillPath; + const outputPath = + values.output ?? + values.out ?? + (resolvedSkillPath + ? getPackageEvalSetPath(resolvedSkillPath) + : `${values.skill}_trigger_eval.json`); emitDashboardStepProgress({ current: values.blend ? 5 : 3, total: values.blend ? 5 : 3, @@ -709,8 +721,10 @@ export async function runEvalGenerate(input: EvalGenerateInput): Promise { phase: "write_eval_set", label: "Write eval set", }); - writeFileSync(outputPath, JSON.stringify(finalEvalSet, null, 2), "utf-8"); - const canonicalPath = writeCanonicalEvalSet(values.skill, finalEvalSet); + const canonicalPath = writeCanonicalEvalSet(values.skill, finalEvalSet, resolvedSkillPath); + if (outputPath !== canonicalPath) { + writeFileSync(outputPath, JSON.stringify(finalEvalSet, null, 2), "utf-8"); + } emitDashboardStepProgress({ current: values.blend ? 5 : 3, total: values.blend ? 5 : 3, diff --git a/packages/runtime/eval/output-quality.ts b/packages/runtime/eval/output-quality.ts new file mode 100644 index 00000000..6fb2524c --- /dev/null +++ b/packages/runtime/eval/output-quality.ts @@ -0,0 +1,455 @@ +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +import { CLIError } from "../utils/cli-error.js"; +import { callLlm, detectLlmAgent, stripMarkdownFences } from "../utils/llm-call.js"; +import type { SkillAssertion } from "../types.js"; +import { checkAssertion } from "./unit-test.js"; + +export interface OutputEvalCase { + readonly id: string | number; + readonly prompt: string; + readonly expected_output: string; + readonly files?: readonly string[]; + readonly assertions?: readonly string[]; + readonly selftune_assertions?: readonly SkillAssertion[]; +} + +export interface OutputEvalFile { + readonly skill_name: string; + readonly evals: readonly OutputEvalCase[]; +} + +export interface OutputEvalRunInput { + readonly skillPath: string; + readonly evalsPath?: string; + readonly workspacePath?: string; + readonly baselineSkillPath?: string; + readonly feedbackPath?: string; + readonly agent?: string; + readonly model?: string; +} + +export interface OutputEvalRunDeps { + readonly execute?: (input: { + system: string; + prompt: string; + agent: string; + model?: string; + workingDirectory: string; + }) => Promise<{ output: string; totalTokens: number | null }>; + readonly grade?: (input: { + expectedOutput: string; + assertions: readonly string[]; + output: string; + agent: string; + model?: string; + }) => Promise; + readonly compare?: (input: { + prompt: string; + outputA: string; + outputB: string; + agent: string; + model?: string; + }) => Promise<{ winner: "A" | "B" | "tie"; evidence: string }>; +} + +interface GradingAssertionResult { + readonly text: string; + readonly passed: boolean; + readonly evidence: string; +} + +interface ArmResult { + readonly pass_rate: number; + readonly duration_ms: number; + readonly total_tokens: number | null; + readonly grading: { + readonly assertion_results: readonly GradingAssertionResult[]; + readonly summary: { + readonly passed: number; + readonly failed: number; + readonly total: number; + readonly pass_rate: number; + }; + }; + readonly output: string; +} + +function skillFile(path: string): string { + const absolute = resolve(path); + return basename(absolute) === "SKILL.md" ? absolute : join(absolute, "SKILL.md"); +} + +export function loadOutputEvalFile(path: string): OutputEvalFile { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + if ( + !parsed || + typeof parsed !== "object" || + !("skill_name" in parsed) || + typeof parsed.skill_name !== "string" || + !("evals" in parsed) || + !Array.isArray(parsed.evals) + ) { + throw new CLIError( + `Invalid Agent Skills eval file: ${path}`, + "INVALID_FLAG", + "Use evals/evals.json with skill_name and evals fields.", + ); + } + for (const entry of parsed.evals) { + if ( + !entry || + typeof entry !== "object" || + !("id" in entry) || + !("prompt" in entry) || + typeof entry.prompt !== "string" || + !("expected_output" in entry) || + typeof entry.expected_output !== "string" + ) { + throw new CLIError( + `Invalid eval case in ${path}`, + "INVALID_FLAG", + "Every case needs id, prompt, and expected_output.", + ); + } + } + return parsed as OutputEvalFile; +} + +function nextIteration(workspace: string): number { + if (!existsSync(workspace)) return 1; + const entries = new Bun.Glob("iteration-*").scanSync({ cwd: workspace, onlyFiles: false }); + return ( + Math.max( + 0, + ...[...entries] + .map((entry) => Number(entry.slice("iteration-".length))) + .filter(Number.isFinite), + ) + 1 + ); +} + +function safeCaseName(entry: OutputEvalCase): string { + const value = String(entry.id) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return `eval-${value || "case"}`; +} + +function mean(values: readonly number[]): number { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function stddev(values: readonly number[]): number { + if (values.length < 2) return 0; + const avg = mean(values); + return Math.sqrt(mean(values.map((value) => (value - avg) ** 2))); +} + +async function defaultExecute(input: { + system: string; + prompt: string; + agent: string; + model?: string; + workingDirectory: string; +}) { + return { + output: await callLlm( + input.system, + input.prompt, + input.agent, + input.model, + undefined, + undefined, + input.workingDirectory, + ), + totalTokens: null, + }; +} + +async function defaultCompare(input: { + prompt: string; + outputA: string; + outputB: string; + agent: string; + model?: string; +}): Promise<{ winner: "A" | "B" | "tie"; evidence: string }> { + const response = await callLlm( + "Blindly compare two outputs. Do not infer which used a skill. Return only JSON with winner A, B, or tie and concrete evidence.", + JSON.stringify({ prompt: input.prompt, output_a: input.outputA, output_b: input.outputB }), + input.agent, + input.model, + ); + return JSON.parse(stripMarkdownFences(response)) as { + winner: "A" | "B" | "tie"; + evidence: string; + }; +} + +async function defaultGrade(input: { + expectedOutput: string; + assertions: readonly string[]; + output: string; + agent: string; + model?: string; +}): Promise { + if (input.assertions.length === 0) return []; + const response = await callLlm( + "You grade output assertions strictly. Return only JSON. A PASS requires concrete evidence from the output.", + JSON.stringify({ + expected_output: input.expectedOutput, + assertions: input.assertions, + output: input.output, + response_schema: { + assertion_results: [ + { text: "assertion", passed: true, evidence: "specific output evidence" }, + ], + }, + }), + input.agent, + input.model, + ); + const parsed = JSON.parse(stripMarkdownFences(response)) as { + assertion_results?: GradingAssertionResult[]; + }; + return parsed.assertion_results ?? []; +} + +function copyInputs(entry: OutputEvalCase, skillDir: string, runDir: string): string[] { + const copied: string[] = []; + for (const relative of entry.files ?? []) { + const source = resolve(skillDir, relative); + if (!source.startsWith(`${skillDir}/`) || !existsSync(source)) { + throw new CLIError( + `Eval input file not found or outside the skill: ${relative}`, + "FILE_NOT_FOUND", + "Keep eval inputs under the skill directory.", + ); + } + const target = join(runDir, "inputs", relative.replace(/^evals\/files\//, "")); + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target, { recursive: true }); + copied.push(target); + } + return copied; +} + +async function runArm( + entry: OutputEvalCase, + runDir: string, + skillContent: string | null, + skillDir: string, + agent: string, + model: string | undefined, + deps: OutputEvalRunDeps, +): Promise { + mkdirSync(join(runDir, "outputs"), { recursive: true }); + const files = copyInputs(entry, skillDir, runDir); + const system = + skillContent == null + ? "Complete the task without access to the evaluated skill." + : `Follow this Agent Skill exactly:\n\n${skillContent}`; + const started = Date.now(); + const execution = await (deps.execute ?? defaultExecute)({ + system, + prompt: `${entry.prompt}\n\nInput files: ${files.join(", ") || "none"}\nSave or describe the completed output.`, + agent, + model, + workingDirectory: runDir, + }); + const duration = Date.now() - started; + const totalTokens = execution.totalTokens ?? Math.ceil(execution.output.length / 4); + writeFileSync(join(runDir, "outputs", "response.md"), execution.output, "utf-8"); + writeFileSync( + join(runDir, "timing.json"), + JSON.stringify( + { + total_tokens: totalTokens, + token_measurement: execution.totalTokens == null ? "estimated" : "reported", + duration_ms: duration, + }, + null, + 2, + ), + "utf-8", + ); + + const judged = await (deps.grade ?? defaultGrade)({ + expectedOutput: entry.expected_output, + assertions: entry.assertions ?? [], + output: execution.output, + agent, + model, + }); + const mechanical = (entry.selftune_assertions ?? []).map((assertion) => { + const result = checkAssertion(assertion, execution.output); + return { + text: assertion.description ?? `${assertion.type}: ${assertion.value}`, + passed: result.passed, + evidence: result.actual ?? "No mechanical evidence was produced.", + }; + }); + const assertionResults = [...judged, ...mechanical]; + const passed = assertionResults.filter((result) => result.passed).length; + const total = assertionResults.length; + const grading = { + assertion_results: assertionResults, + summary: { passed, failed: total - passed, total, pass_rate: total === 0 ? 0 : passed / total }, + }; + writeFileSync(join(runDir, "grading.json"), JSON.stringify(grading, null, 2), "utf-8"); + return { + pass_rate: grading.summary.pass_rate, + duration_ms: duration, + total_tokens: totalTokens, + grading, + output: execution.output, + }; +} + +export async function runOutputQualityEvaluation( + input: OutputEvalRunInput, + deps: OutputEvalRunDeps = {}, +) { + const currentSkillFile = skillFile(input.skillPath); + if (!existsSync(currentSkillFile)) + throw new CLIError( + `SKILL.md not found: ${currentSkillFile}`, + "FILE_NOT_FOUND", + "Pass --skill-path to a valid skill package.", + ); + const skillDir = dirname(currentSkillFile); + const evalsPath = resolve(input.evalsPath ?? join(skillDir, "evals", "evals.json")); + const contract = loadOutputEvalFile(evalsPath); + const agent = input.agent ?? detectLlmAgent(); + if (!agent) + throw new CLIError( + "No supported agent CLI found", + "AGENT_NOT_FOUND", + "Install Claude, Codex, OpenCode, or Pi.", + ); + const workspace = resolve( + input.workspacePath ?? join(dirname(skillDir), `${contract.skill_name}-workspace`), + ); + const iterationNumber = nextIteration(workspace); + const iterationDir = join(workspace, `iteration-${iterationNumber}`); + mkdirSync(iterationDir, { recursive: true }); + const currentContent = readFileSync(currentSkillFile, "utf-8"); + const baselineContent = input.baselineSkillPath + ? readFileSync(skillFile(input.baselineSkillPath), "utf-8") + : null; + const baselineArm = baselineContent ? "old_skill" : "without_skill"; + const caseResults: Array<{ + id: string | number; + with_skill: ArmResult; + baseline: ArmResult; + blind_comparison: { winner: "with_skill" | "baseline" | "tie"; evidence: string }; + }> = []; + + for (const entry of contract.evals) { + const caseDir = join(iterationDir, safeCaseName(entry)); + const withSkill = await runArm( + entry, + join(caseDir, "with_skill"), + currentContent, + skillDir, + agent, + input.model, + deps, + ); + const baseline = await runArm( + entry, + join(caseDir, baselineArm), + baselineContent, + skillDir, + agent, + input.model, + deps, + ); + const swap = Number(entry.id) % 2 === 0; + const comparison = await (deps.compare ?? defaultCompare)({ + prompt: entry.prompt, + outputA: swap ? baseline.output : withSkill.output, + outputB: swap ? withSkill.output : baseline.output, + agent, + model: input.model, + }); + const winner = + comparison.winner === "tie" + ? "tie" + : comparison.winner === (swap ? "B" : "A") + ? "with_skill" + : "baseline"; + caseResults.push({ + id: entry.id, + with_skill: withSkill, + baseline, + blind_comparison: { winner, evidence: comparison.evidence }, + }); + } + + const summarize = (values: readonly ArmResult[]) => ({ + pass_rate: { + mean: mean(values.map((value) => value.pass_rate)), + stddev: stddev(values.map((value) => value.pass_rate)), + }, + time_seconds: { + mean: mean(values.map((value) => value.duration_ms / 1000)), + stddev: stddev(values.map((value) => value.duration_ms / 1000)), + }, + tokens: { + mean: mean( + values.flatMap((value) => (value.total_tokens == null ? [] : [value.total_tokens])), + ), + stddev: stddev( + values.flatMap((value) => (value.total_tokens == null ? [] : [value.total_tokens])), + ), + }, + }); + const withSummary = summarize(caseResults.map((result) => result.with_skill)); + const baselineSummary = summarize(caseResults.map((result) => result.baseline)); + const assertionPairs = caseResults.flatMap((result) => { + const baselineByText = new Map( + result.baseline.grading.assertion_results.map((assertion) => [assertion.text, assertion]), + ); + return result.with_skill.grading.assertion_results.flatMap((withSkill) => { + const baseline = baselineByText.get(withSkill.text); + return baseline ? [{ id: result.id, text: withSkill.text, withSkill, baseline }] : []; + }); + }); + const alwaysPass = assertionPairs + .filter((pair) => pair.withSkill.passed && pair.baseline.passed) + .map((pair) => ({ eval_id: pair.id, assertion: pair.text })); + const alwaysFail = assertionPairs + .filter((pair) => !pair.withSkill.passed && !pair.baseline.passed) + .map((pair) => ({ eval_id: pair.id, assertion: pair.text })); + const benchmark = { + iteration: iterationNumber, + skill_name: contract.skill_name, + baseline: baselineArm, + run_summary: { + with_skill: withSummary, + [baselineArm]: baselineSummary, + delta: { + pass_rate: withSummary.pass_rate.mean - baselineSummary.pass_rate.mean, + time_seconds: withSummary.time_seconds.mean - baselineSummary.time_seconds.mean, + tokens: withSummary.tokens.mean - baselineSummary.tokens.mean, + }, + }, + assertion_analysis: { always_pass_both_arms: alwaysPass, always_fail_both_arms: alwaysFail }, + cases: caseResults.map((result) => ({ + id: result.id, + with_skill_pass_rate: result.with_skill.pass_rate, + baseline_pass_rate: result.baseline.pass_rate, + blind_comparison: result.blind_comparison, + })), + }; + writeFileSync(join(iterationDir, "benchmark.json"), JSON.stringify(benchmark, null, 2), "utf-8"); + const feedback = + input.feedbackPath && existsSync(input.feedbackPath) + ? JSON.parse(readFileSync(input.feedbackPath, "utf-8")) + : Object.fromEntries(contract.evals.map((entry) => [safeCaseName(entry), ""])); + writeFileSync(join(iterationDir, "feedback.json"), JSON.stringify(feedback, null, 2), "utf-8"); + return { iteration_dir: iterationDir, benchmark }; +} diff --git a/packages/runtime/eval/programs.ts b/packages/runtime/eval/programs.ts index 48e4466c..160c92de 100644 --- a/packages/runtime/eval/programs.ts +++ b/packages/runtime/eval/programs.ts @@ -33,6 +33,37 @@ export const runEvalProgram = Effect.fn("selftune.eval.run")(function* ( }); return; } + case "run": { + const runner = yield* Effect.promise(() => import("./output-quality.js")); + if (!request.input.skillPath) { + return yield* Effect.fail( + new CLIError( + "--skill-path is required", + "MISSING_FLAG", + "selftune eval run --skill-path /path/to/SKILL.md", + ), + ); + } + const result = yield* Effect.tryPromise({ + try: () => + runner.runOutputQualityEvaluation({ + skillPath: request.input.skillPath!, + evalsPath: request.input.evals, + workspacePath: request.input.workspace, + baselineSkillPath: request.input.baselineSkillPath, + feedbackPath: request.input.feedback, + agent: request.input.agent, + model: request.input.model, + }), + catch: (cause) => toEvalCliError(cause, request.action), + }); + console.log( + request.input.json + ? JSON.stringify(result, null, 2) + : `Evaluation iteration written to ${result.iteration_dir}`, + ); + return; + } case "import": { const importer = yield* Effect.promise(() => import("./import-skillsbench.js")); yield* Effect.try({ diff --git a/packages/runtime/eval/unit-test-cli.ts b/packages/runtime/eval/unit-test-cli.ts index 4b5d94c2..808e9093 100644 --- a/packages/runtime/eval/unit-test-cli.ts +++ b/packages/runtime/eval/unit-test-cli.ts @@ -22,7 +22,11 @@ import { emitDashboardStepProgress, } from "../dashboard-action-instrumentation.js"; import type { EvalEntry } from "../types.js"; -import { writeCanonicalUnitTests, writeUnitTestRunResult } from "../testing-readiness.js"; +import { + getPackageUnitTestPath, + writeCanonicalUnitTests, + writeUnitTestRunResult, +} from "../testing-readiness.js"; import { CLIError } from "../utils/cli-error.js"; import { callLlm, detectLlmAgent } from "../utils/llm-call.js"; import { generateUnitTests } from "./generate-unit-tests.js"; @@ -56,7 +60,10 @@ export async function runEvalUnitTests(input: EvalUnitTestInput): Promise const skillName = values.skill; const unitTestDir = join(SELFTUNE_CONFIG_DIR, "unit-tests"); const defaultTestsPath = join(unitTestDir, `${skillName}.json`); - const testsPath = values.tests ?? defaultTestsPath; + const packageTestsPath = values["skill-path"] + ? getPackageUnitTestPath(values["skill-path"]) + : null; + const testsPath = values.tests ?? packageTestsPath ?? defaultTestsPath; // --generate: create tests from skill content if (values.generate) { @@ -135,7 +142,12 @@ export async function runEvalUnitTests(input: EvalUnitTestInput): Promise label: "Write generated tests", }); mkdirSync(unitTestDir, { recursive: true }); - const storedPath = writeCanonicalUnitTests(skillName, tests, testsPath); + const storedPath = writeCanonicalUnitTests( + skillName, + tests, + values.tests, + values["skill-path"], + ); emitDashboardStepProgress({ current: 3, total: 3, diff --git a/packages/runtime/eval/unit-test.ts b/packages/runtime/eval/unit-test.ts index f9d455d1..1177030e 100644 --- a/packages/runtime/eval/unit-test.ts +++ b/packages/runtime/eval/unit-test.ts @@ -108,7 +108,33 @@ export function loadUnitTests(testsPath: string): SkillUnitTest[] { return []; } const raw = readFileSync(testsPath, "utf-8"); - const parsed = JSON.parse(raw); + const parsed = JSON.parse(raw) as unknown; + if ( + parsed && + typeof parsed === "object" && + "skill_name" in parsed && + typeof parsed.skill_name === "string" && + "evals" in parsed && + Array.isArray(parsed.evals) + ) { + const skillName = parsed.skill_name; + return parsed.evals.flatMap((entry, index): SkillUnitTest[] => { + if (!entry || typeof entry !== "object") return []; + if (!("prompt" in entry) || typeof entry.prompt !== "string") return []; + if (!("selftune_assertions" in entry) || !Array.isArray(entry.selftune_assertions)) { + return []; + } + return [ + { + id: "id" in entry ? String(entry.id) : `eval-${index + 1}`, + skill_name: skillName, + query: entry.prompt, + assertions: entry.selftune_assertions as SkillUnitTest["assertions"], + ...(Array.isArray(entry.tags) ? { tags: entry.tags as string[] } : {}), + }, + ]; + }); + } if (!Array.isArray(parsed)) { console.warn(`[WARN] Unit test file is not an array: ${testsPath}`); return []; diff --git a/packages/runtime/evolution/cloud-evaluation-target-client.ts b/packages/runtime/evolution/cloud-evaluation-target-client.ts index bf4efb1f..b59983e0 100644 --- a/packages/runtime/evolution/cloud-evaluation-target-client.ts +++ b/packages/runtime/evolution/cloud-evaluation-target-client.ts @@ -25,14 +25,12 @@ const MAX_SKILL_REVISION_LENGTH = 128; const BoundedId = Schema.String.check( Schema.isMinLength(1), Schema.isMaxLength(128), - // oxlint-disable-next-line no-control-regex -- portable identifiers reject ASCII control bytes. - Schema.isPattern(/^[^\u0000-\u001f\u007f]+$/), + Schema.isPattern(/^[^\p{Cc}]+$/u), ); const BoundedName = Schema.String.check( Schema.isMinLength(1), Schema.isMaxLength(256), - // oxlint-disable-next-line no-control-regex -- portable names reject ASCII control bytes. - Schema.isPattern(/^[^\u0000-\u001f\u007f]+$/), + Schema.isPattern(/^[^\p{Cc}]+$/u), ); const SkillRevision = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); const Sha256Digest = Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)); @@ -152,8 +150,7 @@ function isBoundedText(value: unknown, maxLength: number): value is string { typeof value === "string" && value.length > 0 && value.length <= maxLength && - // oxlint-disable-next-line no-control-regex -- Cloud boundary input rejects ASCII control bytes. - !/[\u0000-\u001f\u007f]/.test(value) + !/\p{Cc}/u.test(value) ); } diff --git a/packages/runtime/evolution/evidence-cohort-body-adapter.ts b/packages/runtime/evolution/evidence-cohort-body-adapter.ts index 610f1917..2ef2a5ef 100644 --- a/packages/runtime/evolution/evidence-cohort-body-adapter.ts +++ b/packages/runtime/evolution/evidence-cohort-body-adapter.ts @@ -41,6 +41,9 @@ export class ResolvedEvidenceReference extends Schema.Class; + /** Bounded-search context used to produce deliberately distinct proposals. */ + search?: { + candidate_index: number; + candidate_count: number; + strategy: string; + }; } export type CohortBodyTeacher = (input: CohortBodyTeacherInput) => Promise; @@ -238,7 +247,7 @@ function projectCalibration( : entry.role === "calibration_success" ? "successful" : "counterexample", - excerpt: entry.redacted_excerpt ?? "", + excerpt: entry.redacted_excerpt === resolution.query ? "" : (entry.redacted_excerpt ?? ""), }), ]; }); diff --git a/packages/runtime/evolution/host-task-replay.ts b/packages/runtime/evolution/host-task-replay.ts new file mode 100644 index 00000000..de3594c8 --- /dev/null +++ b/packages/runtime/evolution/host-task-replay.ts @@ -0,0 +1,235 @@ +import type { RoutingReplayFixture } from "../types.js"; + +import type { + RuntimeReplayContentTarget, + RuntimeReplayReasoningEffort, +} from "./validate-host-replay/contracts.js"; +import { + buildRuntimeReplayWorkspace, + cleanupRuntimeReplayWorkspace, +} from "./validate-host-replay/workspace.js"; + +export const HOST_TASK_REPLAY_TIMEOUT_MS = 300_000; +export const HOST_TASK_REPLAY_TERMINATION_GRACE_MS = 2_000; + +export interface CodexTaskReplayProcess { + readonly stdout: ReadableStream; + readonly stderr: ReadableStream; + readonly exited: Promise; + readonly kill: (signal?: number) => void; +} + +export interface CollectCodexTaskReplayProcessOptions { + readonly timeoutMs?: number; + readonly terminationGraceMs?: number; +} + +export interface HostTaskReplayResult { + readonly output: string; + readonly raw_output: string; + readonly session_id: string | null; + readonly duration_ms: number; + readonly input_tokens: number | null; + readonly output_tokens: number | null; +} + +export interface HostTaskReplayOptions { + readonly task: string; + readonly body: string; + readonly fixture: RoutingReplayFixture; + readonly contentTarget?: RuntimeReplayContentTarget; + readonly includeTargetSkill: boolean; + readonly model: string; + readonly reasoningEffort: RuntimeReplayReasoningEffort; +} + +const delay = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * Collects a replay without trusting child streams to close after process exit. + * Some Codex helpers inherit the pipes; timeout rejection must therefore be + * independent of stdout/stderr completion. + */ +export async function collectCodexTaskReplayProcess( + child: CodexTaskReplayProcess, + options: CollectCodexTaskReplayProcessOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? HOST_TASK_REPLAY_TIMEOUT_MS; + const terminationGraceMs = options.terminationGraceMs ?? HOST_TASK_REPLAY_TERMINATION_GRACE_MS; + let exited = false; + const exitCode = child.exited.then((code) => { + exited = true; + return code; + }); + const collected = Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + exitCode, + ]); + let timeoutHandle: ReturnType | undefined; + const timeoutFailure = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + void (async () => { + try { + child.kill(15); + } catch { + // The process may have exited while an inherited stream stayed open. + } + await Promise.race([exitCode.then(() => undefined), delay(terminationGraceMs)]); + if (!exited) { + try { + child.kill(9); + } catch { + // A concurrent exit already satisfied the termination contract. + } + } + reject(new Error(`Codex task replay timed out after ${timeoutMs}ms.`)); + })(); + }, timeoutMs); + }); + try { + return await Promise.race([collected, timeoutFailure]); + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + } +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function normalizedType(value: unknown): string { + return typeof value === "string" ? value.replace(/[._]/g, "-").toLowerCase() : ""; +} + +function textParts(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.flatMap((part) => { + const item = record(part); + const text = item?.text; + return typeof text === "string" && text.trim() ? [text.trim()] : []; + }); +} + +export function parseCodexTaskReplayOutput(rawOutput: string): { + output: string; + sessionId: string | null; + inputTokens: number | null; + outputTokens: number | null; + runtimeError: string | null; +} { + const messages: string[] = []; + let sessionId: string | null = null; + let inputTokens: number | null = null; + let outputTokens: number | null = null; + let runtimeError: string | null = null; + for (const line of rawOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let event: Record; + try { + event = JSON.parse(trimmed) as Record; + } catch { + continue; + } + if (typeof event.thread_id === "string") sessionId = event.thread_id; + const eventType = normalizedType(event.type); + const item = record(event.item); + const itemType = normalizedType(item?.type ?? item?.item_type); + if (eventType === "item-completed" && itemType === "agent-message") { + if (typeof item?.text === "string" && item.text.trim()) messages.push(item.text.trim()); + messages.push(...textParts(item?.content)); + } + if (eventType === "response-item") { + const payload = record(event.payload); + if (normalizedType(payload?.type) === "message" && payload?.role === "assistant") { + messages.push(...textParts(payload.content)); + } + } + const usage = record(event.usage); + if (usage) { + if (typeof usage.input_tokens === "number") inputTokens = usage.input_tokens; + if (typeof usage.output_tokens === "number") outputTokens = usage.output_tokens; + } + if (typeof event.error === "string") runtimeError = event.error; + if (eventType === "turn-failed") { + const error = record(event.error); + if (typeof error?.message === "string") runtimeError = error.message; + } + } + return { + output: messages.at(-1) ?? "", + sessionId, + inputTokens, + outputTokens, + runtimeError, + }; +} + +/** Executes one bounded task with a staged skill arm; external mutation is forbidden. */ +export async function runCodexHostTaskReplay( + options: HostTaskReplayOptions, +): Promise { + if (options.fixture.platform !== "codex") { + throw new Error( + `Codex task replay requires a codex fixture, received ${options.fixture.platform}.`, + ); + } + const workspace = buildRuntimeReplayWorkspace( + options.fixture, + options.body, + options.contentTarget ?? "body", + options.includeTargetSkill, + ); + const prompt = [ + "Run an isolated execution-quality replay for the user request below.", + "Use a relevant local project skill if one is available.", + "Do not access a network, issue tracker, browser, or any path outside this temporary workspace.", + "Do not create, modify, or publish anything. Do not claim an external action succeeded.", + "Return only the user-facing draft response that should appear immediately before any external publishing action.", + `User request: ${options.task}`, + ].join("\n\n"); + const startedAt = Date.now(); + try { + const child = Bun.spawn( + [ + "codex", + "exec", + "--model", + options.model, + "--config", + `model_reasoning_effort="${options.reasoningEffort}"`, + "--json", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "-C", + workspace.rootDir, + prompt, + ], + { + cwd: workspace.rootDir, + stdout: "pipe", + stderr: "pipe", + env: { ...globalThis.process.env, CLAUDECODE: "" }, + }, + ); + const [stdout, stderr, exitCode] = await collectCodexTaskReplayProcess(child); + const parsed = parseCodexTaskReplayOutput(stdout); + const error = [parsed.runtimeError, stderr.trim()].filter(Boolean).join(" | "); + if (exitCode !== 0 || !parsed.output) { + throw new Error(error || `Codex task replay exited with code ${exitCode}.`); + } + return { + output: parsed.output, + raw_output: stdout, + session_id: parsed.sessionId, + duration_ms: Date.now() - startedAt, + input_tokens: parsed.inputTokens, + output_tokens: parsed.outputTokens, + }; + } finally { + cleanupRuntimeReplayWorkspace(workspace); + } +} diff --git a/packages/runtime/evolution/pareto.ts b/packages/runtime/evolution/pareto.ts index 57aa8606..70d227e9 100644 --- a/packages/runtime/evolution/pareto.ts +++ b/packages/runtime/evolution/pareto.ts @@ -172,6 +172,29 @@ export function getDominatedDimensions( // Pareto frontier // --------------------------------------------------------------------------- +/** + * Domain-neutral Pareto frontier primitive shared by description evolution and + * evidence-backed body search. Callers own their objective semantics; this + * function owns the bounded non-dominated-set algorithm. + */ +export function computeNonDominatedFrontier( + candidates: readonly T[], + candidateDominates: (left: T, right: T) => boolean, +): T[] { + const frontier: T[] = []; + + for (const candidate of candidates) { + if (frontier.some((member) => candidateDominates(member, candidate))) continue; + + for (let index = frontier.length - 1; index >= 0; index -= 1) { + if (candidateDominates(candidate, frontier[index])) frontier.splice(index, 1); + } + frontier.push(candidate); + } + + return frontier; +} + /** * Filter candidates to the Pareto frontier (non-dominated set). * Also sets `dominates_on` for each frontier member. @@ -180,44 +203,14 @@ export function getDominatedDimensions( * is used in dominance checks. */ export function computeParetoFrontier(candidates: ParetoCandidate[]): ParetoCandidate[] { - if (candidates.length === 0) return []; - - const frontier: ParetoCandidate[] = []; - - for (const candidate of candidates) { - // Check if any existing frontier member dominates this candidate - let isDominated = false; - for (const member of frontier) { - if ( - dominates( - member.invocation_scores, - candidate.invocation_scores, - member.token_efficiency_score, - candidate.token_efficiency_score, - ) - ) { - isDominated = true; - break; - } - } - - if (!isDominated) { - // Remove frontier members that this candidate dominates - for (let i = frontier.length - 1; i >= 0; i--) { - if ( - dominates( - candidate.invocation_scores, - frontier[i].invocation_scores, - candidate.token_efficiency_score, - frontier[i].token_efficiency_score, - ) - ) { - frontier.splice(i, 1); - } - } - frontier.push(candidate); - } - } + const frontier = computeNonDominatedFrontier(candidates, (left, right) => + dominates( + left.invocation_scores, + right.invocation_scores, + left.token_efficiency_score, + right.token_efficiency_score, + ), + ); // Set dominates_on for each frontier member (compared to others in frontier) for (const member of frontier) { @@ -295,7 +288,7 @@ export function selectFromFrontier(frontier: ParetoCandidate[]): { } // Sort by overall after_pass_rate descending, then by number of new_passes - const sorted = [...frontier].sort((a, b) => { + const sorted = frontier.toSorted((a, b) => { const rateDiff = b.validation.after_pass_rate - a.validation.after_pass_rate; if (Math.abs(rateDiff) > 0.001) return rateDiff; return b.validation.new_passes.length - a.validation.new_passes.length; diff --git a/packages/runtime/evolution/validate-host-replay.ts b/packages/runtime/evolution/validate-host-replay.ts index 5b24ff70..331ddb6d 100644 --- a/packages/runtime/evolution/validate-host-replay.ts +++ b/packages/runtime/evolution/validate-host-replay.ts @@ -3,7 +3,19 @@ export type { RuntimeReplayInvoker, RuntimeReplayInvokerInput, RuntimeReplayObservation, + RuntimeReplayReasoningEffort, } from "./validate-host-replay/contracts.js"; +export { + collectCodexTaskReplayProcess, + HOST_TASK_REPLAY_TERMINATION_GRACE_MS, + HOST_TASK_REPLAY_TIMEOUT_MS, + parseCodexTaskReplayOutput, + runCodexHostTaskReplay, + type CodexTaskReplayProcess, + type CollectCodexTaskReplayProcessOptions, + type HostTaskReplayOptions, + type HostTaskReplayResult, +} from "./host-task-replay.js"; export { extractClaudeRuntimeReplayMetrics, parseCodexRuntimeReplayOutput, @@ -17,5 +29,7 @@ export { } from "./validate-host-replay/runner.js"; export { buildRoutingReplayFixture, + buildRuntimeReplayWorkspace, + cleanupRuntimeReplayWorkspace, resolveRuntimeReplayPlatform, } from "./validate-host-replay/workspace.js"; diff --git a/packages/runtime/evolution/validate-host-replay/contracts.ts b/packages/runtime/evolution/validate-host-replay/contracts.ts index 6171cf6a..7bae2517 100644 --- a/packages/runtime/evolution/validate-host-replay/contracts.ts +++ b/packages/runtime/evolution/validate-host-replay/contracts.ts @@ -11,6 +11,8 @@ export interface ReplayWorkspace { export type RuntimeReplayContentTarget = "routing" | "description" | "body"; +export type RuntimeReplayReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; + export interface RuntimeReplayInvokerInput { query: string; platform: RoutingReplayFixture["platform"]; @@ -19,6 +21,8 @@ export interface RuntimeReplayInvokerInput { targetSkillName: string; targetSkillPath: string; competingSkillPaths: string[]; + model?: string; + reasoningEffort?: RuntimeReplayReasoningEffort; } export interface RuntimeReplayObservation { diff --git a/packages/runtime/evolution/validate-host-replay/runner.ts b/packages/runtime/evolution/validate-host-replay/runner.ts index 2ac9514c..fde4c801 100644 --- a/packages/runtime/evolution/validate-host-replay/runner.ts +++ b/packages/runtime/evolution/validate-host-replay/runner.ts @@ -26,6 +26,7 @@ import type { RuntimeReplayInvoker, RuntimeReplayInvokerInput, RuntimeReplayObservation, + RuntimeReplayReasoningEffort, } from "./contracts.js"; import { buildKnownSkillNames, @@ -132,6 +133,10 @@ async function invokeCodexRuntimeReplay( const command = [ "codex", "exec", + ...(input.model ? ["--model", input.model] : []), + ...(input.reasoningEffort + ? ["--config", `model_reasoning_effort="${input.reasoningEffort}"`] + : []), "--json", "--skip-git-repo-check", "--sandbox", @@ -519,6 +524,8 @@ export async function runHostRuntimeReplayFixture(options: { contentTarget?: RuntimeReplayContentTarget; includeTargetSkill?: boolean; runtimeInvoker?: RuntimeReplayInvoker; + model?: string; + reasoningEffort?: RuntimeReplayReasoningEffort; }): Promise { const invokeRuntime = options.runtimeInvoker ?? getDefaultRuntimeReplayInvoker(options.fixture.platform); @@ -556,6 +563,8 @@ export async function runHostRuntimeReplayFixture(options: { targetSkillName: options.fixture.target_skill_name, targetSkillPath: workspace.targetSkillPath, competingSkillPaths: workspace.competingSkillPaths, + ...(options.model ? { model: options.model } : {}), + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), }); const result = evaluateRuntimeReplayObservation( entry, diff --git a/packages/runtime/license-draft.test.ts b/packages/runtime/license-draft.test.ts new file mode 100644 index 00000000..b8df9528 --- /dev/null +++ b/packages/runtime/license-draft.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { applyLicenseDraft, previewLicenseDraft } from "./license-draft"; + +const roots: string[] = []; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "selftune-license-draft-")); + roots.push(root); + writeFileSync( + join(root, "SKILL.md"), + "---\nname: coach-notes\ndescription: Complete coach notes.\n---\n\n# Coach Notes\n", + ); + return root; +} + +const terms = { + copyrightHolder: "Daniel Petro", + licensedOrganization: "Ithraa Center", + year: 2026, +}; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("license draft review boundary", () => { + test("previews SKILL.md and LICENSE without writing", () => { + const root = fixture(); + const before = readFileSync(join(root, "SKILL.md"), "utf8"); + + const preview = previewLicenseDraft(root, terms); + + expect(preview.licenseExpression).toBe("LicenseRef-Ithraa-Center-Proprietary"); + expect(preview.files.map((file) => file.path)).toEqual(["SKILL.md", "LICENSE"]); + expect(preview.files[0]?.patch).toContain("+license: LicenseRef-Ithraa-Center-Proprietary"); + expect(preview.files[1]?.patch).toContain( + "+Permission is granted exclusively to Ithraa Center", + ); + expect(readFileSync(join(root, "SKILL.md"), "utf8")).toBe(before); + expect(existsSync(join(root, "LICENSE"))).toBe(false); + }); + + test("applies exactly the reviewed draft", () => { + const root = fixture(); + const preview = previewLicenseDraft(root, terms); + + applyLicenseDraft({ skillPath: root, previewId: preview.previewId, terms }); + + expect(readFileSync(join(root, "SKILL.md"), "utf8")).toContain( + "license: LicenseRef-Ithraa-Center-Proprietary", + ); + expect(readFileSync(join(root, "LICENSE"), "utf8")).toContain("Ithraa Center"); + }); + + test("rejects a stale preview without writing a license", () => { + const root = fixture(); + const preview = previewLicenseDraft(root, terms); + writeFileSync( + join(root, "SKILL.md"), + `${readFileSync(join(root, "SKILL.md"), "utf8")}\nchanged\n`, + ); + + expect(() => + applyLicenseDraft({ skillPath: root, previewId: preview.previewId, terms }), + ).toThrow("changed after this draft was reviewed"); + expect(existsSync(join(root, "LICENSE"))).toBe(false); + }); +}); diff --git a/packages/runtime/license-draft.ts b/packages/runtime/license-draft.ts new file mode 100644 index 00000000..957cf57f --- /dev/null +++ b/packages/runtime/license-draft.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { basename, dirname, join, resolve } from "node:path"; + +export interface LicenseDraftTerms { + copyrightHolder: string; + licensedOrganization: string; + year: number; +} + +export interface LicenseDraftPreview { + previewId: string; + skillPath: string; + licenseExpression: string; + files: ReadonlyArray<{ path: "SKILL.md" | "LICENSE"; patch: string }>; +} + +function required(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (/\r|\n/.test(normalized)) throw new Error(`${label} must be one line.`); + return normalized; +} + +function licenseRef(organization: string): string { + const slug = organization + .normalize("NFKD") + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!slug) throw new Error("Licensed organization must contain letters or numbers."); + return `LicenseRef-${slug}-Proprietary`; +} + +function addLicenseFrontmatter(content: string, expression: string): string { + const lines = content.split(/\r?\n/); + if (lines[0] !== "---") + throw new Error("SKILL.md needs YAML frontmatter before a license can be drafted."); + const end = lines.indexOf("---", 1); + if (end < 0) throw new Error("SKILL.md has unterminated YAML frontmatter."); + if (lines.slice(1, end).some((line) => /^license\s*:/.test(line))) { + throw new Error("SKILL.md already declares a license."); + } + lines.splice(end, 0, `license: ${expression}`); + return lines.join("\n"); +} + +function draftTerms(terms: LicenseDraftTerms): string { + const holder = required(terms.copyrightHolder, "Copyright holder"); + const organization = required(terms.licensedOrganization, "Licensed organization"); + if (!Number.isInteger(terms.year) || terms.year < 1900 || terms.year > 2200) { + throw new Error("Copyright year is invalid."); + } + return `PROPRIETARY LICENSE\n\nCopyright (c) ${terms.year} ${holder}. All rights reserved.\n\nPermission is granted exclusively to ${organization} and its authorized personnel to use, copy, and modify this skill for the organization's internal operations. Private distribution within ${organization} is permitted only to authorized personnel.\n\nExternal redistribution, sublicensing, publication, sale, or disclosure to third parties is prohibited without prior written permission from ${holder}.\n\nThis license covers the skill instructions, scripts, templates, and bundled assets only. It does not grant permission to disclose participant, client, session, or other confidential data processed with the skill.\n\nTHE SKILL IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.\n`; +} + +function unifiedPatch(path: string, before: string | null, after: string): string { + const oldLines = before?.split("\n") ?? []; + const newLines = after.split("\n"); + const oldPath = before === null ? "/dev/null" : `a/${path}`; + return [ + `diff --git a/${path} b/${path}`, + `--- ${oldPath}`, + `+++ b/${path}`, + `@@ -${before === null ? "0,0" : `1,${oldLines.length}`} +1,${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ].join("\n"); +} + +export function previewLicenseDraft( + skillPath: string, + terms: LicenseDraftTerms, +): LicenseDraftPreview { + const resolvedSkillPath = resolve(skillPath); + const skillFile = join(resolvedSkillPath, "SKILL.md"); + const licenseFile = join(resolvedSkillPath, "LICENSE"); + if (!existsSync(skillFile)) throw new Error(`SKILL.md was not found for ${basename(skillPath)}.`); + if (existsSync(licenseFile)) throw new Error("This skill already bundles a LICENSE file."); + const before = readFileSync(skillFile, "utf8"); + const expression = licenseRef(required(terms.licensedOrganization, "Licensed organization")); + const skillAfter = addLicenseFrontmatter(before, expression); + const licenseAfter = draftTerms(terms); + const previewId = createHash("sha256") + .update(resolvedSkillPath) + .update("\0") + .update(before) + .update("\0") + .update(skillAfter) + .update("\0") + .update(licenseAfter) + .digest("hex"); + return { + previewId, + skillPath: resolvedSkillPath, + licenseExpression: expression, + files: [ + { path: "SKILL.md", patch: unifiedPatch("SKILL.md", before, skillAfter) }, + { path: "LICENSE", patch: unifiedPatch("LICENSE", null, licenseAfter) }, + ], + }; +} + +export function applyLicenseDraft(input: { + skillPath: string; + previewId: string; + terms: LicenseDraftTerms; +}): LicenseDraftPreview { + const preview = previewLicenseDraft(input.skillPath, input.terms); + if (preview.previewId !== input.previewId) { + throw new Error( + "The skill changed after this draft was reviewed. Preview it again before applying.", + ); + } + const skillFile = join(preview.skillPath, "SKILL.md"); + const licenseFile = join(preview.skillPath, "LICENSE"); + const originalSkill = readFileSync(skillFile, "utf8"); + const nextSkill = addLicenseFrontmatter(originalSkill, preview.licenseExpression); + const nextLicense = draftTerms(input.terms); + const nonce = `${process.pid}-${Date.now()}`; + const skillTemp = join(dirname(skillFile), `.SKILL.md.${nonce}.tmp`); + const licenseTemp = join(dirname(licenseFile), `.LICENSE.${nonce}.tmp`); + try { + writeFileSync(skillTemp, nextSkill, { encoding: "utf8", flag: "wx", mode: 0o600 }); + writeFileSync(licenseTemp, nextLicense, { encoding: "utf8", flag: "wx", mode: 0o600 }); + renameSync(licenseTemp, licenseFile); + try { + renameSync(skillTemp, skillFile); + } catch (cause) { + rmSync(licenseFile, { force: true }); + throw cause; + } + return preview; + } finally { + rmSync(skillTemp, { force: true }); + rmSync(licenseTemp, { force: true }); + } +} diff --git a/packages/runtime/localdb/queries.ts b/packages/runtime/localdb/queries.ts index 0f184df9..199fad39 100644 --- a/packages/runtime/localdb/queries.ts +++ b/packages/runtime/localdb/queries.ts @@ -46,10 +46,12 @@ export { export { getAttentionQueue, getRecentDecisions, + getTrayAttentionSummary, getSkillTrustSummaries, iterateTrustedSkillObservationRows, queryTrustedSkillObservationRows, type SkillTrustSummary, + type TrayAttentionSummary, type TrustedSkillObservationRow, } from "./queries/trust.js"; export { safeParseJson, safeParseJsonArray } from "./queries/json.js"; diff --git a/packages/runtime/localdb/queries/trust.ts b/packages/runtime/localdb/queries/trust.ts index 7faf8179..1b840240 100644 --- a/packages/runtime/localdb/queries/trust.ts +++ b/packages/runtime/localdb/queries/trust.ts @@ -19,6 +19,14 @@ export interface SkillTrustSummary { last_seen: string | null; } +export interface TrayAttentionSummary { + skillsObserved: number; + pendingReviews: number; + attentionRequired: number; + hasCritical: boolean; + criticalCount: number; +} + export interface TrustedSkillObservationRow { skill_name: string; skill_path: string | null; @@ -362,6 +370,86 @@ export function getAttentionQueue(db: Database): AttentionItem[] { return items; } +export function getTrayAttentionSummary(db: Database): TrayAttentionSummary { + // The tray polls every 30 seconds. Keep this summary on indexed scalar fields; + // normalized prompt materialization belongs to the full overview and trust reports. + const summaries = db + .query< + { + skill_name: string; + total_checks: number; + triggered_count: number; + }, + [string] + >( + `SELECT + skill_name, + COUNT(*) AS total_checks, + SUM(CASE WHEN triggered = 1 THEN 1 ELSE 0 END) AS triggered_count + FROM skill_invocations + WHERE instr(skill_invocation_id, ?) = 0 + GROUP BY skill_name`, + ) + .all(":su:"); + const latestActions = new Map( + db + .query<{ skill_name: string; action: string }, []>( + `WITH ranked AS ( + SELECT + skill_name, + action, + ROW_NUMBER() OVER ( + PARTITION BY skill_name + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM evolution_audit + WHERE skill_name IS NOT NULL + ) + SELECT skill_name, action + FROM ranked + WHERE rank = 1`, + ) + .all() + .map((row) => [row.skill_name, row.action] as const), + ); + const pendingSkills = new Set( + getPendingProposals(db) + .map((proposal) => proposal.skill_name) + .filter((skillName): skillName is string => skillName != null), + ); + + let pendingReviews = 0; + let attentionRequired = 0; + let hasCritical = false; + let criticalCount = 0; + for (const summary of summaries) { + if (latestActions.get(summary.skill_name) === "rolled_back") { + pendingReviews++; + attentionRequired++; + hasCritical = true; + criticalCount++; + continue; + } + if (pendingSkills.has(summary.skill_name)) { + pendingReviews++; + attentionRequired++; + continue; + } + const misses = summary.total_checks - summary.triggered_count; + if (summary.total_checks >= 5 && misses / summary.total_checks > 0.1) { + attentionRequired++; + } + } + + return { + skillsObserved: summaries.length, + pendingReviews, + attentionRequired, + hasCritical, + criticalCount, + }; +} + export function getRecentDecisions(db: Database, limit = 20): AutonomousDecision[] { const rows = db .query( diff --git a/packages/runtime/normalization.ts b/packages/runtime/normalization.ts index 400f01c1..ec31b87f 100644 --- a/packages/runtime/normalization.ts +++ b/packages/runtime/normalization.ts @@ -412,6 +412,9 @@ const META_PREFIXES = [ "CONTEXT:", "Base directory for this skill:", "USER'S CURRENT MESSAGE (summarize THIS):", + "The following is the Codex agent history whose request action you are assessing.", + "# AGENTS.md instructions", + "", ]; const CONTINUATION_PREFIXES = [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 50b50e4c..892ebd66 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/runtime", - "version": "1.0.1", + "version": "1.0.0", "private": true, "description": "Local-first SelfTune skill lifecycle runtime", "license": "MIT", @@ -21,7 +21,7 @@ "scripts": { "db:embed": "bun run --cwd ../local-store db:embed", "db:generate": "bun run --cwd ../local-store db:generate", - "test": "bun test ../../tests ../../packages/telemetry-contract --max-concurrency=1 --path-ignore-patterns='**/tests/dashboard/**' --path-ignore-patterns='**/tests/runtime/**' --path-ignore-patterns='**/tests/hooks/**' --path-ignore-patterns='**/tests/ingestors/**' --path-ignore-patterns='**/tests/cron/**' --path-ignore-patterns='**/tests/orchestrate/**' --path-ignore-patterns='**/tests/repair/**' --path-ignore-patterns='**/tests/orchestrate.test.ts' --path-ignore-patterns='**/tests/orchestrate-overlap.test.ts' --path-ignore-patterns='**/tests/sync.test.ts' --path-ignore-patterns='**/tests/search-run.test.ts' --path-ignore-patterns='**/tests/improve.test.ts' --path-ignore-patterns='**/tests/canonical-export.test.ts' --path-ignore-patterns='**/tests/init/codex-install.test.ts' --path-ignore-patterns='**/tests/init/opencode-install.test.ts'", + "test": "bun test ../../tests ../../packages/telemetry-contract --max-concurrency=2 --timeout=15000 --path-ignore-patterns='**/tests/dashboard/**' --path-ignore-patterns='**/tests/runtime/**' --path-ignore-patterns='**/tests/hooks/**' --path-ignore-patterns='**/tests/ingestors/**' --path-ignore-patterns='**/tests/cron/**' --path-ignore-patterns='**/tests/orchestrate/**' --path-ignore-patterns='**/tests/repair/**' --path-ignore-patterns='**/tests/orchestrate.test.ts' --path-ignore-patterns='**/tests/orchestrate-overlap.test.ts' --path-ignore-patterns='**/tests/sync.test.ts' --path-ignore-patterns='**/tests/search-run.test.ts' --path-ignore-patterns='**/tests/improve.test.ts' --path-ignore-patterns='**/tests/canonical-export.test.ts' --path-ignore-patterns='**/tests/init/codex-install.test.ts' --path-ignore-patterns='**/tests/init/opencode-install.test.ts'", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/runtime/portable-feedback-helper.ts b/packages/runtime/portable-feedback-helper.ts index c5d0b08f..91ae0b90 100644 --- a/packages/runtime/portable-feedback-helper.ts +++ b/packages/runtime/portable-feedback-helper.ts @@ -228,7 +228,7 @@ function buildPayload(consent) { skill_name: manifest.skill_name, relay_destination: manifest.creator_id, skill_hash: skillHash(manifest.skill_name), - user_cohort: \`uc_sha256_\${sha(\`\${consent.install_id}:\${monthBucket(now)}\`).slice(0, 12)}\`, + user_cohort: \`uc_sha256_\${sha(\`\${consent.install_id}:\${manifest.creator_id}:\${monthBucket(now)}\`).slice(0, 12)}\`, signals, timestamp_bucket: bucketWeek(now), client_version: HELPER_VERSION, diff --git a/packages/runtime/registry/automatic-suggestions.ts b/packages/runtime/registry/automatic-suggestions.ts new file mode 100644 index 00000000..7cb8875b --- /dev/null +++ b/packages/runtime/registry/automatic-suggestions.ts @@ -0,0 +1,343 @@ +import { Effect, Result, Schedule } from "effect"; + +import { + RegistryAuthenticationError, + RegistryConfigError, + RegistryHttpError, + registryRequest, +} from "./client.js"; +import { + RegistryContributionMutationResponse, + RegistryContributionSnapshotResponse, + RegistryDetailResponse, +} from "./contracts.js"; +import { RegistryPlatform, type PreparedRegistryPush } from "./platform.js"; +import { flushRegistryOutbox } from "./registry-outbox.js"; +import { operationError, RegistryOperationError } from "./program-types.js"; +import type { RegistryStateEntry } from "./registry-state.js"; +import { + commitRegistryState, + keepRegistryState, + registryStateEntriesMatch, + upsertRegistryStateEntry, +} from "./registry-state-store.js"; + +const DEFAULT_SCAN_INTERVAL_MS = 15_000; +const DEFAULT_STABLE_FOR_MS = 5_000; +const DEFAULT_RETRY_BASE_MS = 30_000; +const DEFAULT_RETRY_MAX_MS = 60 * 60 * 1_000; +const DEFAULT_BLOCKED_RETRY_MS = 15 * 60 * 1_000; + +export interface AutomaticRegistrySuggestionOptions { + readonly now?: () => number; + readonly scanIntervalMs?: number; + readonly stableForMs?: number; + readonly retryBaseMs?: number; + readonly retryMaxMs?: number; + readonly blockedRetryMs?: number; +} + +export interface AutomaticRegistrySuggestionScanResult { + readonly managed: number; + readonly armed: number; + readonly submitted: number; + readonly deferred: number; + readonly failed: number; +} + +interface SubmittedSuggestion { + readonly id: string; + readonly status: "pending" | "rejected" | "adopted" | "stale" | "rolled_back"; +} + +function retryDelay(attempt: number, baseMs: number, maximumMs: number): number { + return Math.min(maximumMs, baseMs * 2 ** Math.min(attempt, 10)); +} + +function candidateVersion(baseVersion: string | undefined, observedHash: string): string { + const suffix = `.team.${observedHash.slice(0, 12)}`; + const prefix = (baseVersion || "0.1.0").slice(0, Math.max(1, 50 - suffix.length)); + return `${prefix}${suffix}`; +} + +function isTerminalFailure(cause: unknown): boolean { + if (cause instanceof RegistryAuthenticationError || cause instanceof RegistryConfigError) { + return true; + } + if (cause instanceof RegistryOperationError && cause.operation === "automatic-suggestion") { + return true; + } + return ( + cause instanceof RegistryHttpError && + cause.status >= 400 && + cause.status < 500 && + cause.status !== 408 && + cause.status !== 425 && + cause.status !== 429 + ); +} + +function failureCode(cause: unknown): string { + if (cause instanceof RegistryHttpError) return `http_${cause.status}`; + if (cause instanceof RegistryAuthenticationError) return "authentication"; + if (cause instanceof RegistryConfigError) return "configuration"; + if (cause instanceof RegistryOperationError) return cause.operation.replaceAll("-", "_"); + return "transport"; +} + +function automaticSummary(name: string): string { + return `Automatically captured teammate edits to ${name}`; +} + +function clearPendingSuggestion(entry: RegistryStateEntry): RegistryStateEntry { + const { automaticSuggestion: _automaticSuggestion, ...remaining } = entry; + return remaining; +} + +const submitPreparedSuggestion = Effect.fn("selftune.registry.automaticSuggestions.submit")( + function* (entry: RegistryStateEntry, baseVersionId: string, prepared: PreparedRegistryPush) { + const formData = new FormData(); + formData.append( + "metadata", + JSON.stringify({ + baseVersionId, + candidateVersion: prepared.version, + candidateContentHash: prepared.archiveHash, + summary: automaticSummary(entry.name), + files: prepared.manifest, + }), + ); + formData.append( + "archive", + new Blob([Uint8Array.from(prepared.archiveBuffer)], { type: "application/gzip" }), + `${prepared.name}.tar.gz`, + ); + + const submission = yield* registryRequest(RegistryContributionMutationResponse, { + method: "POST", + path: `/api/v1/collaboration/registry/${encodeURIComponent(entry.entryId)}/contributions`, + formData, + }).pipe(Effect.result); + if (Result.isSuccess(submission)) return submission.success satisfies SubmittedSuggestion; + + // The archive is deterministic. If the request committed but its response was lost, or a + // previous process crashed before recording its receipt, reconcile the existing candidate + // instead of producing another review item. + const snapshot = yield* registryRequest(RegistryContributionSnapshotResponse, { + method: "GET", + path: "/api/v1/collaboration", + }).pipe(Effect.result); + if (Result.isSuccess(snapshot)) { + const existing = snapshot.success.contributions.find( + (candidate) => + candidate.entryId === entry.entryId && + candidate.baseVersionId === baseVersionId && + candidate.candidateContentHash === prepared.archiveHash && + candidate.status !== "rolled_back", + ); + if (existing) + return { id: existing.id, status: existing.status } satisfies SubmittedSuggestion; + } + return yield* submission.failure; + }, +); + +const updateEntry = Effect.fn("selftune.registry.automaticSuggestions.updateState")(function* ( + expected: RegistryStateEntry, + update: (current: RegistryStateEntry) => RegistryStateEntry, +) { + const platform = yield* RegistryPlatform; + yield* flushRegistryOutbox().pipe(Effect.ignore); + return yield* platform.withStateTransaction((latest) => { + const current = latest.find((entry) => entry.entryId === expected.entryId); + if (!registryStateEntriesMatch(current, expected) || !current) { + return Effect.succeed(keepRegistryState(false)); + } + return Effect.succeed( + commitRegistryState(upsertRegistryStateEntry(latest, update(current)), true), + ); + }); +}); + +const scanEntry = Effect.fn("selftune.registry.automaticSuggestions.scanEntry")(function* ( + entry: RegistryStateEntry, + options: Required>, +) { + const platform = yield* RegistryPlatform; + const target = yield* platform.validatePersistedTarget(entry.installPath, entry.name); + const observedContentHash = yield* platform.computeInstalledContentHash(target.targetDir); + + // Old state predating content receipts remains untrusted. Only a fresh Registry install can + // establish its exact clean baseline; relabeling current bytes would let rollout overwrite + // unknown local edits later. + if (!entry.localContentHash) { + return "deferred"; + } + + if (observedContentHash === entry.localContentHash) { + if (entry.automaticSuggestion) { + yield* updateEntry(entry, clearPendingSuggestion); + } + return "unchanged"; + } + if (entry.lastSuggestion?.observedContentHash === observedContentHash) return "unchanged"; + + const now = options.now(); + const pending = entry.automaticSuggestion; + if ( + !pending || + pending.observedContentHash !== observedContentHash || + pending.baseVersionHash !== entry.versionHash || + pending.baseVersionId !== entry.versionId + ) { + yield* updateEntry(entry, (current) => ({ + ...current, + automaticSuggestion: { + observedContentHash, + baseVersionHash: current.versionHash, + ...(current.versionId ? { baseVersionId: current.versionId } : {}), + stableAt: now + options.stableForMs, + attemptCount: 0, + nextAttemptAt: now + options.stableForMs, + }, + })); + return "armed"; + } + if (now < pending.stableAt || now < pending.nextAttemptAt) return "deferred"; + + let baseVersionId = pending.baseVersionId; + let baseVersion = entry.version; + if (!baseVersionId) { + const detail = yield* registryRequest(RegistryDetailResponse, { + method: "GET", + path: `/${encodeURIComponent(entry.entryId)}`, + }); + const base = detail.versions.find( + (version) => version.content_hash === pending.baseVersionHash, + ); + if (!base) { + return yield* operationError( + "automatic-suggestion", + new Error("The installed Registry base revision is unavailable"), + ); + } + baseVersionId = base.id; + baseVersion = base.version; + } + + const prepared = yield* platform.preparePackage(target.targetDir, { + operation: "suggest", + name: entry.name, + version: candidateVersion(baseVersion, observedContentHash), + summary: automaticSummary(entry.name), + }); + if (!prepared) { + return yield* operationError( + "automatic-suggestion", + new Error("The managed Registry skill no longer contains SKILL.md"), + ); + } + + const afterPackagingHash = yield* platform.computeInstalledContentHash(target.targetDir); + if (afterPackagingHash !== observedContentHash) { + yield* updateEntry(entry, (current) => ({ + ...current, + automaticSuggestion: { + observedContentHash: afterPackagingHash, + baseVersionHash: current.versionHash, + ...(current.versionId ? { baseVersionId: current.versionId } : {}), + stableAt: now + options.stableForMs, + attemptCount: 0, + nextAttemptAt: now + options.stableForMs, + }, + })); + return "armed"; + } + + const submitted = yield* submitPreparedSuggestion(entry, baseVersionId, prepared); + const recorded = yield* updateEntry(entry, (current) => ({ + ...clearPendingSuggestion(current), + lastSuggestion: { + observedContentHash, + candidateContentHash: prepared.archiveHash, + baseVersionHash: pending.baseVersionHash, + baseVersionId, + contributionId: submitted.id, + submittedAt: new Date(now).toISOString(), + }, + })); + return recorded ? "submitted" : "deferred"; +}); + +export const runAutomaticRegistrySuggestionScan = Effect.fn( + "selftune.registry.automaticSuggestions.scan", +)(function* (input: AutomaticRegistrySuggestionOptions = {}) { + const platform = yield* RegistryPlatform; + const options = { + now: input.now ?? Date.now, + stableForMs: input.stableForMs ?? DEFAULT_STABLE_FOR_MS, + retryBaseMs: input.retryBaseMs ?? DEFAULT_RETRY_BASE_MS, + retryMaxMs: input.retryMaxMs ?? DEFAULT_RETRY_MAX_MS, + blockedRetryMs: input.blockedRetryMs ?? DEFAULT_BLOCKED_RETRY_MS, + }; + const state = yield* platform.loadState(); + const result = { + managed: state.length, + armed: 0, + submitted: 0, + deferred: 0, + failed: 0, + } satisfies AutomaticRegistrySuggestionScanResult; + + for (const entry of state) { + const outcome = yield* scanEntry(entry, options).pipe(Effect.result); + if (Result.isSuccess(outcome)) { + if (outcome.success === "armed") result.armed += 1; + if (outcome.success === "submitted") result.submitted += 1; + if (outcome.success === "deferred") result.deferred += 1; + continue; + } + result.failed += 1; + const pending = entry.automaticSuggestion; + if (!pending) continue; + const attemptCount = pending.attemptCount + 1; + const blocked = isTerminalFailure(outcome.failure); + const nextAttemptAt = blocked + ? options.now() + options.blockedRetryMs + : options.now() + retryDelay(attemptCount - 1, options.retryBaseMs, options.retryMaxMs); + yield* updateEntry(entry, (current) => ({ + ...current, + automaticSuggestion: { + ...pending, + attemptCount, + nextAttemptAt, + lastFailure: { + kind: blocked ? "blocked" : "retrying", + code: failureCode(outcome.failure), + at: options.now(), + }, + }, + })).pipe(Effect.ignore); + if (blocked) { + yield* Effect.logWarning( + `Automatic team suggestion for '${entry.name}' paused until ${new Date(nextAttemptAt).toISOString()} (${failureCode(outcome.failure)}).`, + ); + } + } + return result; +}); + +export const runAutomaticRegistrySuggestions = Effect.fn( + "selftune.registry.automaticSuggestions.worker", +)(function* (options: AutomaticRegistrySuggestionOptions = {}) { + const interval = options.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS; + const resilientScan = runAutomaticRegistrySuggestionScan(options).pipe( + Effect.catch((cause) => + Effect.logWarning( + `Automatic team suggestion scan failed and will retry: ${cause.message}`, + ).pipe(Effect.as({ managed: 0, armed: 0, submitted: 0, deferred: 0, failed: 1 })), + ), + ); + yield* resilientScan; + return yield* Effect.repeat(resilientScan, Schedule.spaced(interval)); +}); diff --git a/packages/runtime/registry/client.ts b/packages/runtime/registry/client.ts index fcdb0eab..55e3f9a5 100644 --- a/packages/runtime/registry/client.ts +++ b/packages/runtime/registry/client.ts @@ -319,7 +319,9 @@ function makeRegistryUrl(apiUrl: string, requestPath: string): string { const path = queryIndex === -1 ? requestPath : requestPath.slice(0, queryIndex); const search = queryIndex === -1 ? "" : requestPath.slice(queryIndex + 1); const basePath = base.pathname.replace(/\/+$/, ""); - base.pathname = `${basePath}${REGISTRY_API_PATH}${path}`; + base.pathname = path.startsWith("/api/") + ? `${basePath}${path}` + : `${basePath}${REGISTRY_API_PATH}${path}`; base.search = search; return base.toString(); } diff --git a/packages/runtime/registry/contracts.ts b/packages/runtime/registry/contracts.ts index a41a089f..4c10409d 100644 --- a/packages/runtime/registry/contracts.ts +++ b/packages/runtime/registry/contracts.ts @@ -49,9 +49,13 @@ export const RegistryStatusResponse = Schema.Struct({ name: Schema.String, has_update: Schema.Boolean, latest_version: Schema.String, + latest_version_id: Schema.optionalKey(Schema.String), current_version: Schema.String, + current_version_id: Schema.optionalKey(Schema.NullOr(Schema.String)), latest_content_hash: Schema.optionalKey(Schema.String), download_url: Schema.optionalKey(Schema.String), + rollout_policy: Schema.optionalKey(Schema.Literals(["manual", "notify", "automatic"])), + automatic_update_allowed: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -63,9 +67,13 @@ export const RegistrySyncResponse = Schema.Struct({ name: Schema.String, has_update: Schema.Boolean, latest_version: Schema.String, + latest_version_id: Schema.optionalKey(Schema.String), latest_content_hash: Schema.String, current_version: Schema.optionalKey(Schema.String), + current_version_id: Schema.optionalKey(Schema.NullOr(Schema.String)), download_url: Schema.optionalKey(Schema.String), + rollout_policy: Schema.optionalKey(Schema.Literals(["manual", "notify", "automatic"])), + automatic_update_allowed: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -96,3 +104,25 @@ export const RegistryHistoryResponse = Schema.Struct({ export const RegistryMutationResponse = Schema.Record(Schema.String, Schema.Unknown); export const EmptyRegistryResponse = Schema.Record(Schema.String, Schema.Unknown); + +export const RegistryInstallationMutationResponse = Schema.Struct({ + data: Schema.Struct({ id: Schema.String }), +}); + +export const RegistryContributionMutationResponse = Schema.Struct({ + id: Schema.String, + status: Schema.Literals(["pending", "rejected", "adopted", "stale", "rolled_back"]), + deduplicated: Schema.optionalKey(Schema.Boolean), +}); + +export const RegistryContributionSnapshotResponse = Schema.Struct({ + contributions: Schema.Array( + Schema.Struct({ + id: Schema.String, + entryId: Schema.String, + baseVersionId: Schema.String, + candidateContentHash: Schema.String, + status: Schema.Literals(["pending", "rejected", "adopted", "stale", "rolled_back"]), + }), + ), +}); diff --git a/packages/runtime/registry/install-utils.ts b/packages/runtime/registry/install-utils.ts index 9395f867..0a07c32d 100644 --- a/packages/runtime/registry/install-utils.ts +++ b/packages/runtime/registry/install-utils.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { gunzipSync } from "node:zlib"; @@ -310,6 +311,29 @@ async function extractArchive(archivePath: string, targetDir: string): Promise( + options: { + archiveBuffer: Buffer; + expectedHash: string; + label?: string; + }, + inspect: (directory: string) => Promise, +): Promise { + verifyArchiveHash(options.archiveBuffer, options.expectedHash, options.label); + assertSafeArchive(options.archiveBuffer); + const tempRoot = await mkdtemp(path.join(tmpdir(), "selftune-registry-inspect-")); + const stagedDir = path.join(tempRoot, "skill"); + const archivePath = path.join(tempRoot, "skill.tar.gz"); + try { + await mkdir(stagedDir, { recursive: true }); + await writeFile(archivePath, options.archiveBuffer); + await extractArchive(archivePath, stagedDir); + return await inspect(stagedDir); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + export async function installRegistryArchive(options: { archiveBuffer: Buffer; expectedHash: string; diff --git a/packages/runtime/registry/install.ts b/packages/runtime/registry/install.ts index 00053cce..180fd6a3 100644 --- a/packages/runtime/registry/install.ts +++ b/packages/runtime/registry/install.ts @@ -1,8 +1,9 @@ +import { randomUUID } from "node:crypto"; + import { Effect, Result } from "effect"; import { RegistryClient, registryRequest } from "./client.js"; import { - EmptyRegistryResponse, RegistryDetailResponse, RegistryInstallLookupResponse, RegistryInstallSyncResponse, @@ -10,6 +11,7 @@ import { import { parseGithubRegistryInstallTarget } from "./github-install.js"; import { validateRegistrySkillName, validateRegistryVersion } from "./path-policy.js"; import { RegistryPlatform } from "./platform.js"; +import { flushRegistryOutbox } from "./registry-outbox.js"; import { validate } from "./program-support.js"; import { failure, @@ -107,18 +109,13 @@ export const runRegistryInstall = Effect.fn("selftune.registry.install")(functio const archive = yield* client.download(downloadUrl).pipe(Effect.result); if (Result.isFailure(archive)) return failureWithProgress(progress, archive.failure.message); const expectedStateEntry = preflightState.find((item) => item.entryId === entry.id); + const registrationReceiptId = randomUUID(); const installed = yield* platform .withStateTransaction((latest) => { const latestEntry = latest.find((item) => item.entryId === entry.id); if (!registryStateEntriesMatch(latestEntry, expectedStateEntry)) { return Effect.succeed(keepRegistryState(false)); } - const nextEntry = { - entryId: entry.id, - name: skillName, - versionHash: current.content_hash, - installPath: target.targetDir, - }; return platform .installArchive({ archive: archive.success, @@ -128,7 +125,29 @@ export const runRegistryInstall = Effect.fn("selftune.registry.install")(functio version, label: `${skillName} v${version}`, }) - .pipe(Effect.as(commitRegistryState(upsertRegistryStateEntry(latest, nextEntry), true))); + .pipe( + Effect.andThen(platform.computeInstalledContentHash(target.targetDir)), + Effect.map((localContentHash) => + commitRegistryState( + upsertRegistryStateEntry(latest, { + entryId: entry.id, + name: skillName, + versionHash: current.content_hash, + version, + versionId: current.id, + installPath: target.targetDir, + localContentHash, + receiptId: registrationReceiptId, + pendingRegistration: { + receiptId: registrationReceiptId, + installPath: target.targetDir, + installedContentHash: localContentHash, + }, + }), + true, + ), + ), + ); }) .pipe(Effect.result); if (Result.isFailure(installed)) return failureWithProgress(progress, installed.failure.message); @@ -139,11 +158,7 @@ export const runRegistryInstall = Effect.fn("selftune.registry.install")(functio ); } - yield* registryRequest(EmptyRegistryResponse, { - method: "POST", - path: `/${encodeURIComponent(entry.id)}/install`, - body: { install_path: target.targetDir, device_id: platform.deviceId }, - }).pipe(Effect.ignore); + yield* flushRegistryOutbox().pipe(Effect.ignore); return success( "install", progress, diff --git a/packages/runtime/registry/platform.ts b/packages/runtime/registry/platform.ts index a7b93344..99f71997 100644 --- a/packages/runtime/registry/platform.ts +++ b/packages/runtime/registry/platform.ts @@ -1,13 +1,14 @@ import { createHash } from "node:crypto"; -import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { lstat, mkdtemp, open, readFile, readdir, realpath, rm } from "node:fs/promises"; import { homedir, hostname, tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve, sep } from "node:path"; import { SELFTUNE_CONFIG_DIR } from "@selftune/config"; import { Context, Effect, FileSystem, Layer } from "effect"; import { installFromGithubTarget, type GithubRegistryInstallResult } from "./github-install.js"; -import { installRegistryArchive } from "./install-utils.js"; +import { inspectRegistryArchive, installRegistryArchive } from "./install-utils.js"; import { resolveRegistryInstallPath, validatePersistedRegistryInstallPath, @@ -55,8 +56,23 @@ export interface RegistryPlatformService { global: boolean, ) => Effect.Effect; readonly loadState: RegistryStateStore["load"]; + readonly computeInstalledContentHash: ( + directory: string, + ) => Effect.Effect; + readonly findProtectedPaths: ( + directory: string, + ) => Effect.Effect, RegistryProgramFailure>; + readonly computeArchiveContentHash: (options: { + readonly archive: Uint8Array; + readonly expectedHash: string; + readonly label: string; + }) => Effect.Effect; readonly preparePush: ( - input: Extract, + input: Extract, + ) => Effect.Effect; + readonly preparePackage: ( + directory: string, + input: Extract, ) => Effect.Effect; readonly resolveInstallTarget: ( skillName: string, @@ -69,6 +85,31 @@ export interface RegistryPlatformService { ) => Effect.Effect; } +const MAX_PACKAGE_FILES = 1_024; +const MAX_PACKAGE_FILE_BYTES = 2 * 1_024 * 1_024; +const MAX_PACKAGE_TOTAL_BYTES = 16 * 1_024 * 1_024; + +function isExcludedName(name: string): boolean { + return name === ".git" || name === "node_modules" || name === ".env" || name.startsWith(".env."); +} + +function isSensitiveName(name: string): boolean { + const lower = name.toLowerCase(); + return ( + lower === ".npmrc" || + /^credentials(?:[._-].*)?\.json$/.test(lower) || + /^(?:auth|token|private[-_.]?key)(?:[._-].*)?$/.test(lower) || + /^(?:id_rsa|id_ed25519)(?:\.pub)?$/.test(lower) || + /\.(?:pem|key)$/.test(lower) || + lower === "transcripts" || + lower === ".transcripts" + ); +} + +function isProtectedName(name: string): boolean { + return isExcludedName(name) || isSensitiveName(name); +} + export class RegistryPlatform extends Context.Service()( "@selftune/runtime/RegistryPlatform", ) {} @@ -81,31 +122,142 @@ export interface RegistryPlatformOptions { readonly stateStore?: Omit; } -async function collectFiles( +async function collectFilePaths( directory: string, base?: string, -): Promise> { + collected: Array<{ path: string; size: number }> = [], +): Promise> { const entries = await readdir(directory, { withFileTypes: true }); - const groups = await Promise.all( - entries.map(async (entry): Promise> => { - if ( - entry.name === ".git" || - entry.name === "node_modules" || - entry.name === ".env" || - entry.name.startsWith(".env.") - ) { - return []; + for (const entry of entries) { + if (isExcludedName(entry.name)) continue; + const fullPath = join(directory, entry.name); + const relativePath = base ? join(base, entry.name) : entry.name; + if (isSensitiveName(entry.name)) { + throw new Error(`Registry skill contains a protected local path: ${relativePath}`); + } + if (entry.isDirectory()) { + // oxlint-disable-next-line no-await-in-loop -- sequential traversal stops at the file/size boundary without queueing an unbounded tree + await collectFilePaths(fullPath, relativePath, collected); + continue; + } + if (entry.isFile()) { + // oxlint-disable-next-line no-await-in-loop -- metadata limits are enforced before discovering another file + const metadata = await lstat(fullPath); + if (!metadata.isFile()) { + throw new Error(`Registry skill contains unsupported filesystem entry: ${relativePath}`); } - const fullPath = join(directory, entry.name); - const relativePath = base ? join(base, entry.name) : entry.name; - if (entry.isDirectory()) return collectFiles(fullPath, relativePath); - if (entry.isFile()) { - return [{ path: relativePath, content: await readFile(fullPath) }]; + if (metadata.size > MAX_PACKAGE_FILE_BYTES) { + throw new Error(`Registry package file exceeds the 2 MiB limit: ${relativePath}`); + } + collected.push({ path: relativePath, size: metadata.size }); + if (collected.length > MAX_PACKAGE_FILES) { + throw new Error(`Registry package exceeds the ${MAX_PACKAGE_FILES} file limit`); } - throw new Error(`Registry skill contains unsupported filesystem entry: ${relativePath}`); + if (collected.reduce((total, file) => total + file.size, 0) > MAX_PACKAGE_TOTAL_BYTES) { + throw new Error("Registry package exceeds the 16 MiB uncompressed size limit"); + } + continue; + } + throw new Error(`Registry skill contains unsupported filesystem entry: ${relativePath}`); + } + return collected; +} + +async function findProtectedPaths(directory: string, base?: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const groups = await Promise.all( + entries.map(async (entry): Promise => { + const relativePath = base ? join(base, entry.name) : entry.name; + if (isProtectedName(entry.name)) return [relativePath]; + if (entry.isDirectory()) return findProtectedPaths(join(directory, entry.name), relativePath); + return []; }), ); - return groups.flat(); + return groups.flat().toSorted((left, right) => left.localeCompare(right)); +} + +async function readBoundedFile(pathname: string, expectedSize: number): Promise { + if (expectedSize > MAX_PACKAGE_FILE_BYTES) { + throw new Error(`Registry package file exceeds the 2 MiB limit: ${pathname}`); + } + const handle = await open(pathname, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > MAX_PACKAGE_FILE_BYTES) { + throw new Error(`Registry package file exceeds the 2 MiB limit: ${pathname}`); + } + const output = Buffer.alloc(MAX_PACKAGE_FILE_BYTES + 1); + let offset = 0; + while (offset < output.length) { + // oxlint-disable-next-line no-await-in-loop -- ordered bounded reads prevent whole-file allocation and detect growth past the cap + const read = await handle.read(output, offset, output.length - offset, offset); + if (read.bytesRead === 0) break; + offset += read.bytesRead; + } + if (offset > MAX_PACKAGE_FILE_BYTES) { + throw new Error(`Registry package file exceeds the 2 MiB limit: ${pathname}`); + } + return output.subarray(0, offset); + } finally { + await handle.close(); + } +} + +async function assertPackageDirectory(directory: string): Promise { + const lexical = resolve(directory); + const [metadata] = await Promise.all([lstat(lexical), realpath(lexical)]); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`Registry skill path is not a real directory: ${directory}`); + } + + // System paths may themselves have canonical aliases (for example /var -> /private/var on + // macOS). The managed boundary begins at .claude, so reject links in every component from + // there through the skill directory without misclassifying those system aliases. + const segments = lexical.split(sep); + const managedIndex = segments.lastIndexOf(".claude"); + if (managedIndex >= 0) { + let current = segments.slice(0, managedIndex).join(sep) || sep; + for (const segment of segments.slice(managedIndex)) { + current = join(current, segment); + // oxlint-disable-next-line no-await-in-loop -- each ancestor must be checked before descending through it + const component = await lstat(current); + if (component.isSymbolicLink()) { + throw new Error(`Registry skill path crosses a symbolic link: ${directory}`); + } + } + } +} + +async function collectFiles(directory: string): Promise> { + await assertPackageDirectory(directory); + const files = (await collectFilePaths(directory)).toSorted((left, right) => + left.path.localeCompare(right.path), + ); + const collected: Array<{ path: string; content: Buffer }> = []; + let total = 0; + for (const file of files) { + // oxlint-disable-next-line no-await-in-loop -- one bounded buffer at a time caps sidecar memory + const content = await readBoundedFile(join(directory, file.path), file.size); + total += content.length; + if (total > MAX_PACKAGE_TOTAL_BYTES) { + throw new Error("Registry package exceeds the 16 MiB uncompressed size limit"); + } + collected.push({ path: file.path, content }); + } + return collected; +} + +function hashCollectedFiles(files: Array<{ path: string; content: Buffer }>): string { + const digest = createHash("sha256"); + for (const file of files.toSorted((left, right) => left.path.localeCompare(right.path))) { + digest.update(file.path.replaceAll("\\", "/")); + digest.update("\0"); + digest.update(createHash("sha256").update(file.content).digest("hex")); + digest.update("\0"); + digest.update(String(file.content.length)); + digest.update("\n"); + } + return digest.digest("hex"); } function makePlatform( @@ -115,17 +267,14 @@ function makePlatform( const cwd = options.cwd ?? process.cwd(); const home = options.homeDirectory ?? process.env.HOME ?? homedir(); - const preparePush = Effect.fn("selftune.registry.platform.preparePush")(function* ( - input: Extract, + const preparePackage = Effect.fn("selftune.registry.platform.preparePackage")(function* ( + directory: string, + input: Extract, ) { - const skillPath = join(cwd, "SKILL.md"); - const exists = yield* fromPromise("push", () => - stat(skillPath) - .then(() => true) - .catch(() => false), - ); - if (!exists) return null; - const skillContent = yield* fromPromise("push", () => readFile(skillPath, "utf8")); + const files = yield* fromPromise("push", () => collectFiles(directory)); + const skillFile = files.find((file) => file.path === "SKILL.md"); + if (!skillFile) return null; + const skillContent = skillFile.content.toString("utf8"); const nameMatch = skillContent.match(/^name:\s*(.+)$/m); const descriptionMatch = skillContent.match(/^description:\s*(.+)$/m); const name = yield* validate("push", () => @@ -134,30 +283,25 @@ function makePlatform( const version = yield* validate("push", () => validateRegistryVersion(input.version || `0.1.${Date.now()}`), ); - const files = yield* fromPromise("push", () => collectFiles(cwd)); const archiveBuffer = yield* Effect.acquireUseRelease( fromPromise("push", () => mkdtemp(join(tmpdir(), "selftune-registry-"))), (tempRoot) => fromPromise("push", async () => { - const archivePath = join(tempRoot, "skill.tar.gz"); - const tar = Bun.spawn( - [ - "tar", - "czf", - archivePath, - "-C", - cwd, - "--exclude=.git", - "--exclude=node_modules", - "--exclude=.env", - "--exclude=.env.*", - ".", - ], - { stdout: "ignore", stderr: "pipe" }, - ); + const tarPath = join(tempRoot, "skill.tar"); + const archivePaths = files + .map((file) => file.path.replaceAll("\\", "/")) + .toSorted((left, right) => left.localeCompare(right)); + const tar = Bun.spawn(["tar", "cf", tarPath, "-C", directory, "--", ...archivePaths], { + stdout: "ignore", + stderr: "pipe", + }); await tar.exited; if (tar.exitCode !== 0) throw new Error("Failed to create archive"); - return await readFile(archivePath); + const tarBytes = await readFile(tarPath); + const compressed = new Blob([Uint8Array.from(tarBytes)]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + return Buffer.from(await new Response(compressed).arrayBuffer()); }), (tempRoot) => fromPromise("push-cleanup", () => rm(tempRoot, { recursive: true, force: true })).pipe( @@ -178,8 +322,35 @@ function makePlatform( }; }); + const preparePush = (input: Extract) => + preparePackage(cwd, input); + + const computeInstalledContentHash = Effect.fn( + "selftune.registry.platform.computeInstalledContentHash", + )(function* (directory: string) { + const files = yield* fromPromise("installed-content-hash", () => collectFiles(directory)); + return hashCollectedFiles(files); + }); + return { deviceId: options.deviceId ?? hostname(), + computeInstalledContentHash, + computeArchiveContentHash: (archiveOptions) => + fromPromise("inspect-archive", () => + inspectRegistryArchive( + { + archiveBuffer: Buffer.from(archiveOptions.archive), + expectedHash: archiveOptions.expectedHash, + label: archiveOptions.label, + }, + async (directory) => hashCollectedFiles(await collectFiles(directory)), + ), + ), + findProtectedPaths: (directory) => + fromPromise("protected-paths", async () => { + await assertPackageDirectory(directory); + return findProtectedPaths(directory); + }), installArchive: (archiveOptions) => fromPromise("install-archive", () => installRegistryArchive({ @@ -194,6 +365,7 @@ function makePlatform( installFromGithub: (target, global) => fromPromise("install-github", () => installFromGithubTarget(target, global)), loadState: stateStore.load, + preparePackage, preparePush, resolveInstallTarget: (skillName, global) => validate("install", () => { @@ -204,7 +376,13 @@ function makePlatform( }), withStateTransaction: stateStore.withTransaction, validatePersistedTarget: (installPath, skillName) => - validate("sync", () => validatePersistedRegistryInstallPath(installPath, skillName)), + Effect.gen(function* () { + const target = yield* validate("sync", () => + validatePersistedRegistryInstallPath(installPath, skillName), + ); + yield* fromPromise("sync", () => assertPackageDirectory(target.targetDir)); + return target; + }), }; } diff --git a/packages/runtime/registry/program-types.ts b/packages/runtime/registry/program-types.ts index 8cbfff2e..3b431f22 100644 --- a/packages/runtime/registry/program-types.ts +++ b/packages/runtime/registry/program-types.ts @@ -9,8 +9,15 @@ export type RegistryProgramInput = readonly version?: string; readonly summary?: string; } + | { + readonly operation: "suggest"; + readonly name?: string; + readonly version?: string; + readonly summary?: string; + } | { readonly operation: "install"; readonly target?: string; readonly global: boolean } - | { readonly operation: "sync" | "status" | "list" } + | { readonly operation: "sync"; readonly automaticOnly?: boolean } + | { readonly operation: "status" | "list" } | { readonly operation: "rollback"; readonly name?: string; diff --git a/packages/runtime/registry/programs.ts b/packages/runtime/registry/programs.ts index c448f405..e7236010 100644 --- a/packages/runtime/registry/programs.ts +++ b/packages/runtime/registry/programs.ts @@ -11,6 +11,7 @@ import { runRegistryPush } from "./push.js"; import { runRegistryRollback } from "./rollback.js"; import { runRegistryStatus } from "./status.js"; import { runRegistrySync } from "./sync.js"; +import { runRegistrySuggest } from "./suggest.js"; import { RegistryStateValidationError } from "./registry-state.js"; import type { FormattedRegistryResult, @@ -37,10 +38,12 @@ export const runRegistryProgram = Effect.fn("selftune.registry.run")(function* ( switch (input.operation) { case "push": return yield* runRegistryPush(input); + case "suggest": + return yield* runRegistrySuggest(input); case "install": return yield* runRegistryInstall(input); case "sync": - return yield* runRegistrySync(); + return yield* runRegistrySync({ automaticOnly: input.automaticOnly }); case "status": return yield* runRegistryStatus(); case "rollback": diff --git a/packages/runtime/registry/registry-outbox.ts b/packages/runtime/registry/registry-outbox.ts new file mode 100644 index 00000000..db96dd74 --- /dev/null +++ b/packages/runtime/registry/registry-outbox.ts @@ -0,0 +1,95 @@ +import { Effect, Result } from "effect"; + +import { registryRequest } from "./client.js"; +import { EmptyRegistryResponse, RegistryInstallationMutationResponse } from "./contracts.js"; +import { RegistryPlatform } from "./platform.js"; +import type { RegistryStateEntry } from "./registry-state.js"; +import { + commitRegistryState, + keepRegistryState, + upsertRegistryStateEntry, +} from "./registry-state-store.js"; + +export type PendingRegistryReceipt = NonNullable[number]; + +export function enqueueRegistryReceipt( + entry: RegistryStateEntry, + receipt: PendingRegistryReceipt, +): RegistryStateEntry { + const existing = entry.pendingReceipts ?? []; + const duplicate = existing.find( + (candidate) => + candidate.status === receipt.status && + candidate.installedVersion === receipt.installedVersion && + candidate.installedContentHash === receipt.installedContentHash && + candidate.previousVersionId === receipt.previousVersionId, + ); + if (duplicate) return entry; + return { ...entry, pendingReceipts: [...existing, receipt] }; +} + +export const flushRegistryOutbox = Effect.fn("selftune.registry.outbox.flush")(function* () { + const platform = yield* RegistryPlatform; + const initial = yield* platform.loadState(); + + for (const entry of initial) { + const pending = entry.pendingRegistration; + if (!pending || entry.installationId) continue; + const recorded = yield* registryRequest(RegistryInstallationMutationResponse, { + method: "POST", + path: `/${encodeURIComponent(entry.entryId)}/install`, + body: { + install_path: pending.installPath, + device_id: platform.deviceId, + installed_content_hash: pending.installedContentHash, + receipt_id: pending.receiptId, + }, + }).pipe(Effect.result); + if (Result.isFailure(recorded)) continue; + yield* platform.withStateTransaction((latest) => { + const current = latest.find((candidate) => candidate.entryId === entry.entryId); + if (current?.pendingRegistration?.receiptId !== pending.receiptId) { + return Effect.succeed(keepRegistryState(false)); + } + const { pendingRegistration: _pendingRegistration, ...remaining } = current; + return Effect.succeed( + commitRegistryState( + upsertRegistryStateEntry(latest, { + ...remaining, + installationId: recorded.success.data.id, + }), + true, + ), + ); + }); + } + + const registered = yield* platform.loadState(); + for (const entry of registered) { + if (!entry.installationId) continue; + for (const receipt of entry.pendingReceipts ?? []) { + const sent = yield* registryRequest(EmptyRegistryResponse, { + method: "POST", + path: `/api/v1/collaboration/registry/${encodeURIComponent(entry.entryId)}/installations/${encodeURIComponent(entry.installationId)}/receipt`, + body: receipt, + }).pipe(Effect.result); + if (Result.isFailure(sent)) continue; + yield* platform.withStateTransaction((latest) => { + const current = latest.find((candidate) => candidate.entryId === entry.entryId); + if ( + !current?.pendingReceipts?.some((candidate) => candidate.receiptId === receipt.receiptId) + ) { + return Effect.succeed(keepRegistryState(false)); + } + const remainingReceipts = current.pendingReceipts.filter( + (candidate) => candidate.receiptId !== receipt.receiptId, + ); + const updated = + remainingReceipts.length === 0 + ? (({ pendingReceipts: _pendingReceipts, ...remaining }) => remaining)(current) + : { ...current, pendingReceipts: remainingReceipts }; + return Effect.succeed(commitRegistryState(upsertRegistryStateEntry(latest, updated), true)); + }); + } + } +}); diff --git a/packages/runtime/registry/registry-state-store.ts b/packages/runtime/registry/registry-state-store.ts index 8e13916e..88597423 100644 --- a/packages/runtime/registry/registry-state-store.ts +++ b/packages/runtime/registry/registry-state-store.ts @@ -53,7 +53,18 @@ export function registryStateEntriesMatch( left?.entryId === right?.entryId && left?.name === right?.name && left?.versionHash === right?.versionHash && - left?.installPath === right?.installPath + left?.version === right?.version && + left?.versionId === right?.versionId && + left?.installPath === right?.installPath && + left?.localContentHash === right?.localContentHash && + left?.installationId === right?.installationId && + left?.receiptId === right?.receiptId && + left?.previousVersionHash === right?.previousVersionHash && + JSON.stringify(left?.pendingRegistration) === JSON.stringify(right?.pendingRegistration) && + JSON.stringify(left?.pendingReceipts) === JSON.stringify(right?.pendingReceipts) && + JSON.stringify(left?.pendingUpdate) === JSON.stringify(right?.pendingUpdate) && + JSON.stringify(left?.automaticSuggestion) === JSON.stringify(right?.automaticSuggestion) && + JSON.stringify(left?.lastSuggestion) === JSON.stringify(right?.lastSuggestion) ); } diff --git a/packages/runtime/registry/registry-state.ts b/packages/runtime/registry/registry-state.ts index a92555e0..3a1f0380 100644 --- a/packages/runtime/registry/registry-state.ts +++ b/packages/runtime/registry/registry-state.ts @@ -10,7 +10,55 @@ export interface RegistryStateEntry { entryId: string; name: string; versionHash: string; + version?: string; + versionId?: string; installPath: string; + localContentHash?: string; + installationId?: string; + receiptId?: string; + previousVersionHash?: string; + pendingRegistration?: { + receiptId: string; + installPath: string; + installedContentHash?: string; + }; + pendingReceipts?: ReadonlyArray<{ + receiptId: string; + installedVersion: string; + installedContentHash: string; + previousVersionId: string | null; + status: "updated" | "conflict"; + }>; + pendingUpdate?: { + receiptId: string; + targetVersionHash: string; + targetVersion: string; + targetVersionId?: string; + previousVersionId?: string; + observedContentHashBefore: string; + expectedInstalledContentHash: string; + }; + automaticSuggestion?: { + observedContentHash: string; + baseVersionHash: string; + baseVersionId?: string; + stableAt: number; + attemptCount: number; + nextAttemptAt: number; + lastFailure?: { + kind: "blocked" | "retrying"; + code: string; + at: number; + }; + }; + lastSuggestion?: { + observedContentHash: string; + candidateContentHash: string; + baseVersionHash: string; + baseVersionId: string; + contributionId: string; + submittedAt: string; + }; } const RegistryState = Schema.Array( @@ -18,7 +66,69 @@ const RegistryState = Schema.Array( entryId: Schema.String, name: Schema.String, versionHash: Schema.String, + version: Schema.optionalKey(Schema.String), + versionId: Schema.optionalKey(Schema.String), installPath: Schema.String, + localContentHash: Schema.optionalKey(Schema.String), + installationId: Schema.optionalKey(Schema.String), + receiptId: Schema.optionalKey(Schema.String), + previousVersionHash: Schema.optionalKey(Schema.String), + pendingRegistration: Schema.optionalKey( + Schema.Struct({ + receiptId: Schema.String, + installPath: Schema.String, + installedContentHash: Schema.optionalKey(Schema.String), + }), + ), + pendingReceipts: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + receiptId: Schema.String, + installedVersion: Schema.String, + installedContentHash: Schema.String, + previousVersionId: Schema.Union([Schema.String, Schema.Null]), + status: Schema.Literals(["updated", "conflict"]), + }), + ), + ), + pendingUpdate: Schema.optionalKey( + Schema.Struct({ + receiptId: Schema.String, + targetVersionHash: Schema.String, + targetVersion: Schema.String, + targetVersionId: Schema.optionalKey(Schema.String), + previousVersionId: Schema.optionalKey(Schema.String), + observedContentHashBefore: Schema.String, + expectedInstalledContentHash: Schema.String, + }), + ), + automaticSuggestion: Schema.optionalKey( + Schema.Struct({ + observedContentHash: Schema.String, + baseVersionHash: Schema.String, + baseVersionId: Schema.optionalKey(Schema.String), + stableAt: Schema.Number, + attemptCount: Schema.Number, + nextAttemptAt: Schema.Number, + lastFailure: Schema.optionalKey( + Schema.Struct({ + kind: Schema.Literals(["blocked", "retrying"]), + code: Schema.String, + at: Schema.Number, + }), + ), + }), + ), + lastSuggestion: Schema.optionalKey( + Schema.Struct({ + observedContentHash: Schema.String, + candidateContentHash: Schema.String, + baseVersionHash: Schema.String, + baseVersionId: Schema.String, + contributionId: Schema.String, + submittedAt: Schema.String, + }), + ), }), ); diff --git a/packages/runtime/registry/suggest.ts b/packages/runtime/registry/suggest.ts new file mode 100644 index 00000000..1bd83cd0 --- /dev/null +++ b/packages/runtime/registry/suggest.ts @@ -0,0 +1,91 @@ +import { Effect, Result } from "effect"; + +import { registryRequest } from "./client.js"; +import { + RegistryContributionMutationResponse, + RegistryDetailResponse, + RegistryInstallLookupResponse, +} from "./contracts.js"; +import { RegistryPlatform } from "./platform.js"; +import { + failure, + json, + registryFailure, + success, + type RegistryProgramInput, +} from "./program-types.js"; + +export const runRegistrySuggest = Effect.fn("selftune.registry.suggest")(function* ( + input: Extract, +) { + const platform = yield* RegistryPlatform; + const prepared = yield* platform.preparePush(input).pipe(Effect.result); + if (Result.isFailure(prepared)) return yield* prepared.failure; + if (!prepared.success) { + return failure("suggest", { + error: "No SKILL.md found in the current directory.", + guidance: { next_command: "cd " }, + }); + } + const candidate = prepared.success; + const lookup = yield* registryRequest(RegistryInstallLookupResponse, { + method: "GET", + path: `?name=${encodeURIComponent(candidate.name)}`, + }).pipe(Effect.result); + if (Result.isFailure(lookup)) return registryFailure("suggest", lookup.failure); + const entry = lookup.success.entries[0]; + if (!entry) { + return failure("suggest", { + error: `Skill '${candidate.name}' is not managed by this workspace registry.`, + guidance: { next_command: `selftune registry install ${candidate.name}` }, + }); + } + const detail = yield* registryRequest(RegistryDetailResponse, { + method: "GET", + path: `/${encodeURIComponent(entry.id)}`, + }).pipe(Effect.result); + if (Result.isFailure(detail)) return registryFailure("suggest", detail.failure); + const state = yield* platform.loadState(); + const installation = state.find((candidate) => candidate.entryId === entry.id); + const base = installation + ? detail.success.versions.find((version) => version.content_hash === installation.versionHash) + : detail.success.versions.find((version) => version.is_current); + if (!base) return failure("suggest", { error: "Registry entry has no current base version." }); + + const formData = new FormData(); + formData.append( + "metadata", + JSON.stringify({ + baseVersionId: base.id, + candidateVersion: candidate.version, + candidateContentHash: candidate.archiveHash, + summary: input.summary?.trim() || `Teammate revision based on ${base.version}`, + files: candidate.manifest, + }), + ); + formData.append( + "archive", + new Blob([Uint8Array.from(candidate.archiveBuffer)], { type: "application/gzip" }), + `${candidate.name}.tar.gz`, + ); + const submitted = yield* registryRequest(RegistryContributionMutationResponse, { + method: "POST", + path: `/api/v1/collaboration/registry/${encodeURIComponent(entry.id)}/contributions`, + formData, + }).pipe(Effect.result); + if (Result.isFailure(submitted)) return registryFailure("suggest", submitted.failure); + return success( + "suggest", + json({ + success: true, + contribution_id: submitted.success.id, + skill: candidate.name, + base_version: base.version, + candidate_version: candidate.version, + candidate_hash: candidate.archiveHash, + files: candidate.manifest.length, + status: submitted.success.status, + message: "Submitted for creator review. No workspace installation was changed.", + }), + ); +}); diff --git a/packages/runtime/registry/sync.ts b/packages/runtime/registry/sync.ts index 2f7a67af..bfbe11b2 100644 --- a/packages/runtime/registry/sync.ts +++ b/packages/runtime/registry/sync.ts @@ -1,9 +1,13 @@ +import { randomUUID } from "node:crypto"; + import { Effect, Result } from "effect"; import { RegistryClient, RegistryHttpError, registryRequest } from "./client.js"; +import { runAutomaticRegistrySuggestionScan } from "./automatic-suggestions.js"; import { RegistrySyncResponse } from "./contracts.js"; import { validateRegistryVersion } from "./path-policy.js"; import { RegistryPlatform } from "./platform.js"; +import { enqueueRegistryReceipt, flushRegistryOutbox } from "./registry-outbox.js"; import { validate } from "./program-support.js"; import { json, @@ -19,8 +23,36 @@ import { upsertRegistryStateEntry, } from "./registry-state-store.js"; -export const runRegistrySync = Effect.fn("selftune.registry.sync")(function* () { +function clearSuggestionState< + T extends { automaticSuggestion?: unknown; lastSuggestion?: unknown; pendingUpdate?: unknown }, +>(entry: T): Omit { + const { + automaticSuggestion: _automaticSuggestion, + lastSuggestion: _lastSuggestion, + pendingUpdate: _pendingUpdate, + ...remaining + } = entry; + return remaining; +} + +export const runRegistrySync = Effect.fn("selftune.registry.sync")(function* ( + options: { + readonly automaticOnly?: boolean; + readonly scanForSuggestions?: boolean; + readonly suggestionStableForMs?: number; + } = {}, +) { const platform = yield* RegistryPlatform; + yield* flushRegistryOutbox().pipe(Effect.ignore); + if (options.automaticOnly && options.scanForSuggestions !== false) { + // A scheduled one-shot still provides the automatic contribution path when Desktop was not + // left running. The first pass arms the exact observed hash; the second packages it and + // re-hashes after packaging, which is the stability fence for this foreground invocation. + const stableForMs = options.suggestionStableForMs ?? 5_000; + yield* runAutomaticRegistrySuggestionScan({ stableForMs }).pipe(Effect.ignore); + yield* Effect.sleep(stableForMs); + yield* runAutomaticRegistrySuggestionScan({ stableForMs }).pipe(Effect.ignore); + } const state = yield* platform.loadState(); if (state.length === 0) { return success( @@ -41,7 +73,9 @@ export const runRegistrySync = Effect.fn("selftune.registry.sync")(function* () }, }).pipe(Effect.result); if (Result.isFailure(response)) return registryFailure("sync", response.failure); - const updates = response.success.entries.filter((entry) => entry.has_update); + const updates = response.success.entries.filter( + (entry) => entry.has_update && (!options.automaticOnly || entry.automatic_update_allowed), + ); if (updates.length === 0) { return success("sync", json({ message: "All installations up to date", count: state.length })); } @@ -52,41 +86,205 @@ export const runRegistrySync = Effect.fn("selftune.registry.sync")(function* () let failed = 0; for (const update of updates) { const local = state.find((entry) => entry.entryId === update.entry_id); - if (!update.download_url || !local) { + if (!local) { failed++; continue; } - const downloadUrl = update.download_url; const attempt = yield* Effect.gen(function* () { + const previousVersionId = local.versionId ?? update.current_version_id ?? null; const target = yield* platform.validatePersistedTarget(local.installPath, local.name); - const version = yield* validate("sync", () => validateRegistryVersion(update.latest_version)); - const archive = yield* client.download(downloadUrl); - const committed = yield* platform.withStateTransaction((latest) => { - const latestEntry = latest.find((entry) => entry.entryId === local.entryId); - if (!registryStateEntriesMatch(latestEntry, local)) { - return Effect.succeed(keepRegistryState(false)); - } - return platform - .installArchive({ - archive, - expectedHash: update.latest_content_hash, - installRoot: target.installRoot, - skillName: local.name, - version, - label: `${update.name} v${version}`, - }) - .pipe( - Effect.as( + const observedContentHash = yield* platform.computeInstalledContentHash(target.targetDir); + const pendingUpdateMatches = + local.pendingUpdate?.targetVersionHash === update.latest_content_hash && + local.pendingUpdate.targetVersion === update.latest_version; + const recoveredAppliedUpdate = + pendingUpdateMatches && + observedContentHash === local.pendingUpdate?.expectedInstalledContentHash; + const hasLocalDrift = + !local.localContentHash || observedContentHash !== local.localContentHash; + const adoptedExactLocalSuggestion = + recoveredAppliedUpdate || + (hasLocalDrift && + local.lastSuggestion?.observedContentHash === observedContentHash && + local.lastSuggestion.candidateContentHash === update.latest_content_hash); + const protectedPaths = adoptedExactLocalSuggestion + ? [] + : yield* platform.findProtectedPaths(target.targetDir); + if ( + (hasLocalDrift && !adoptedExactLocalSuggestion) || + (local.pendingUpdate !== undefined && !pendingUpdateMatches) || + protectedPaths.length > 0 + ) { + const receiptId = randomUUID(); + if (local.installationId) { + yield* platform.withStateTransaction((latest) => { + const current = latest.find((entry) => entry.entryId === local.entryId); + if (!registryStateEntriesMatch(current, local) || !current) { + return Effect.succeed(keepRegistryState(false)); + } + return Effect.succeed( commitRegistryState( - upsertRegistryStateEntry(latest, { - ...local, - versionHash: update.latest_content_hash, - }), + upsertRegistryStateEntry( + latest, + enqueueRegistryReceipt(current, { + installedVersion: local.version ?? update.current_version ?? "unknown", + installedContentHash: observedContentHash, + previousVersionId, + status: "conflict", + receiptId, + }), + ), true, ), + ); + }); + yield* flushRegistryOutbox().pipe(Effect.ignore); + } + const protectedDetail = + protectedPaths.length > 0 + ? ` Protected local paths (${protectedPaths.slice(0, 3).join(", ")}) must be moved or backed up before a team rollout can replace this skill.` + : ""; + return yield* Effect.fail( + operationError( + "sync", + new Error( + `Local changes detected in '${local.name}'. Automatic replacement was blocked while SelfTune prepares or waits for workspace review of those edits.${protectedDetail}`, ), - ); - }); + ), + ); + } + const version = yield* validate("sync", () => validateRegistryVersion(update.latest_version)); + const receiptId = pendingUpdateMatches + ? (local.pendingUpdate?.receiptId ?? randomUUID()) + : randomUUID(); + const committed = adoptedExactLocalSuggestion + ? yield* platform.withStateTransaction((latest) => { + const latestEntry = latest.find((entry) => entry.entryId === local.entryId); + if (!registryStateEntriesMatch(latestEntry, local)) { + return Effect.succeed(keepRegistryState(false)); + } + return platform.computeInstalledContentHash(target.targetDir).pipe( + Effect.flatMap((freshContentHash) => { + if (freshContentHash !== observedContentHash) { + return Effect.succeed(keepRegistryState(false)); + } + const reconciled = { + ...clearSuggestionState(local), + previousVersionHash: local.versionHash, + versionHash: update.latest_content_hash, + version, + versionId: update.latest_version_id ?? local.versionId, + localContentHash: freshContentHash, + receiptId, + }; + return Effect.succeed( + commitRegistryState( + upsertRegistryStateEntry( + latest, + local.installationId + ? enqueueRegistryReceipt(reconciled, { + installedVersion: version, + installedContentHash: freshContentHash, + previousVersionId, + status: "updated", + receiptId, + }) + : reconciled, + ), + true, + ), + ); + }), + ); + }) + : yield* Effect.gen(function* () { + if (!update.download_url) { + return yield* operationError( + "sync", + new Error(`Registry update '${update.name}' has no download URL`), + ); + } + const archive = yield* client.download(update.download_url); + const expectedInstalledContentHash = yield* platform.computeArchiveContentHash({ + archive, + expectedHash: update.latest_content_hash, + label: `${update.name} v${version}`, + }); + const marked = yield* platform.withStateTransaction((latest) => { + const latestEntry = latest.find((entry) => entry.entryId === local.entryId); + if (!registryStateEntriesMatch(latestEntry, local)) { + return Effect.succeed(keepRegistryState(false)); + } + return Effect.succeed( + commitRegistryState( + upsertRegistryStateEntry(latest, { + ...local, + pendingUpdate: { + receiptId, + targetVersionHash: update.latest_content_hash, + targetVersion: version, + ...(update.latest_version_id + ? { targetVersionId: update.latest_version_id } + : {}), + ...(previousVersionId ? { previousVersionId } : {}), + observedContentHashBefore: observedContentHash, + expectedInstalledContentHash, + }, + }), + true, + ), + ); + }); + if (!marked) return false; + + yield* platform.installArchive({ + archive, + expectedHash: update.latest_content_hash, + installRoot: target.installRoot, + skillName: local.name, + version, + label: `${update.name} v${version}`, + }); + const contentHash = yield* platform.computeInstalledContentHash(target.targetDir); + if (contentHash !== expectedInstalledContentHash) { + return yield* operationError( + "sync", + new Error(`Registry update '${update.name}' produced an unexpected file tree`), + ); + } + return yield* platform.withStateTransaction((latest) => { + const latestEntry = latest.find((entry) => entry.entryId === local.entryId); + if (latestEntry?.pendingUpdate?.receiptId !== receiptId) { + return Effect.succeed(keepRegistryState(false)); + } + const installed = { + ...clearSuggestionState(latestEntry), + previousVersionHash: local.versionHash, + versionHash: update.latest_content_hash, + version, + versionId: update.latest_version_id ?? local.versionId, + localContentHash: contentHash, + receiptId, + }; + return Effect.succeed( + commitRegistryState( + upsertRegistryStateEntry( + latest, + local.installationId + ? enqueueRegistryReceipt(installed, { + installedVersion: version, + installedContentHash: contentHash, + previousVersionId, + status: "updated", + receiptId, + }) + : installed, + ), + true, + ), + ); + }); + }); if (!committed) { return yield* Effect.fail( operationError( @@ -97,6 +295,7 @@ export const runRegistrySync = Effect.fn("selftune.registry.sync")(function* () ), ); } + yield* flushRegistryOutbox().pipe(Effect.ignore); return version; }).pipe(Effect.result); if (Result.isFailure(attempt)) { diff --git a/packages/runtime/remote-library/collect.ts b/packages/runtime/remote-library/collect.ts index 9c4df93a..3319dd73 100644 --- a/packages/runtime/remote-library/collect.ts +++ b/packages/runtime/remote-library/collect.ts @@ -116,22 +116,25 @@ function collectLocalObjectsFromSnapshot( for (const skill of snapshot.skills) { if (!options.selectedSkillIds?.includes(skill.skillId)) continue; - const location = [...skill.locations] + const activeLocations = [...skill.locations] .filter((candidate) => candidate.active) - .toSorted((left, right) => right.modifiedAt.localeCompare(left.modifiedAt))[0]; - if (!location) { + .toSorted((left, right) => right.modifiedAt.localeCompare(left.modifiedAt)); + if (activeLocations.length === 0) { throw new CLIError( `Skill "${skill.name}" does not have an active local package to back up.`, "FILE_NOT_FOUND", "Choose a skill with an active local installation and retry.", ); } - const revisionHash = skill.revisions.find((revision) => - revision.locations.some( - (revisionLocation) => revisionLocation.packagePath === location.packagePath, - ), - )?.contentHash; - if (!revisionHash) { + const selection = activeLocations.flatMap((location) => { + const revisionHash = skill.revisions.find((revision) => + revision.locations.some( + (revisionLocation) => revisionLocation.packagePath === location.packagePath, + ), + )?.contentHash; + return revisionHash ? [{ location, revisionHash }] : []; + })[0]; + if (!selection) { throw new CLIError( `Skill "${skill.name}" does not have a verifiable local revision.`, "GUARD_BLOCKED", @@ -140,9 +143,9 @@ function collectLocalObjectsFromSnapshot( } addSkillRevision({ name: skill.name, - packagePath: location.packagePath, - revisionHash, - updatedAt: location.modifiedAt, + packagePath: selection.location.packagePath, + revisionHash: selection.revisionHash, + updatedAt: selection.location.modifiedAt, artifactPrefix: "backup-skill", }); } diff --git a/packages/runtime/remote-library/config.ts b/packages/runtime/remote-library/config.ts index 1e20d013..c9023e99 100644 --- a/packages/runtime/remote-library/config.ts +++ b/packages/runtime/remote-library/config.ts @@ -126,6 +126,17 @@ function processEnvironment(): RemoteLibraryEnvironment { }; } +function migrateRetiredCloudUrl(input: string): string { + const url = normalizeRemoteLibraryUrl(input); + try { + return new URL(url).hostname.toLowerCase() === "api.selftune.dev" + ? normalizeRemoteLibraryUrl(DEFAULT_CLOUD_API_URL) + : url; + } catch { + return url; + } +} + export function loadRemoteLibraryConfig( configRoot = SELFTUNE_CONFIG_DIR, deps: RemoteLibraryConfigDeps = {}, @@ -133,7 +144,8 @@ export function loadRemoteLibraryConfig( const environmentConfig = remoteLibraryConfigFromEnvironment( deps.environment ?? processEnvironment(), ); - if (environmentConfig) return environmentConfig; + if (environmentConfig) + return { ...environmentConfig, url: migrateRetiredCloudUrl(environmentConfig.url) }; const explicitConfigPath = configPath(configRoot); if (!existsSync(explicitConfigPath)) { @@ -150,7 +162,7 @@ export function loadRemoteLibraryConfig( const alpha = migratedConfig?.alpha ?? selftuneConfig.alpha; return { version: 2, - url: normalizeRemoteLibraryUrl(alpha.cloud_api_url ?? DEFAULT_CLOUD_API_URL), + url: migrateRetiredCloudUrl(alpha.cloud_api_url ?? DEFAULT_CLOUD_API_URL), apiKey, preferences: loadCloudPreferences(configRoot), credentialProvider: alpha.credential?.provider ?? "file", @@ -175,14 +187,15 @@ export function loadRemoteLibraryConfig( "selftune library configure --url --api-key ", ); } - return makeRemoteLibraryConfig(stored, apiKey); + const config = makeRemoteLibraryConfig(stored, apiKey); + return { ...config, url: migrateRetiredCloudUrl(config.url) }; } catch (cause) { if (cause instanceof LibraryError) throw cause; } try { const legacy = decodeLegacyRemoteLibraryConfig(value); - const url = normalizeRemoteLibraryUrl(legacy.url); + const url = migrateRetiredCloudUrl(legacy.url); const migrated: StoredRemoteLibraryConfig = { version: 2, url, @@ -202,7 +215,7 @@ export function saveRemoteLibraryConfig( deps: RemoteLibraryConfigDeps = {}, ): RemoteLibraryConfig { const apiKey = normalizeRemoteLibraryApiKey(input.apiKey); - const url = normalizeRemoteLibraryUrl(input.url); + const url = migrateRetiredCloudUrl(input.url); const preferences = decodeSyncPreferences(input.preferences); const previousConfig = readStoredRemoteLibraryConfig(configRoot); const stored: StoredRemoteLibraryConfig = { @@ -246,7 +259,7 @@ export function updateRemoteLibraryConfig( configRoot = SELFTUNE_CONFIG_DIR, deps: RemoteLibraryConfigDeps = {}, ): RemoteLibraryConfig { - const url = normalizeRemoteLibraryUrl(input.url); + const url = migrateRetiredCloudUrl(input.url); if (url === normalizeRemoteLibraryUrl(DEFAULT_CLOUD_API_URL) && !input.api_key?.trim()) { return activateCloudRemoteLibraryConfig(input.preferences, configRoot, deps); } @@ -312,7 +325,7 @@ export function activateCloudRemoteLibraryConfig( const alpha = migratedConfig?.alpha ?? selftuneConfig.alpha; return { version: 2, - url: normalizeRemoteLibraryUrl(alpha.cloud_api_url ?? DEFAULT_CLOUD_API_URL), + url: migrateRetiredCloudUrl(alpha.cloud_api_url ?? DEFAULT_CLOUD_API_URL), apiKey, preferences: decodedPreferences, credentialProvider: alpha.credential?.provider ?? "file", diff --git a/packages/runtime/remote-library/package-bundle-collector.cjs b/packages/runtime/remote-library/package-bundle-collector.cjs index e7dca343..d7535717 100644 --- a/packages/runtime/remote-library/package-bundle-collector.cjs +++ b/packages/runtime/remote-library/package-bundle-collector.cjs @@ -45,37 +45,30 @@ const fail = (reason, message, path) => { throw new CollectionFailure(reason, message, path); }; -function anchoredReadOnlyOpenFlags( - kind, - platform = process.platform, - constants = fileSystemConstants, -) { - if (platform === "win32") { - // Windows does not expose the POSIX no-follow flags. The surrounding - // lstat -> open -> fstat identity fence rejects a raced reparse target - // before the collector can enter a directory or read a file. - return constants.O_RDONLY; - } - const noFollow = constants.O_NOFOLLOW; - if (!Number.isInteger(noFollow) || noFollow === 0) { - throw new Error("O_NOFOLLOW unavailable"); - } - if (kind === "file") { - return constants.O_RDONLY | noFollow; - } - const directory = constants.O_DIRECTORY; - if (!Number.isInteger(directory) || directory === 0) { - throw new Error("safe directory flags unavailable"); - } - return constants.O_RDONLY | noFollow | directory; -} - const nodeFileSystem = { changeDirectory: (path) => process.chdir(path), readDirectory: (path) => readdirSync(path), lstat: (path) => lstatSync(path), - openDirectoryNoFollow: (path) => openSync(path, anchoredReadOnlyOpenFlags("directory")), - openReadOnlyNoFollow: (path) => openSync(path, anchoredReadOnlyOpenFlags("file")), + openDirectoryNoFollow: (path) => { + const noFollow = fileSystemConstants.O_NOFOLLOW; + const directory = fileSystemConstants.O_DIRECTORY; + if ( + !Number.isInteger(noFollow) || + noFollow === 0 || + !Number.isInteger(directory) || + directory === 0 + ) { + throw new Error("safe directory flags unavailable"); + } + return openSync(path, fileSystemConstants.O_RDONLY | noFollow | directory); + }, + openReadOnlyNoFollow: (path) => { + const noFollow = fileSystemConstants.O_NOFOLLOW; + if (!Number.isInteger(noFollow) || noFollow === 0) { + throw new Error("O_NOFOLLOW unavailable"); + } + return openSync(path, fileSystemConstants.O_RDONLY | noFollow); + }, fstat: (descriptor) => fstatSync(descriptor), allocate: (size) => Buffer.allocUnsafe(size), read: (descriptor, buffer, offset, length, position) => @@ -421,17 +414,9 @@ function uint32(value) { return buffer; } -function positiveSafeInteger(value, label) { +function positiveInteger(value, label) { const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - fail("invalid_package", `Invalid collector ${label}`, "."); - } - return parsed; -} - -function positiveUint32(value, label) { - const parsed = positiveSafeInteger(value, label); - if (parsed > 0xffffffff) { + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > 0xffffffff) { fail("invalid_package", `Invalid collector ${label}`, "."); } return parsed; @@ -452,17 +437,17 @@ function parseArguments(argv) { } const expectedRoot = { dev: nonNegativeInteger(argv[3], "root device"), - ino: positiveSafeInteger(argv[4], "root inode"), + ino: positiveInteger(argv[4], "root inode"), size: 0, mtimeMs: 0, ctimeMs: 0, }; const limits = { - maximumFileCount: positiveUint32(argv[5], "file count"), - maximumDecodedFileBytes: positiveUint32(argv[6], "file byte limit"), - maximumDecodedPackageBytes: positiveUint32(argv[7], "package byte limit"), - maximumPathBytes: positiveUint32(argv[8], "path byte limit"), - maximumTotalPathBytes: positiveUint32(argv[9], "aggregate path byte limit"), + maximumFileCount: positiveInteger(argv[5], "file count"), + maximumDecodedFileBytes: positiveInteger(argv[6], "file byte limit"), + maximumDecodedPackageBytes: positiveInteger(argv[7], "package byte limit"), + maximumPathBytes: positiveInteger(argv[8], "path byte limit"), + maximumTotalPathBytes: positiveInteger(argv[9], "aggregate path byte limit"), }; if ( limits.maximumFileCount > MAXIMUM_FILE_COUNT || @@ -528,7 +513,6 @@ function runMain(argv = process.argv) { module.exports = { CollectionFailure, PROTOCOL_MAGIC, - anchoredReadOnlyOpenFlags, collectPackageFiles, encodeProtocol, runMain, diff --git a/packages/runtime/remote-library/pull.ts b/packages/runtime/remote-library/pull.ts index cdd5ecc3..a525c456 100644 --- a/packages/runtime/remote-library/pull.ts +++ b/packages/runtime/remote-library/pull.ts @@ -9,7 +9,11 @@ import { type RemoteSnapshot, type SyncPreferences, } from "@selftune/control-plane"; -import { importPortableSkillSet, type SkillSetSkillReference } from "@selftune/library"; +import { + importPortableSkillSet, + isSkillSetDeleted, + type SkillSetSkillReference, +} from "@selftune/library"; import type { RemoteLibraryHandle } from "@selftune/library/remote/transport"; import * as Schema from "effect/Schema"; @@ -147,6 +151,7 @@ async function pullRemoteSkillSetManifest(options: { }): Promise { const localPath = join(options.configRoot, "skill-sets", `${options.setId}.json`); if (existsSync(localPath)) return false; + if (isSkillSetDeleted(options.setId, { configRoot: options.configRoot })) return false; const bytes = await fromRemote(`downloading Skill Set "${options.setId}"`, () => options.handle.getObject(options.artifact.objectHash), ); diff --git a/packages/runtime/remote-library/sync.ts b/packages/runtime/remote-library/sync.ts index 2f6e9215..34ac8a7d 100644 --- a/packages/runtime/remote-library/sync.ts +++ b/packages/runtime/remote-library/sync.ts @@ -49,12 +49,7 @@ export async function syncRemoteLibrary(options: { catalogOptions?: LibraryCatalogOptions; db?: Database; now?: Date; -}): Promise<{ - snapshot: RemoteSnapshot; - uploaded: number; - unchanged: number; - syncedArtifacts: ReadonlyArray; -}> { +}): Promise<{ snapshot: RemoteSnapshot; uploaded: number; unchanged: number }> { const configRoot = resolve(options.configRoot ?? SELFTUNE_CONFIG_DIR); const db = options.db ?? (configRoot === resolve(SELFTUNE_CONFIG_DIR) ? getDb() : undefined); const localObjects = await collectLocalObjects({ @@ -64,7 +59,7 @@ export async function syncRemoteLibrary(options: { catalogOptions: options.catalogOptions, db, }); - const result = await syncRemoteObjects({ + return syncRemoteObjects({ handle: options.handle, objects: localObjects, now: options.now, @@ -77,8 +72,4 @@ export async function syncRemoteLibrary(options: { db, }), }); - return { - ...result, - syncedArtifacts: localObjects.map((object) => object.artifact), - }; } diff --git a/packages/runtime/schedule-definitions.ts b/packages/runtime/schedule-definitions.ts index 93f63ec9..3da95f05 100644 --- a/packages/runtime/schedule-definitions.ts +++ b/packages/runtime/schedule-definitions.ts @@ -10,8 +10,8 @@ export const SELFTUNE_SCHEDULE_JOBS: ReadonlyArray = [ name: "selftune-sync", cron: "*/30 * * * *", message: - "Run selftune sync --no-repair to ingest new Claude Code, Codex, OpenCode, OpenClaw, and Pi source data without rebuilding the full historical repair overlay.", - description: "Sync source-truth telemetry every 30 minutes", + "Run selftune registry sync --automatic-only first so teammate suggestions and workspace-approved skill updates continue independently. Then run selftune sync --no-repair to ingest new Claude Code, Codex, OpenCode, OpenClaw, and Pi source data without rebuilding the full historical repair overlay.", + description: "Sync source-truth telemetry and policy-approved skill updates every 30 minutes", }, { name: "selftune-status", diff --git a/packages/runtime/scheduling.ts b/packages/runtime/scheduling.ts index 22bf96c0..855e981c 100644 --- a/packages/runtime/scheduling.ts +++ b/packages/runtime/scheduling.ts @@ -42,7 +42,7 @@ export interface ScheduleEntry { function commandForJob(jobName: string): string { switch (jobName) { case "selftune-sync": - return "selftune sync --no-repair"; + return "selftune registry sync --automatic-only; selftune sync --no-repair"; case "selftune-status": return "selftune sync && selftune status"; case "selftune-orchestrate": @@ -147,7 +147,7 @@ function shellQuoteIfNeeded(value: string): string { } function toLaunchdArgs(command: string): string { - if (command.includes(" && ") || command.startsWith("'")) { + if (command.includes(" && ") || command.includes("; ") || command.startsWith("'")) { return ["/bin/sh", "-c", command] .map((argument) => ` ${escapeXml(argument)}`) .join("\n"); @@ -160,7 +160,7 @@ function toLaunchdArgs(command: string): string { /** Build systemd ExecStart, using /bin/sh -c for chained commands. */ function toSystemdExecStart(command: string): string { - if (command.includes(" && ")) { + if (command.includes(" && ") || command.includes("; ")) { return `/bin/sh -c "${command}"`; } return command; diff --git a/packages/runtime/testing-readiness.ts b/packages/runtime/testing-readiness.ts index cc72b591..ffa80cb3 100644 --- a/packages/runtime/testing-readiness.ts +++ b/packages/runtime/testing-readiness.ts @@ -32,6 +32,8 @@ import { getCanonicalEvalSetPath, getCanonicalPackageEvaluationPath, getEvalSetDir, + getPackageEvalSetPath, + getPackageUnitTestPath, getPackageEvaluationDir, getUnitTestDir, getUnitTestPath, @@ -51,6 +53,8 @@ export { getCanonicalPackageEvaluationArtifactPath, getCanonicalPackageEvaluationPath, getUnitTestPath, + getPackageEvalSetPath, + getPackageUnitTestPath, getUnitTestResultPath, readCanonicalPackageEvaluationArtifact, readCanonicalUnitTestRunResult, @@ -560,24 +564,34 @@ function buildSkillTestingReadinessRow( const draftPackage = isDraftSkillPath(skillPath); const evalReadiness = deriveEvalReadiness(skillPath, trustedTriggerCount); - const canonicalEvalPath = getCanonicalEvalSetPath(skillName); + const packageEvalPath = skillPath ? getPackageEvalSetPath(skillPath) : null; + const canonicalEvalPath = + packageEvalPath && existsSync(packageEvalPath) + ? packageEvalPath + : getCanonicalEvalSetPath(skillName); const storedEvalSet = readCanonicalEvalSetFromDb(context.db, skillName); + const packagedEvalEntries = readJsonArrayFile(canonicalEvalPath) as EvalEntry[]; const canonicalEvalEntries = - storedEvalSet?.entries ?? (readJsonArrayFile(canonicalEvalPath) as EvalEntry[]); - const canonicalEvalStat = - !storedEvalSet && existsSync(canonicalEvalPath) ? statSync(canonicalEvalPath) : null; + packagedEvalEntries.length > 0 ? packagedEvalEntries : (storedEvalSet?.entries ?? []); + const canonicalEvalStat = existsSync(canonicalEvalPath) ? statSync(canonicalEvalPath) : null; const evidenceEval = context.evalEvidenceBySkill.get(skillName) ?? { count: 0, latestAt: null }; const evalSetEntries = canonicalEvalEntries.length > 0 ? canonicalEvalEntries.length : evidenceEval.count; const latestEvalAt = - storedEvalSet?.storedAt ?? canonicalEvalStat?.mtime.toISOString?.() ?? + storedEvalSet?.storedAt ?? evidenceEval.latestAt ?? null; - const unitTestPath = getUnitTestPath(skillName); + const packageUnitTestPath = skillPath ? getPackageUnitTestPath(skillPath) : null; + const unitTestPath = + packageUnitTestPath && existsSync(packageUnitTestPath) + ? packageUnitTestPath + : getUnitTestPath(skillName); const storedUnitTests = readUnitTestsFromDb(context.db, skillName); - const unitTestCases = storedUnitTests?.tests.length ?? readJsonArrayFile(unitTestPath).length; + const packagedUnitTests = readJsonArrayFile(unitTestPath); + const unitTestCases = + packagedUnitTests.length > 0 ? packagedUnitTests.length : (storedUnitTests?.tests.length ?? 0); const unitTestResult = readUnitTestRunResultFromDb(context.db, skillName) ?? readUnitTestResult(getUnitTestResultPath(skillName)); diff --git a/packages/runtime/testing-readiness/artifacts.ts b/packages/runtime/testing-readiness/artifacts.ts index ee2178d6..778c90e6 100644 --- a/packages/runtime/testing-readiness/artifacts.ts +++ b/packages/runtime/testing-readiness/artifacts.ts @@ -1,7 +1,7 @@ import type { Database } from "bun:sqlite"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { SELFTUNE_CONFIG_DIR } from "../constants.js"; import type { CreatePackageEvaluationResult } from "../create/package-evaluator.js"; @@ -33,6 +33,61 @@ export function getCanonicalEvalSetPath(skillName: string): string { return join(getEvalSetDir(), `${skillName}.json`); } +function resolveSkillDirectory(skillPath: string): string { + const absolute = resolve(skillPath); + return basename(absolute) === "SKILL.md" ? dirname(absolute) : absolute; +} + +export function getPackageEvalSetPath(skillPath: string): string { + return join(resolveSkillDirectory(skillPath), "evals", "routing.json"); +} + +export function getPackageUnitTestPath(skillPath: string): string { + return join(resolveSkillDirectory(skillPath), "evals", "evals.json"); +} + +interface PortableEvalCase { + readonly id: string; + readonly prompt: string; + readonly expected_output: string; + readonly files: readonly string[]; + readonly assertions: readonly string[]; + readonly selftune_assertions: SkillUnitTest["assertions"]; + readonly tags?: readonly string[]; +} + +interface PortableEvalFile { + readonly skill_name: string; + readonly evals: readonly PortableEvalCase[]; +} + +function describeExpectedOutput(test: SkillUnitTest): string { + const descriptions = test.assertions + .map((assertion) => assertion.description?.trim()) + .filter((description): description is string => Boolean(description)); + if (descriptions.length > 0) return descriptions.join("; "); + return `The result satisfies ${test.assertions.length} objective behavior assertion${test.assertions.length === 1 ? "" : "s"}.`; +} + +function toPortableEvalFile(skillName: string, tests: SkillUnitTest[]): PortableEvalFile { + return { + skill_name: skillName, + evals: tests.map((test) => ({ + id: test.id, + prompt: test.query, + expected_output: describeExpectedOutput(test), + files: [], + assertions: test.assertions.map( + (assertion) => + assertion.description?.trim() || + `The output passes the ${assertion.type} check for ${JSON.stringify(assertion.value)}.`, + ), + selftune_assertions: test.assertions, + ...(test.tags ? { tags: test.tags } : {}), + })), + }; +} + export function getUnitTestPath(skillName: string): string { return join(getUnitTestDir(), `${skillName}.json`); } @@ -246,12 +301,23 @@ export function listStoredSkillNames(db: Database, tableName: string): Set row.skill_name).filter(Boolean)); } -export function writeCanonicalEvalSet(skillName: string, evalSet: EvalEntry[]): string { - const path = getCanonicalEvalSetPath(skillName); +export function writeCanonicalEvalSet( + skillName: string, + evalSet: EvalEntry[], + packagePath?: string, +): string { + const path = packagePath + ? getPackageEvalSetPath(packagePath) + : getCanonicalEvalSetPath(skillName); const db = getOptionalDb(); if (db) upsertCanonicalEvalSet(db, skillName, evalSet); - mkdirSync(getEvalSetDir(), { recursive: true }); + mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(evalSet, null, 2), "utf-8"); + const compatibilityPath = getCanonicalEvalSetPath(skillName); + if (compatibilityPath !== path) { + mkdirSync(dirname(compatibilityPath), { recursive: true }); + writeFileSync(compatibilityPath, JSON.stringify(evalSet, null, 2), "utf-8"); + } return path; } @@ -259,12 +325,21 @@ export function writeCanonicalUnitTests( skillName: string, tests: SkillUnitTest[], outputPath?: string, + packagePath?: string, ): string { - const canonicalPath = getUnitTestPath(skillName); + const canonicalPath = packagePath + ? getPackageUnitTestPath(packagePath) + : getUnitTestPath(skillName); const db = getOptionalDb(); if (db) upsertUnitTestFile(db, skillName, tests); - mkdirSync(getUnitTestDir(), { recursive: true }); - writeFileSync(canonicalPath, JSON.stringify(tests, null, 2), "utf-8"); + mkdirSync(dirname(canonicalPath), { recursive: true }); + const canonicalContents = packagePath ? toPortableEvalFile(skillName, tests) : tests; + writeFileSync(canonicalPath, JSON.stringify(canonicalContents, null, 2), "utf-8"); + const compatibilityPath = getUnitTestPath(skillName); + if (compatibilityPath !== canonicalPath) { + mkdirSync(dirname(compatibilityPath), { recursive: true }); + writeFileSync(compatibilityPath, JSON.stringify(tests, null, 2), "utf-8"); + } if (outputPath && outputPath !== canonicalPath) { mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, JSON.stringify(tests, null, 2), "utf-8"); @@ -308,7 +383,11 @@ export function readJsonArrayFile(path: string): unknown[] { try { if (!existsSync(path)) return []; const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; - return Array.isArray(parsed) ? parsed : []; + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === "object" && "evals" in parsed && Array.isArray(parsed.evals)) { + return parsed.evals; + } + return []; } catch { return []; } diff --git a/packages/runtime/utils/cli-error.ts b/packages/runtime/utils/cli-error.ts index ed157fc5..10dee47d 100644 --- a/packages/runtime/utils/cli-error.ts +++ b/packages/runtime/utils/cli-error.ts @@ -20,6 +20,7 @@ import { LibraryError } from "@selftune/library/errors"; export type CLIErrorCode = | "INVALID_FLAG" + | "INVALID_ARGUMENT" | "MISSING_FLAG" | "CONFIG_MISSING" | "FILE_NOT_FOUND" diff --git a/packages/runtime/utils/llm-call.ts b/packages/runtime/utils/llm-call.ts index f8ea2647..ae399a8f 100644 --- a/packages/runtime/utils/llm-call.ts +++ b/packages/runtime/utils/llm-call.ts @@ -262,6 +262,7 @@ export async function callViaAgent( retryOpts?: RetryOptions, effort?: EffortLevel, observer?: LlmCallObserver, + workingDirectory?: string, ): Promise { // Write prompt to temp file to avoid shell quoting issues const promptFile = join(tmpdir(), `selftune-llm-${Date.now()}.txt`); @@ -345,6 +346,7 @@ export async function callViaAgent( try { const proc = Bun.spawn(cmd, { + cwd: workingDirectory, stdout: "pipe", stderr: "pipe", env: { ...process.env, CLAUDECODE: "" }, @@ -433,6 +435,8 @@ function mapAllowedToolsToPi(tools?: string[]): string[] { /** Options for calling a named subagent (Claude Code or OpenCode). */ export interface SubagentCallOptions { + /** Explicit agent CLI. When omitted, SelfTune auto-detects the first supported CLI. */ + agent?: LlmBackedAgent; /** Name of the subagent (synced into ~/.claude/agents/ or opencode.json by selftune init/update). */ agentName: string; /** The task prompt for the subagent. */ @@ -462,6 +466,7 @@ export interface SubagentCallOptions { */ export async function callViaSubagent(options: SubagentCallOptions): Promise { const { + agent: requestedAgent, agentName, prompt, appendSystemPrompt, @@ -472,7 +477,7 @@ export async function callViaSubagent(options: SubagentCallOptions): Promise { if (!agent) { throw new Error("Agent must be specified for callLlm"); } - return callViaAgent(systemPrompt, userPrompt, agent, modelFlag, undefined, effort, observer); + return callViaAgent( + systemPrompt, + userPrompt, + agent, + modelFlag, + undefined, + effort, + observer, + workingDirectory, + ); } diff --git a/packages/runtime/utils/skill-discovery.ts b/packages/runtime/utils/skill-discovery.ts index 242ec580..016111b1 100644 --- a/packages/runtime/utils/skill-discovery.ts +++ b/packages/runtime/utils/skill-discovery.ts @@ -1,7 +1,10 @@ import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -export { computeSkillVersionHash } from "@selftune/library/hash"; +export { + computeSkillVersionHash, + computeSkillVersionHashWithContent, +} from "@selftune/library/hash"; import { resolveGlobalSkillPlacementDirs, diff --git a/packages/skill-intelligence/package.json b/packages/skill-intelligence/package.json index 28640461..32ea3dbf 100644 --- a/packages/skill-intelligence/package.json +++ b/packages/skill-intelligence/package.json @@ -15,6 +15,7 @@ "./outcomes": "./src/outcomes.ts", "./paired-replay": "./src/paired-replay.ts", "./study-drafts": "./src/study-drafts.ts", + "./trajectory-process": "./src/trajectory-process.ts", "./verifier-instruments": "./src/verifier-instruments.ts", "./workflow-discovery": "./src/workflow-discovery.ts" }, diff --git a/packages/skill-intelligence/src/blind-benchmark.ts b/packages/skill-intelligence/src/blind-benchmark.ts index 864a1908..8c807adf 100644 --- a/packages/skill-intelligence/src/blind-benchmark.ts +++ b/packages/skill-intelligence/src/blind-benchmark.ts @@ -4,6 +4,11 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { VerifierQualificationResult } from "./verifier-instruments.js"; +import { + aggregateTrajectoryProcessMetrics, + TrajectoryProcessMetrics, + type TrajectoryProcessMetrics as TrajectoryProcessMetricsType, +} from "./trajectory-process.js"; const Identifier = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(128)); const Revision = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); @@ -45,6 +50,7 @@ export const BlindBenchmarkAttempt = Schema.Union([ kind: Schema.Literal("scored"), passed: Schema.Boolean, executed_revision: Schema.NullOr(Revision), + process: Schema.optionalKey(TrajectoryProcessMetrics), }), Schema.Struct({ kind: Schema.Literal("infrastructure"), retryable: Schema.Boolean }), Schema.Struct({ kind: Schema.Literal("cancelled") }), @@ -97,6 +103,7 @@ export const BlindBenchmarkTrial = Schema.Struct({ censored_attempts: Schema.Number, passed_repetitions: Schema.Number, skipped: Schema.Boolean, + process: Schema.optionalKey(Schema.NullOr(TrajectoryProcessMetrics)), }); export type BlindBenchmarkTrial = typeof BlindBenchmarkTrial.Type; export const BlindBenchmarkResult = Schema.Struct({ @@ -116,7 +123,13 @@ export const BlindBenchmarkResult = Schema.Struct({ export type BlindBenchmarkResult = typeof BlindBenchmarkResult.Type; type ArmRun = - | { kind: "scored"; passed: boolean; attempts: number; censored: number } + | { + kind: "scored"; + passed: boolean; + attempts: number; + censored: number; + process: TrajectoryProcessMetricsType | null; + } | { kind: "infra"; attempts: number; censored: number } | { kind: "cancelled"; attempts: number; censored: number } | { kind: "budget"; attempts: number; censored: number } @@ -239,7 +252,13 @@ function runArm( if (outcome.kind === "scored") { if (outcome.executed_revision !== revision) return { kind: "mismatch", attempts: attempt, censored }; - return { kind: "scored", passed: outcome.passed, attempts: attempt, censored }; + return { + kind: "scored", + passed: outcome.passed, + attempts: attempt, + censored, + process: outcome.process ?? null, + }; } if (outcome.kind === "cancelled") return { kind: "cancelled", attempts: attempt, censored }; if (outcome.kind === "budget_exhausted") @@ -287,7 +306,12 @@ export function runBlindBenchmark( regression: boolean; } >(); - const runCases = protocol.cases.filter((entry) => entry.partition !== "audit_holdout"); + // Calibration tasks were visible during candidate generation, so scoring + // them adds cost without adding blind evidence. Active regressions remain + // mandatory even if an older manifest classified one as calibration. + const runCases = protocol.cases.filter( + (entry) => entry.partition === "selection" || entry.regression_case, + ); let auditOpened = false; for (const entry of runCases) { const row = { @@ -302,6 +326,7 @@ export function runBlindBenchmark( let passed = 0; let censored = 0; let scored = 0; + const process: TrajectoryProcessMetricsType[] = []; for ( let repetition = 1; repetition <= protocol.required_scored_repetitions; @@ -340,6 +365,7 @@ export function runBlindBenchmark( censored_attempts: censored, passed_repetitions: passed, skipped: false, + process: process.length === 0 ? null : aggregateTrajectoryProcessMetrics(process), }), ); return BlindBenchmarkResult.make({ @@ -350,6 +376,7 @@ export function runBlindBenchmark( }); } scored += 1; + if (outcome.process !== null) process.push(outcome.process); if (outcome.passed) passed += 1; } row[arm] = passed; @@ -362,6 +389,7 @@ export function runBlindBenchmark( censored_attempts: censored, passed_repetitions: passed, skipped: false, + process: process.length === 0 ? null : aggregateTrajectoryProcessMetrics(process), }), ); } @@ -415,6 +443,7 @@ export function runBlindBenchmark( let passed = 0; let censored = 0; let scored = 0; + const process: TrajectoryProcessMetricsType[] = []; for ( let repetition = 1; repetition <= protocol.required_scored_repetitions; @@ -440,6 +469,7 @@ export function runBlindBenchmark( audit_opened: true, }); scored += 1; + if (outcome.process !== null) process.push(outcome.process); if (outcome.passed) passed += 1; } trials.push( @@ -451,6 +481,7 @@ export function runBlindBenchmark( censored_attempts: censored, passed_repetitions: passed, skipped: false, + process: process.length === 0 ? null : aggregateTrajectoryProcessMetrics(process), }), ); auditRow[arm] = passed; diff --git a/packages/skill-intelligence/src/index.ts b/packages/skill-intelligence/src/index.ts index 1e65645d..fa9aec39 100644 --- a/packages/skill-intelligence/src/index.ts +++ b/packages/skill-intelligence/src/index.ts @@ -9,6 +9,7 @@ export * from "./observation-groups.js"; export * from "./study-drafts.js"; export * from "./paired-replay.js"; export * from "./blind-benchmark.js"; +export * from "./trajectory-process.js"; export { VerifierControlDecision, VerifierControlLabel, diff --git a/packages/skill-intelligence/src/paired-replay.ts b/packages/skill-intelligence/src/paired-replay.ts index d3fe06c9..be49b037 100644 --- a/packages/skill-intelligence/src/paired-replay.ts +++ b/packages/skill-intelligence/src/paired-replay.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { VerifierQualificationResult } from "./verifier-instruments.js"; +import { TrajectoryProcessMetrics } from "./trajectory-process.js"; const Identifier = Schema.String.check( Schema.isNonEmpty(), @@ -53,6 +54,7 @@ export const ReplayScoredAttempt = Schema.Struct({ kind: Schema.Literal("scored"), passed: Schema.Boolean, executed_revision: Revision, + process: Schema.optionalKey(TrajectoryProcessMetrics), }); export const ReplayInfrastructureAttempt = Schema.Struct({ kind: Schema.Literal("infrastructure"), @@ -118,6 +120,8 @@ export const ManagedPairedReplayTrial = Schema.Struct({ pre_edit_attempts: Schema.Number, post_edit_attempts: Schema.Number, censored_attempts: Schema.Number, + pre_edit_process: Schema.optionalKey(Schema.NullOr(TrajectoryProcessMetrics)), + post_edit_process: Schema.optionalKey(Schema.NullOr(TrajectoryProcessMetrics)), }); export type ManagedPairedReplayTrial = typeof ManagedPairedReplayTrial.Type; @@ -135,7 +139,13 @@ export const ManagedPairedReplayResult = Schema.Struct({ export type ManagedPairedReplayResult = typeof ManagedPairedReplayResult.Type; type ArmRun = - | { kind: "scored"; passed: boolean; attempts: number; censored: number } + | { + kind: "scored"; + passed: boolean; + attempts: number; + censored: number; + process: TrajectoryProcessMetrics | null; + } | { kind: "infra_exhausted"; attempts: number; censored: number } | { kind: "cancelled"; attempts: number; censored: number } | { kind: "budget_exhausted"; attempts: number; censored: number } @@ -276,7 +286,13 @@ function runArm(input: { if (outcome.executed_revision !== input.revision) { return { kind: "revision_mismatch", attempts: attempt, censored }; } - return { kind: "scored", passed: outcome.passed, attempts: attempt, censored }; + return { + kind: "scored", + passed: outcome.passed, + attempts: attempt, + censored, + process: outcome.process ?? null, + }; } if (outcome.kind === "cancelled") return { kind: "cancelled", attempts: attempt, censored }; if (outcome.kind === "budget_exhausted") { @@ -361,6 +377,8 @@ export function runManagedPairedReplay( pre_edit_attempts: pre.attempts, post_edit_attempts: post.attempts, censored_attempts: pre.censored + post.censored, + pre_edit_process: pre.kind === "scored" ? pre.process : null, + post_edit_process: post.kind === "scored" ? post.process : null, }), ); const terminal = terminalReason(pre) ?? terminalReason(post); diff --git a/packages/skill-intelligence/src/study-drafts.ts b/packages/skill-intelligence/src/study-drafts.ts index 91ed9064..20ba95a8 100644 --- a/packages/skill-intelligence/src/study-drafts.ts +++ b/packages/skill-intelligence/src/study-drafts.ts @@ -124,85 +124,12 @@ export const StudyDraft = Schema.Struct({ }); export type StudyDraft = typeof StudyDraft.Type; -const PRIVATE_KEY_BEGIN = "-----BEGIN "; -const PRIVATE_KEY_END = "-----END "; -const PRIVATE_KEY_LABEL = "PRIVATE KEY"; -const PRIVATE_KEY_FENCE = "-----"; -const PRIVATE_KEY_REPLACEMENT = "[redacted-private-key]"; - -interface TextRange { - readonly start: number; - readonly end: number; -} - -function matchesAsciiCaseInsensitive(value: string, start: number, expected: string): boolean { - if (start < 0 || start + expected.length > value.length) return false; - for (let offset = 0; offset < expected.length; offset += 1) { - const actualCode = value.charCodeAt(start + offset); - const upperCode = actualCode >= 97 && actualCode <= 122 ? actualCode - 32 : actualCode; - if (upperCode !== expected.charCodeAt(offset)) return false; - } - return true; -} - -function isAsciiLetterOrSpace(value: string, index: number): boolean { - const code = value.charCodeAt(index); - return code === 32 || (code >= 65 && code <= 90) || (code >= 97 && code <= 122); -} - -function findPrivateKeyMarker( - value: string, - prefix: typeof PRIVATE_KEY_BEGIN | typeof PRIVATE_KEY_END, - fromIndex: number, -): TextRange | null { - const lastCandidate = value.length - prefix.length; - for (let candidate = Math.max(0, fromIndex); candidate <= lastCandidate; candidate += 1) { - if (!matchesAsciiCaseInsensitive(value, candidate, prefix)) continue; - - const labelStart = candidate + prefix.length; - let cursor = labelStart; - while (cursor < value.length && isAsciiLetterOrSpace(value, cursor)) cursor += 1; - - const keyLabelStart = cursor - PRIVATE_KEY_LABEL.length; - if ( - keyLabelStart >= labelStart && - matchesAsciiCaseInsensitive(value, keyLabelStart, PRIVATE_KEY_LABEL) && - value.startsWith(PRIVATE_KEY_FENCE, cursor) - ) { - return { start: candidate, end: cursor + PRIVATE_KEY_FENCE.length }; - } - - // A marker prefix cannot start inside the ASCII label run, so skip it in one pass. - candidate = Math.max(candidate, cursor - 1); - } - return null; -} - -function findPrivateKeyBlock(value: string, fromIndex = 0): TextRange | null { - const begin = findPrivateKeyMarker(value, PRIVATE_KEY_BEGIN, fromIndex); - if (begin === null) return null; - const end = findPrivateKeyMarker(value, PRIVATE_KEY_END, begin.end); - return end === null ? null : { start: begin.start, end: end.end }; -} - -function redactPrivateKeyBlocks(value: string): string { - let cursor = 0; - const output: string[] = []; - while (cursor < value.length) { - const block = findPrivateKeyBlock(value, cursor); - if (block === null) { - if (output.length === 0) return value; - output.push(value.slice(cursor)); - return output.join(""); - } - output.push(value.slice(cursor, block.start), PRIVATE_KEY_REPLACEMENT); - cursor = block.end; - } - return output.join(""); -} - function boundedRedactedText(value: string, maximumLength: number): string { - return redactPrivateKeyBlocks(value) + return value + .replace( + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, + "[redacted-private-key]", + ) .replace( /\b(?:api[_-]?key|token|secret|password|authorization|cookie|signature)\s*[:=]\s*[^\s,;]+/gi, "[redacted]", diff --git a/packages/skill-intelligence/src/trajectory-process.ts b/packages/skill-intelligence/src/trajectory-process.ts new file mode 100644 index 00000000..50a27c07 --- /dev/null +++ b/packages/skill-intelligence/src/trajectory-process.ts @@ -0,0 +1,55 @@ +import * as Schema from "effect/Schema"; + +const Count = Schema.Number.check( + Schema.isFinite(), + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(0), +); + +/** + * Harness-neutral process evidence retained beside an outcome score. These + * counters explain how an arm operated; they are not collapsed into a reward. + */ +export const TrajectoryProcessMetrics = Schema.Struct({ + turns: Count, + input_tokens: Count, + output_tokens: Count, + tool_calls: Count, + failed_tool_calls: Count, + repeated_actions: Count, + user_corrections: Count, + progress_events: Count, + wall_time_ms: Count, +}); +export type TrajectoryProcessMetrics = typeof TrajectoryProcessMetrics.Type; + +export const emptyTrajectoryProcessMetrics = (): TrajectoryProcessMetrics => ({ + turns: 0, + input_tokens: 0, + output_tokens: 0, + tool_calls: 0, + failed_tool_calls: 0, + repeated_actions: 0, + user_corrections: 0, + progress_events: 0, + wall_time_ms: 0, +}); + +export function aggregateTrajectoryProcessMetrics( + entries: ReadonlyArray, +): TrajectoryProcessMetrics { + return entries.reduce( + (total, entry) => ({ + turns: total.turns + entry.turns, + input_tokens: total.input_tokens + entry.input_tokens, + output_tokens: total.output_tokens + entry.output_tokens, + tool_calls: total.tool_calls + entry.tool_calls, + failed_tool_calls: total.failed_tool_calls + entry.failed_tool_calls, + repeated_actions: total.repeated_actions + entry.repeated_actions, + user_corrections: total.user_corrections + entry.user_corrections, + progress_events: total.progress_events + entry.progress_events, + wall_time_ms: total.wall_time_ms + entry.wall_time_ms, + }), + emptyTrajectoryProcessMetrics(), + ); +} diff --git a/packages/ui/components.json b/packages/ui/components.json index 4637276a..8c4b04ba 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -21,5 +21,7 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": { + "@interior": "https://www.interior.dev/r/{name}.json" + } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 9dc88191..25bbde43 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,11 +18,10 @@ "./lib": "./src/lib/index.ts", "./types": "./src/types.ts" }, - "scripts": { - "test": "vitest run" - }, "dependencies": { "@base-ui/react": "^1.3.0", + "@pierre/diffs": "^1.1.19", + "@pierre/trees": "^1.0.0-beta.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", diff --git a/packages/ui/src/components/EvidenceViewer.test.tsx b/packages/ui/src/components/EvidenceViewer.test.tsx deleted file mode 100644 index 0fecc064..00000000 --- a/packages/ui/src/components/EvidenceViewer.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseFrontmatter } from "./EvidenceViewer"; - -describe("parseFrontmatter", () => { - it("parses CRLF frontmatter without changing metadata or body", () => { - expect( - parseFrontmatter("---\r\nname: Example\r\ndescription: includes: colon\r\n---\r\n# Body"), - ).toEqual({ - meta: { - name: "Example", - description: "includes: colon", - }, - body: "# Body", - }); - }); - - it("scans adversarial close-looking lines without accepting them as delimiters", () => { - const closeLookingLines = "---not-a-delimiter\n".repeat(20_000); - const text = `---\nname: Example\n${closeLookingLines}---\n# Body`; - - expect(parseFrontmatter(text)).toEqual({ - meta: { name: "Example" }, - body: "# Body", - }); - }); - - it("leaves unterminated frontmatter unchanged", () => { - const text = `---\nname: Example\n${"---not-a-delimiter\n".repeat(20_000)}`; - - expect(parseFrontmatter(text)).toEqual({ meta: {}, body: text }); - }); - - it("consumes whitespace-only lines after the closing delimiter but preserves body indentation", () => { - expect(parseFrontmatter("---\nname: Example\n---\n \t\n\r\n Body")).toEqual({ - meta: { name: "Example" }, - body: " Body", - }); - }); - - it("preserves greedy opening-delimiter whitespace before close-looking metadata", () => { - expect(parseFrontmatter("---\n\n---\nname: value\n---\n")).toEqual({ - meta: { name: "value" }, - body: "", - }); - }); - - it("backtracks opening whitespace when its greedy choice leaves no later closing delimiter", () => { - expect(parseFrontmatter("---\n\n---\nbody")).toEqual({ - meta: {}, - body: "body", - }); - }); -}); diff --git a/packages/ui/src/components/EvidenceViewer.tsx b/packages/ui/src/components/EvidenceViewer.tsx index 292ada64..6e2ed931 100644 --- a/packages/ui/src/components/EvidenceViewer.tsx +++ b/packages/ui/src/components/EvidenceViewer.tsx @@ -20,63 +20,16 @@ interface Props { evidence: EvidenceEntry[]; } -interface FrontmatterClosingDelimiter { - readonly bodyStart: number; - readonly metadataEnd: number; -} - -function isRegexWhitespace(character: string): boolean { - return character.trim().length === 0; -} - /** Parse YAML-ish frontmatter from text, returns { meta, body } */ -export function parseFrontmatter(text: string): { +function parseFrontmatter(text: string): { meta: Record; body: string; } { - if (!text.startsWith("---")) { - return { meta: {}, body: text }; - } - - const openingLineFeeds: number[] = []; - let cursor = 3; - while (cursor < text.length && isRegexWhitespace(text[cursor])) { - if (text[cursor] === "\n") openingLineFeeds.push(cursor); - cursor += 1; - } - if (openingLineFeeds.length === 0) return { meta: {}, body: text }; - - const closingDelimiters: FrontmatterClosingDelimiter[] = []; - for (let marker = 0; marker + 4 <= text.length; marker += 1) { - if (text[marker] !== "\n" || !text.startsWith("---", marker + 1)) continue; - let whitespaceCursor = marker + 4; - let lastLineFeed = -1; - while (whitespaceCursor < text.length && isRegexWhitespace(text[whitespaceCursor])) { - if (text[whitespaceCursor] === "\n") lastLineFeed = whitespaceCursor; - whitespaceCursor += 1; - } - if (lastLineFeed !== -1) { - closingDelimiters.push({ bodyStart: lastLineFeed + 1, metadataEnd: marker }); - } - } - const lastClosing = closingDelimiters.at(-1); - if (lastClosing === undefined) return { meta: {}, body: text }; - - let openingLineEnd = -1; - for (let index = openingLineFeeds.length - 1; index >= 0; index -= 1) { - if (openingLineFeeds[index] < lastClosing.metadataEnd) { - openingLineEnd = openingLineFeeds[index]; - break; - } - } - if (openingLineEnd === -1) return { meta: {}, body: text }; - - const closing = closingDelimiters.find((candidate) => candidate.metadataEnd > openingLineEnd); - if (closing === undefined) return { meta: {}, body: text }; - const metadataStart = openingLineEnd + 1; + const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!match) return { meta: {}, body: text }; const meta: Record = {}; - for (const line of text.slice(metadataStart, closing.metadataEnd).split("\n")) { + for (const line of match[1].split("\n")) { const idx = line.indexOf(":"); if (idx > 0) { const key = line.slice(0, idx).trim(); @@ -84,7 +37,7 @@ export function parseFrontmatter(text: string): { if (key && val) meta[key] = val; } } - return { meta, body: text.slice(closing.bodyStart) }; + return { meta, body: match[2] }; } function FrontmatterTable({ meta }: { meta: Record }) { diff --git a/packages/ui/src/components/PierreDiffReview.tsx b/packages/ui/src/components/PierreDiffReview.tsx new file mode 100644 index 00000000..3b7f9695 --- /dev/null +++ b/packages/ui/src/components/PierreDiffReview.tsx @@ -0,0 +1,108 @@ +"use client"; + +import * as React from "react"; +import { PatchDiff } from "@pierre/diffs/react"; +import { FileTree, useFileTree, useFileTreeSelection } from "@pierre/trees/react"; + +export interface PierreDiffReviewFile { + path: string; + patch: string; +} + +export interface PierreDiffReviewProps { + files: readonly PierreDiffReviewFile[]; + theme?: "light" | "dark"; + className?: string; +} + +type TreeStyle = React.CSSProperties & { [key: `--${string}`]: string }; + +const TREE_CSS = ` + [data-file-tree-search-container] { padding: 8px 0; } + [data-file-tree-search-input]:focus-visible { outline-color: var(--ring); } +`; + +const DIFF_CSS = ` + :host { + --diffs-font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace; + --diffs-font-size: 12px; + --diffs-line-height: 1.6; + } + [data-line-type="change-addition"] { + background-color: color-mix(in srgb, var(--success) 15%, transparent); + } + [data-line-type="change-deletion"] { + background-color: color-mix(in srgb, var(--destructive) 15%, transparent); + } + [data-gutter] { background: color-mix(in srgb, var(--muted) 8%, transparent); } +`; + +export function PierreDiffReview({ files, theme = "light", className }: PierreDiffReviewProps) { + const paths = React.useMemo(() => files.map((file) => file.path), [files]); + const [selectedPath, setSelectedPath] = React.useState(paths[0] ?? null); + const { model } = useFileTree({ + paths, + initialExpansion: "open", + search: paths.length > 1, + initialVisibleRowCount: 8, + unsafeCSS: TREE_CSS, + }); + const selection = useFileTreeSelection(model); + + React.useEffect(() => { + const [path] = selection; + if (path) setSelectedPath(path); + }, [selection]); + React.useEffect(() => { + if (selectedPath) model.getItem(selectedPath)?.select(); + }, [model, selectedPath]); + + const selected = files.find((file) => file.path === selectedPath) ?? files[0]; + const treeStyle: TreeStyle = { + height: "100%", + colorScheme: theme, + "--trees-fg-override": "var(--foreground)", + "--trees-fg-muted-override": "var(--muted-foreground)", + "--trees-bg-override": "var(--card)", + "--trees-bg-muted-override": "var(--secondary)", + "--trees-border-color-override": "var(--border)", + "--trees-accent-override": "var(--foreground)", + "--trees-focus-ring-color-override": "var(--ring)", + "--trees-selected-focused-border-color-override": "var(--ring)", + "--trees-search-bg-override": "var(--card)", + "--trees-search-fg-override": "var(--foreground)", + "--trees-selected-fg-override": "var(--foreground)", + "--trees-selected-bg-override": "var(--secondary)", + }; + + return ( +
    +
    +
    + Draft files +
    +
    + +
    +
    +
    + {selected ? ( + + ) : null} +
    +
    + ); +} diff --git a/packages/ui/src/components/StatusBadge.stories.tsx b/packages/ui/src/components/StatusBadge.stories.tsx new file mode 100644 index 00000000..bfc470e2 --- /dev/null +++ b/packages/ui/src/components/StatusBadge.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { StatusBadge } from "./StatusBadge"; + +const meta = { + component: StatusBadge, + tags: ["ai-generated"], + args: { tone: "healthy", children: "Healthy" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Healthy: Story = {}; +export const Warning: Story = { args: { tone: "warning", children: "Needs attention" } }; +export const Critical: Story = { args: { tone: "critical", children: "Blocked" } }; +export const Pending: Story = { args: { tone: "pending", children: "Pending review" } }; +export const Soft: Story = { args: { appearance: "soft", children: "Qualified" } }; +export const Text: Story = { args: { appearance: "text", children: "Evidence verified" } }; diff --git a/packages/ui/src/components/TriggerSparkline.test.tsx b/packages/ui/src/components/TriggerSparkline.test.tsx index bda74869..7250915d 100644 --- a/packages/ui/src/components/TriggerSparkline.test.tsx +++ b/packages/ui/src/components/TriggerSparkline.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { TriggerSparkline } from "./TriggerSparkline"; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 7ffca2c9..8972d6cc 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -46,6 +46,33 @@ export { StatusBadge, StatusDot } from "./StatusBadge"; export type { StatusBadgeAppearance, StatusTone } from "./StatusBadge"; export { UnifiedDiffViewer } from "./UnifiedDiffViewer"; export type { UnifiedDiffViewerProps } from "./UnifiedDiffViewer"; +export { PierreDiffReview } from "./PierreDiffReview"; +export type { PierreDiffReviewFile, PierreDiffReviewProps } from "./PierreDiffReview"; +export { useWizard, WizardSteps } from "./interior/wizard-steps"; +export type { + UseWizardOptions, + UseWizardReturn, + WizardDirection, + WizardStep, + WizardStepsProps, +} from "./interior/wizard-steps"; +export { LoadingButton, useAsyncAction } from "./interior/loading-button"; +export type { + AsyncActionStatus, + LoadingButtonProps, + UseAsyncActionOptions, +} from "./interior/loading-button"; +export { SkeletonSwap, useSkeletonSwap } from "./interior/skeleton-swap"; +export type { SkeletonSwapProps, UseSkeletonSwapOptions } from "./interior/skeleton-swap"; +export { SortableTable, useSortableRows } from "./interior/sortable-table"; +export type { + OrderedRow, + SortableColumn, + SortableTableProps, + SortDirection, + SortState, + UseSortableRowsOptions, +} from "./interior/sortable-table"; export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "./Empty"; export { SkillReportTopRow, diff --git a/packages/ui/src/components/interior/interior-motion-components.test.tsx b/packages/ui/src/components/interior/interior-motion-components.test.tsx new file mode 100644 index 00000000..ea38b130 --- /dev/null +++ b/packages/ui/src/components/interior/interior-motion-components.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LoadingButton } from "./loading-button"; +import { SkeletonSwap } from "./skeleton-swap"; +import { SortableTable } from "./sortable-table"; + +describe("Interior motion components", () => { + it("keeps action labels and loaded content accessible", () => { + const html = renderToStaticMarkup( + <> + undefined}>Refresh + +

    Summary loaded

    +
    + , + ); + + expect(html).toContain('aria-label="Refresh"'); + expect(html).toContain('aria-label="Team summary"'); + expect(html).toContain("Summary loaded"); + }); + + it("renders sortable rows with an accessible initial sort", () => { + const html = renderToStaticMarkup( + row.name }]} + getRowId={(row) => row.id} + label="Skills" + defaultSort={{ columnId: "name", direction: "asc" }} + />, + ); + + expect(html).toContain('role="table"'); + expect(html).toContain('aria-sort="ascending"'); + expect(html.indexOf("Alpha")).toBeLessThan(html.indexOf("Beta")); + }); +}); diff --git a/packages/ui/src/components/interior/loading-button.tsx b/packages/ui/src/components/interior/loading-button.tsx new file mode 100644 index 00000000..0bba8c45 --- /dev/null +++ b/packages/ui/src/components/interior/loading-button.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { motion, useReducedMotion } from "motion/react"; + +import { cn } from "../../lib/utils"; + +const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; +const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; +const INSTANT = { duration: 0 } as const; + +export type AsyncActionStatus = "idle" | "pending" | "success" | "error"; + +export type UseAsyncActionOptions = { + action: () => unknown; + resetAfter?: number; + onError?: (error: unknown) => void; +}; + +export function useAsyncAction({ action, resetAfter = 1400, onError }: UseAsyncActionOptions) { + const [status, setStatus] = useState("idle"); + + const phase = useRef("idle"); + const runId = useRef(0); + const timer = useRef | null>(null); + const alive = useRef(true); + + const act = useRef(action); + const fail = useRef(onError); + + useEffect(() => { + act.current = action; + fail.current = onError; + }); + + const clear = useCallback(() => { + if (timer.current) { + clearTimeout(timer.current); + timer.current = null; + } + }, []); + + const reset = useCallback(() => { + runId.current += 1; + clear(); + phase.current = "idle"; + setStatus("idle"); + }, [clear]); + + const run = useCallback(() => { + if (phase.current === "pending") return; + + clear(); + const id = ++runId.current; + phase.current = "pending"; + setStatus("pending"); + + const settle = (next: "success" | "error") => { + if (!alive.current || id !== runId.current) return; + clear(); + phase.current = next; + setStatus(next); + timer.current = setTimeout(() => { + if (!alive.current || id !== runId.current) return; + phase.current = "idle"; + setStatus("idle"); + }, resetAfter); + }; + + Promise.resolve() + .then(() => act.current()) + .then( + () => settle("success"), + (error: unknown) => { + fail.current?.(error); + settle("error"); + }, + ); + }, [clear, resetAfter]); + + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + clear(); + }; + }, [clear]); + + return { + status, + run, + reset, + pending: status === "pending", + }; +} + +function Spinner({ still }: { still: boolean }) { + return ( + + ); +} + +function CheckMark() { + return ( + + ); +} + +function AlertMark() { + return ( + + ); +} + +export type LoadingButtonProps = { + onAction: () => unknown; + children: string; + pendingLabel?: string; + successLabel?: string; + errorLabel?: string; + resetAfter?: number; + disabled?: boolean; + onError?: (error: unknown) => void; + className?: string; +}; + +export function LoadingButton({ + onAction, + children, + pendingLabel = children, + successLabel = "Done", + errorLabel = "Try again", + resetAfter = 1400, + disabled = false, + onError, + className = "", +}: LoadingButtonProps) { + const reduced = useReducedMotion(); + + const { status, run, pending } = useAsyncAction({ + action: onAction, + resetAfter, + onError, + }); + + const fade = reduced ? INSTANT : CROSSFADE; + + const label = + status === "pending" + ? pendingLabel + : status === "success" + ? successLabel + : status === "error" + ? errorLabel + : children; + + const faces = [ + { + key: "idle", + text: children, + tone: "text-foreground", + icon: null, + }, + { + key: "pending", + text: pendingLabel, + tone: "text-muted-foreground", + icon: , + }, + { + key: "success", + text: successLabel, + tone: "text-primary", + icon: , + }, + { + key: "error", + text: errorLabel, + tone: "text-destructive", + icon: , + }, + ]; + + return ( + <> + { + if (pending) { + event.preventDefault(); + return; + } + run(); + }} + className={cn( + "relative inline-flex h-9 select-none items-center justify-center rounded-[9px] border border-border bg-background px-3.5 text-[13px] font-medium text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color] duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50", + className, + )} + style={{ borderRadius: 9, touchAction: "manipulation" }} + > + + {faces.map((face) => ( + + {face.icon} + {face.text} + + ))} + + + + + {status === "success" ? successLabel : status === "error" ? errorLabel : ""} + + + ); +} diff --git a/packages/ui/src/components/interior/skeleton-swap.tsx b/packages/ui/src/components/interior/skeleton-swap.tsx new file mode 100644 index 00000000..8a8446fa --- /dev/null +++ b/packages/ui/src/components/interior/skeleton-swap.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { cn } from "../../lib/utils"; + +const CROSSFADE = { + type: "spring", + stiffness: 260, + damping: 34, + mass: 0.8, +} as const; + +const WIDTHS = [100, 93, 97, 88, 95, 91] as const; + +function widthFor(index: number, total: number) { + if (total > 1 && index === total - 1) return 62; + return WIDTHS[(index * 7 + 3) % WIDTHS.length]; +} + +export type UseSkeletonSwapOptions = { + ready: boolean; + delay?: number; + minVisible?: number; +}; + +export function useSkeletonSwap({ ready, delay = 120, minVisible = 380 }: UseSkeletonSwapOptions) { + const [visible, setVisible] = useState(false); + const shownAt = useRef(0); + + useEffect(() => { + if (!ready) { + if (visible) return; + const t = setTimeout(() => { + shownAt.current = performance.now(); + setVisible(true); + }, delay); + return () => clearTimeout(t); + } + + if (!visible) return; + const rest = Math.max(0, minVisible - (performance.now() - shownAt.current)); + const t = setTimeout(() => setVisible(false), rest); + return () => clearTimeout(t); + }, [ready, visible, delay, minVisible]); + + return { showSkeleton: visible, busy: !ready }; +} + +export type SkeletonSwapProps = { + ready: boolean; + children: React.ReactNode; + lines?: number; + lineHeight?: number; + barHeight?: number; + reserve?: number; + delay?: number; + minVisible?: number; + label?: string; + skeleton?: React.ReactNode; + className?: string; +}; + +export function SkeletonSwap({ + ready, + children, + lines = 3, + lineHeight = 21, + barHeight = 9, + reserve, + delay = 120, + minVisible = 380, + label, + skeleton, + className = "", +}: SkeletonSwapProps) { + const { showSkeleton } = useSkeletonSwap({ ready, delay, minVisible }); + const reduced = useReducedMotion(); + + const shell = useRef(null); + const body = useRef(null); + const [scrollable, setScrollable] = useState(false); + + const box = reserve ?? lines * lineHeight; + + useEffect(() => { + const el = shell.current; + const inner = body.current; + if (!el || typeof ResizeObserver === "undefined") return; + + const check = () => setScrollable(el.scrollHeight - el.clientHeight > 1); + check(); + + const ro = new ResizeObserver(check); + ro.observe(el); + if (inner) ro.observe(inner); + return () => ro.disconnect(); + }, []); + + return ( +
    + + {children} + + + + {showSkeleton ? ( + + {skeleton ?? ( +
    + {Array.from({ length: lines }, (_, i) => ( +
    +
    +
    + ))} +
    + )} + + ) : null} + + + {label ? ( + + {ready ? `${label} loaded` : ""} + + ) : null} +
    + ); +} diff --git a/packages/ui/src/components/interior/sortable-table.tsx b/packages/ui/src/components/interior/sortable-table.tsx new file mode 100644 index 00000000..8e5baf02 --- /dev/null +++ b/packages/ui/src/components/interior/sortable-table.tsx @@ -0,0 +1,442 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { motion, useReducedMotion } from "motion/react"; + +import { cn } from "../../lib/utils"; + +const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; + +const SMALL = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; + +const EASE = [0.23, 1, 0.32, 1] as const; +const LEAVE = [0.4, 0, 1, 1] as const; +const HIDE = { duration: 0.12, ease: LEAVE } as const; +const SHOW = { duration: 0.25, ease: EASE } as const; + +const STEP = 0.018; +const STEP_CAP = 8; +const SETTLE_MS = 380; + +export type SortDirection = "asc" | "desc"; + +export type SortState = { columnId: string; direction: SortDirection }; + +export type SortableColumn = { + id: string; + header: string; + width?: string; + align?: "start" | "end"; + numeric?: boolean; + sortable?: boolean; + value?: (row: T) => string | number | null | undefined; + cell?: (row: T) => ReactNode; +}; + +export type OrderedRow = { id: string; row: T; index: number }; + +export type UseSortableRowsOptions = { + rows: T[]; + getRowId: (row: T) => string; + getValue: (row: T, columnId: string) => string | number | null | undefined; + sort?: SortState | null; + defaultSort?: SortState | null; + onSortChange?: (next: SortState | null) => void; + restoreOriginal?: boolean; +}; + +export function useSortableRows({ + rows, + getRowId, + getValue, + sort, + defaultSort = null, + onSortChange, + restoreOriginal = true, +}: UseSortableRowsOptions) { + const [internal, setInternal] = useState(defaultSort); + + const controlled = sort !== undefined; + const current = controlled ? sort : internal; + + const collator = useMemo( + () => new Intl.Collator("en", { numeric: true, sensitivity: "base" }), + [], + ); + + const ordered = useMemo[]>(() => { + const base = rows.map((row, i) => ({ id: getRowId(row), row, i })); + + if (current) { + const dir = current.direction === "asc" ? 1 : -1; + base.sort((x, y) => { + const a = getValue(x.row, current.columnId); + const b = getValue(y.row, current.columnId); + const emptyA = a === null || a === undefined || a === ""; + const emptyB = b === null || b === undefined || b === ""; + if (emptyA || emptyB) { + if (emptyA && emptyB) return x.i - y.i; + return emptyA ? 1 : -1; + } + const d = + typeof a === "number" && typeof b === "number" + ? a - b + : collator.compare(String(a), String(b)); + return d === 0 ? x.i - y.i : d * dir; + }); + } + + return base.map(({ id, row }, index) => ({ id, row, index })); + }, [rows, current, getRowId, getValue, collator]); + + const toggle = useCallback( + (columnId: string) => { + const next: SortState | null = + !current || current.columnId !== columnId + ? { columnId, direction: "asc" } + : current.direction === "asc" + ? { columnId, direction: "desc" } + : restoreOriginal + ? null + : { columnId, direction: "asc" }; + + if (!controlled) setInternal(next); + onSortChange?.(next); + }, + [current, controlled, onSortChange, restoreOriginal], + ); + + const ariaSort = useCallback( + (columnId: string): "ascending" | "descending" | "none" => + current?.columnId === columnId + ? current.direction === "asc" + ? "ascending" + : "descending" + : "none", + [current], + ); + + return { sort: current, ordered, toggle, ariaSort }; +} + +export type SortableTableProps = { + rows: T[]; + columns: SortableColumn[]; + getRowId: (row: T) => string; + label: string; + rowHeight?: number; + maxHeight?: number; + sort?: SortState | null; + defaultSort?: SortState | null; + onSortChange?: (next: SortState | null) => void; + markable?: boolean; + onMarkChange?: (id: string | null) => void; + getRowLabel?: (row: T) => string; + className?: string; +}; + +export function SortableTable({ + rows, + columns, + getRowId, + label, + rowHeight = 44, + maxHeight, + sort, + defaultSort = null, + onSortChange, + markable = false, + onMarkChange, + getRowLabel, + className = "", +}: SortableTableProps) { + const reduced = useReducedMotion(); + const [marked, setMarked] = useState(null); + const [touched, setTouched] = useState(false); + const [moving, setMoving] = useState(false); + const settleTimer = useRef | null>(null); + + useEffect( + () => () => { + if (settleTimer.current) clearTimeout(settleTimer.current); + }, + [], + ); + + const getValue = useCallback( + (row: T, columnId: string) => { + const column = columns.find((c) => c.id === columnId); + return column?.value ? column.value(row) : null; + }, + [columns], + ); + + const { + sort: current, + ordered, + toggle, + ariaSort, + } = useSortableRows({ + rows, + getRowId, + getValue, + sort, + defaultSort, + onSortChange, + }); + + const template = useMemo( + () => (markable ? "28px " : "") + columns.map((c) => c.width ?? "minmax(0, 1fr)").join(" "), + [columns, markable], + ); + + const onToggle = (columnId: string) => { + setTouched(true); + toggle(columnId); + if (reduced) return; + setMoving(true); + if (settleTimer.current) clearTimeout(settleTimer.current); + settleTimer.current = setTimeout(() => setMoving(false), SETTLE_MS); + }; + + const onMark = (id: string) => { + const next = marked === id ? null : id; + setMarked(next); + onMarkChange?.(next); + }; + + const nameOf = (row: T) => + getRowLabel?.(row) ?? String(columns[0]?.value?.(row) ?? getRowId(row)); + + const activeHeader = columns.find((c) => c.id === current?.columnId)?.header; + + const message = !touched + ? "" + : current && activeHeader + ? `Sorted by ${activeHeader}, ${ + current.direction === "asc" ? "ascending" : "descending" + }. ${rows.length} rows.` + : `Original order restored. ${rows.length} rows.`; + + return ( +
    +
    +
    +
    + {markable && ( +
    + Follow +
    + )} + + {columns.map((column) => { + const state = ariaSort(column.id); + const active = state !== "none"; + const end = column.align === "end"; + + return ( +
    + {column.sortable === false ? ( + + {column.header} + + ) : ( + + )} +
    + ); + })} +
    +
    +
    + {rows.length === 0 && ( +
    + + No rows + +
    + )} + + {ordered.map(({ id, row, index }) => { + const isMarked = markable && marked === id; + + return ( + + {markable && ( +
    + +
    + )} + + {columns.map((column, c) => { + const raw = column.value?.(row); + const content = column.cell + ? column.cell(row) + : raw === null || raw === undefined || raw === "" + ? "—" + : String(raw); + + return ( +
    + {content} +
    + ); + })} +
    + ); + })} + + + {Array.from({ length: Math.max(0, rows.length - 1) }, (_, i) => ( +
    + ))} + +
    +
    +
    + {message} +
    +
    + ); +} diff --git a/packages/ui/src/components/interior/wizard-steps.test.tsx b/packages/ui/src/components/interior/wizard-steps.test.tsx new file mode 100644 index 00000000..114bde75 --- /dev/null +++ b/packages/ui/src/components/interior/wizard-steps.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { WizardSteps } from "./wizard-steps"; + +describe("WizardSteps", () => { + it("renders the accessible step rail and first step content", () => { + const html = renderToStaticMarkup( + Inspect the exact revision.

    }, + { id: "evidence", label: "Evidence", content:

    Check the evidence.

    }, + { id: "decide", label: "Decide", content:

    Choose the outcome.

    }, + ]} + />, + ); + + expect(html).toContain('aria-label="Steps"'); + expect(html).toContain("Step 1 of 3: Inspect"); + expect(html).toContain("Inspect the exact revision."); + expect(html).toContain("Step 3 of 3: Decide"); + }); +}); diff --git a/packages/ui/src/components/interior/wizard-steps.tsx b/packages/ui/src/components/interior/wizard-steps.tsx new file mode 100644 index 00000000..9a5c85fe --- /dev/null +++ b/packages/ui/src/components/interior/wizard-steps.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent, ReactNode } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { cn } from "../../lib/utils"; + +const EASE = [0.23, 1, 0.32, 1] as const; +const EXIT_EASE = [0.4, 0, 1, 1] as const; + +const RAIL = { type: "spring", stiffness: 520, damping: 40, mass: 0.5 } as const; +const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; + +export type WizardDirection = 1 | -1; + +export type UseWizardOptions = { + total: number; + index?: number; + defaultIndex?: number; + onIndexChange?: (index: number, direction: WizardDirection) => void; + onComplete?: () => void; +}; + +export type UseWizardReturn = { + index: number; + direction: WizardDirection; + furthest: number; + total: number; + isFirst: boolean; + isLast: boolean; + next: () => void; + back: () => void; + goTo: (index: number) => void; +}; + +function clampIndex(value: number, total: number) { + if (total < 1) return 0; + return Math.max(0, Math.min(total - 1, Math.trunc(value))); +} + +export function useWizard({ + total, + index, + defaultIndex = 0, + onIndexChange, + onComplete, +}: UseWizardOptions): UseWizardReturn { + const [internal, setInternal] = useState(() => clampIndex(defaultIndex, total)); + const current = clampIndex(index ?? internal, total); + + const [seen, setSeen] = useState<{ index: number; direction: WizardDirection }>({ + index: current, + direction: 1, + }); + if (seen.index !== current) { + setSeen({ index: current, direction: current > seen.index ? 1 : -1 }); + } + + const [furthest, setFurthest] = useState(current); + if (furthest < current) setFurthest(current); + + const emit = useRef(onIndexChange); + emit.current = onIndexChange; + const finish = useRef(onComplete); + finish.current = onComplete; + + const controlled = index !== undefined; + + const goTo = useCallback( + (to: number) => { + const target = clampIndex(to, total); + if (target === current) return; + const direction: WizardDirection = target > current ? 1 : -1; + if (!controlled) setInternal(target); + emit.current?.(target, direction); + }, + [controlled, current, total], + ); + + const next = useCallback(() => { + if (current >= total - 1) { + finish.current?.(); + return; + } + goTo(current + 1); + }, [current, goTo, total]); + + const back = useCallback(() => goTo(current - 1), [current, goTo]); + + return { + index: current, + direction: seen.direction, + furthest: Math.min(furthest, Math.max(total - 1, 0)), + total, + isFirst: current === 0, + isLast: current === total - 1, + next, + back, + goTo, + }; +} + +export type WizardStep = { + id: string; + label: string; + content: ReactNode; +}; + +export type WizardStepsProps = { + steps: WizardStep[]; + index?: number; + defaultIndex?: number; + onIndexChange?: (index: number, direction: WizardDirection) => void; + onComplete?: () => void; + + complete?: boolean; + height?: number; + backLabel?: string; + nextLabel?: string; + finishLabel?: string; + completeLabel?: string; + completeHint?: string; + label?: string; + className?: string; +}; + +export function WizardSteps({ + steps, + index, + defaultIndex = 0, + onIndexChange, + onComplete, + complete = false, + height = 184, + backLabel = "Back", + nextLabel = "Next", + finishLabel = "Finish", + completeLabel = "All set", + completeHint = "Step back to change anything", + label = "Steps", + className = "", +}: WizardStepsProps) { + const wizard = useWizard({ + total: steps.length, + index, + defaultIndex, + onIndexChange, + onComplete, + }); + const reduced = useReducedMotion(); + + const listRef = useRef(null); + const viewportRef = useRef(null); + const intent = useRef<"list" | "panel" | null>(null); + + const { index: at, direction, furthest, total, isFirst, isLast, next, back, goTo } = wizard; + + useEffect(() => { + const move = intent.current; + intent.current = null; + if (move === "list") { + listRef.current?.querySelector('button[data-current="true"]')?.focus(); + return; + } + if (move === "panel") viewportRef.current?.focus({ preventScroll: true }); + }, [at]); + + const variants = useMemo( + () => ({ + enter: (d: WizardDirection) => (reduced ? { opacity: 0 } : { opacity: 0, x: d * 22 }), + center: reduced ? { opacity: 1 } : { opacity: 1, x: 0 }, + exit: (d: WizardDirection) => + reduced + ? { opacity: 0, transition: { duration: 0 } } + : { + opacity: 0, + x: d * -22, + transition: { duration: 0.14, ease: EXIT_EASE }, + }, + }), + [reduced], + ); + + const panelTransition = reduced ? { duration: 0 } : CROSSFADE; + + const onStepKeyDown = (e: KeyboardEvent) => { + let target = at; + if (e.key === "ArrowRight" || e.key === "ArrowDown") target = at + 1; + else if (e.key === "ArrowLeft" || e.key === "ArrowUp") target = at - 1; + else if (e.key === "Home") target = 0; + else if (e.key === "End") target = furthest; + else return; + e.preventDefault(); + target = Math.min(clampIndex(target, total), furthest); + if (target === at) return; + intent.current = "list"; + goTo(target); + }; + + const step = steps[at]; + if (!step) return null; + + const position = `Step ${at + 1} of ${total}: ${step.label}`; + + return ( +
    +

    + {position} +

    + + {steps.map((s, i) => ( + + {s.label} + + ))} + +
      + {steps.map((s, i) => { + const done = complete || i < at; + const here = !complete && i === at; + + const tile = ( + + {done ? ( + + ) : ( + i + 1 + )} + + ); + + return ( +
    1. + {i <= furthest ? ( + + ) : ( + + {`Step ${i + 1} of ${total}: ${s.label}`} + {tile} + + )} + + {i < total - 1 ? ( + + + + ) : null} +
    2. + ); + })} +
    +
    + + + {complete ? ( +
    +

    {completeLabel}

    +

    {completeHint}

    +
    + ) : ( + step.content + )} +
    +
    +
    +
    + + {isFirst ? null : ( + { + intent.current = "panel"; + back(); + }} + className="h-9 rounded-[9px] border border-border bg-background px-3 text-[13px] font-medium text-foreground outline-none transition-[border-color,box-shadow] duration-150 hover:border-foreground/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" + > + {backLabel} + + )} + + + {complete ? null : ( + { + if (!isLast) intent.current = "panel"; + next(); + }} + initial={{ opacity: 0, scale: 0.96 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ + opacity: 0, + scale: 0.96, + transition: reduced ? { duration: 0 } : { duration: 0.14, ease: EXIT_EASE }, + }} + transition={reduced ? { duration: 0 } : CROSSFADE} + className="ml-auto grid h-9 place-items-center rounded-[9px] bg-primary px-3.5 text-[13px] font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" + > + + {finishLabel.length > nextLabel.length ? finishLabel : nextLabel} + + + {nextLabel} + + + {finishLabel} + + + )} + +
    +
    + ); +} diff --git a/packages/ui/src/primitives/badge.stories.tsx b/packages/ui/src/primitives/badge.stories.tsx new file mode 100644 index 00000000..a19d3811 --- /dev/null +++ b/packages/ui/src/primitives/badge.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Badge } from "./badge"; + +const meta = { + component: Badge, + tags: ["ai-generated"], + args: { children: "Qualified" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Secondary: Story = { args: { variant: "secondary", children: "Candidate" } }; +export const Warning: Story = { args: { variant: "warning", children: "Needs review" } }; +export const Destructive: Story = { args: { variant: "destructive", children: "Failed" } }; +export const Outline: Story = { args: { variant: "outline", children: "No winner" } }; diff --git a/packages/ui/src/primitives/button.stories.tsx b/packages/ui/src/primitives/button.stories.tsx new file mode 100644 index 00000000..f6fb5502 --- /dev/null +++ b/packages/ui/src/primitives/button.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect } from "storybook/test"; + +import { Button } from "./button"; + +const meta = { + component: Button, + tags: ["ai-generated"], + args: { children: "Improve skill" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = {}; +export const Secondary: Story = { args: { variant: "secondary", children: "Review evidence" } }; +export const Destructive: Story = { args: { variant: "destructive", children: "Discard draft" } }; +export const Disabled: Story = { args: { disabled: true, children: "Improving" } }; + +export const CssCheck: Story = { + args: { children: "Verify styling" }, + play: async ({ canvas }) => { + const button = canvas.getByRole("button", { name: "Verify styling" }); + await expect(getComputedStyle(button).backgroundColor).not.toBe("transparent"); + }, +}; diff --git a/packages/ui/src/primitives/card.stories.tsx b/packages/ui/src/primitives/card.stories.tsx new file mode 100644 index 00000000..bba20b05 --- /dev/null +++ b/packages/ui/src/primitives/card.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Button } from "./button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./card"; + +const meta = { + component: Card, + tags: ["ai-generated"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const EvidenceSummary: Story = { + render: () => ( + + + Evaluation complete + The candidate passed all sealed holdouts. + + + + + 12 of 12 checks passed with no regressions. + Ready for review + + ), +}; + +export const Compact: Story = { + render: () => ( + + + No winner + + The incumbent remains installed. + + ), +}; diff --git a/packages/ui/src/primitives/checkbox.stories.tsx b/packages/ui/src/primitives/checkbox.stories.tsx new file mode 100644 index 00000000..c00534c4 --- /dev/null +++ b/packages/ui/src/primitives/checkbox.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect } from "storybook/test"; + +import { Checkbox } from "./checkbox"; + +const meta = { + component: Checkbox, + tags: ["ai-generated"], + args: { "aria-label": "Use sealed holdout" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unchecked: Story = {}; +export const Checked: Story = { + args: { defaultChecked: true }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("checkbox", { name: "Use sealed holdout" })).toBeChecked(); + }, +}; +export const Disabled: Story = { args: { disabled: true } }; diff --git a/packages/ui/src/primitives/field.tsx b/packages/ui/src/primitives/field.tsx new file mode 100644 index 00000000..3c30bcc0 --- /dev/null +++ b/packages/ui/src/primitives/field.tsx @@ -0,0 +1,65 @@ +import type { ComponentProps, ReactNode } from "react"; + +import { cn } from "../lib/utils"; +import { Label } from "./label"; +import { Separator } from "./separator"; + +function FieldGroup({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ); +} + +function Field({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ); +} + +function FieldLabel({ className, ...props }: ComponentProps) { + return ( + open: (databasePath: string) => Promise, + warnings: string[], +): DuckDbWalRecoveryDependencies { + return { + copyFile, + lstat, + mkdir, + now: () => new Date("2026-08-01T12:34:56.789Z"), + open, + randomUuid: () => "11111111-2222-4333-8444-555555555555", + rename, + warn: (message) => warnings.push(message), + }; +} + +test("preserves the checkpoint and quarantines a known corrupt DuckDB WAL before retrying once", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-wal-recovery-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const walPath = `${databasePath}.wal`; + writeFileSync(databasePath, "checkpoint"); + writeFileSync(walPath, "unreplayable wal"); + const warnings: string[] = []; + let opens = 0; + + const opened = await openDuckDbWithWalRecovery( + databasePath, + recoveryDependencies(async () => { + opens += 1; + if (opens === 1) throw knownWalReplayFailure(); + return { generation: opens }; + }, warnings), + ); + + const backupRoot = join(directory, "backups"); + const backupNames = readdirSync(backupRoot); + expect(opened).toEqual({ generation: 2 }); + expect(opens).toBe(2); + expect(backupNames).toEqual([ + "duckdb-wal-recovery-20260801T123456789Z-11111111-2222-4333-8444-555555555555", + ]); + const backupDirectory = join(backupRoot, backupNames[0]!); + expect(readFileSync(join(backupDirectory, "observability.duckdb"), "utf8")).toBe("checkpoint"); + expect(readFileSync(join(backupDirectory, "observability.duckdb.wal"), "utf8")).toBe( + "unreplayable wal", + ); + expect(readFileSync(databasePath, "utf8")).toBe("checkpoint"); + expect(existsSync(walPath)).toBe(false); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(backupDirectory); +}); + +test("keeps the observed newer-engine ART replay signature inside the narrow recovery allowlist", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-art-wal-recovery-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const walPath = `${databasePath}.wal`; + writeFileSync(databasePath, "checkpoint"); + writeFileSync(walPath, "unreplayable wal"); + const warnings: string[] = []; + let opens = 0; + + const opened = await openDuckDbWithWalRecovery( + databasePath, + recoveryDependencies(async () => { + opens += 1; + if (opens === 1) throw knownNewerEngineWalReplayFailure(); + return { generation: opens }; + }, warnings), + ); + + expect(opened).toEqual({ generation: 2 }); + expect(existsSync(walPath)).toBe(false); + expect(warnings).toHaveLength(1); +}); + +test("fails closed without touching files for an unrelated DuckDB startup error", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-wal-nonmatch-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const walPath = `${databasePath}.wal`; + writeFileSync(databasePath, "checkpoint"); + writeFileSync(walPath, "healthy wal"); + const failure = new Error( + 'TransactionContext Error: Failure while replaying WAL file "/tmp/observability.duckdb.wal": Failed to commit: Corrupted unique ART index "some_other_index": unexpected node shape', + ); + const warnings: string[] = []; + + await expect( + openDuckDbWithWalRecovery( + databasePath, + recoveryDependencies(async () => { + throw failure; + }, warnings), + ), + ).rejects.toBe(failure); + + expect(readFileSync(walPath, "utf8")).toBe("healthy wal"); + expect(existsSync(join(directory, "backups"))).toBe(false); + expect(warnings).toEqual([]); +}); + +test("refuses recovery when the WAL is not a regular non-symlink file", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-wal-unsafe-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const walPath = `${databasePath}.wal`; + const realWalPath = join(directory, "unexpected.wal"); + writeFileSync(databasePath, "checkpoint"); + writeFileSync(realWalPath, "unsafe wal"); + symlinkSync(realWalPath, walPath); + const warnings: string[] = []; + + await expect( + openDuckDbWithWalRecovery( + databasePath, + recoveryDependencies(async () => { + throw knownWalReplayFailure(); + }, warnings), + ), + ).rejects.toThrow("regular non-symlink file"); + + expect(existsSync(walPath)).toBe(true); + expect(readFileSync(realWalPath, "utf8")).toBe("unsafe wal"); + expect(warnings).toEqual([]); +}); + +test("refuses recovery when the checkpoint database is a symlink", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-checkpoint-unsafe-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const realDatabasePath = join(directory, "unexpected.duckdb"); + writeFileSync(realDatabasePath, "unsafe checkpoint"); + symlinkSync(realDatabasePath, databasePath); + writeFileSync(`${databasePath}.wal`, "unreplayable wal"); + const warnings: string[] = []; + + await expect( + openDuckDbWithWalRecovery( + databasePath, + recoveryDependencies(async () => { + throw knownWalReplayFailure(); + }, warnings), + ), + ).rejects.toThrow("regular non-symlink file"); + + expect(readFileSync(realDatabasePath, "utf8")).toBe("unsafe checkpoint"); + expect(warnings).toEqual([]); +}); + +test("keeps the quarantined WAL recoverable when the one retry also fails", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-wal-retry-failure-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const walPath = `${databasePath}.wal`; + writeFileSync(databasePath, "checkpoint"); + writeFileSync(walPath, "unreplayable wal"); + const warnings: string[] = []; + const retryFailure = new Error("checkpoint also failed to open"); + let opens = 0; + + await expect( + openDuckDbWithWalRecovery( + databasePath, + recoveryDependencies(async () => { + opens += 1; + if (opens === 1) throw knownWalReplayFailure(); + throw retryFailure; + }, warnings), + ), + ).rejects.toBe(retryFailure); + + const backupDirectory = join(directory, "backups", readdirSync(join(directory, "backups"))[0]!); + expect(existsSync(walPath)).toBe(false); + expect(readFileSync(join(backupDirectory, "observability.duckdb.wal"), "utf8")).toBe( + "unreplayable wal", + ); + expect(warnings[0]).toContain(backupDirectory); +}); + +test("an ENOENT WAL race retries after another process already moved the WAL", async () => { + const directory = mkdtempSync(join(tmpdir(), "selftune-duckdb-wal-race-")); + temporaryDirectories.push(directory); + const databasePath = join(directory, "observability.duckdb"); + const walPath = `${databasePath}.wal`; + const racedWalPath = join(directory, "recovered-by-other-process.wal"); + writeFileSync(databasePath, "checkpoint"); + writeFileSync(walPath, "unreplayable wal"); + const warnings: string[] = []; + let opens = 0; + const dependencies = recoveryDependencies(async () => { + opens += 1; + if (opens === 1) throw knownWalReplayFailure(); + return { generation: opens }; + }, warnings); + + const opened = await openDuckDbWithWalRecovery(databasePath, { + ...dependencies, + rename: async (source, destination) => { + if (source === walPath) { + renameSync(source, racedWalPath); + throw Object.assign(new Error("WAL already moved"), { code: "ENOENT" }); + } + return dependencies.rename(source, destination); + }, + }); + + expect(opened).toEqual({ generation: 2 }); + expect(readFileSync(racedWalPath, "utf8")).toBe("unreplayable wal"); + expect(warnings).toEqual([]); +}); + const batch = { schema_version: "1.0.0", batch_id: "batch-codex-001", diff --git a/tests/observability/duckdb-store.test.ts b/tests/observability/duckdb-store.test.ts index 8d41187b..b731579c 100644 --- a/tests/observability/duckdb-store.test.ts +++ b/tests/observability/duckdb-store.test.ts @@ -22,7 +22,10 @@ function queryResult(rows: ReadonlyArray>): DuckDbQueryR return { getRowObjects: async () => rows }; } -function makeFactory(statements: Statement[]): DuckDbInstanceFactory { +function makeFactory( + statements: Statement[], + options?: { readonly historicalEvidenceQuality?: "source_exact" | null }, +): DuckDbInstanceFactory { const batches = new Map(); const connection: DuckDbConnection = { appendRows: async (table, rows) => { @@ -90,6 +93,29 @@ function makeFactory(statements: Statement[]): DuckDbInstanceFactory { }, ]); } + if ( + sql.includes("SELECT DISTINCT") && + sql.includes("span.trace_boundary") && + sql.includes("span.capture_mode") + ) { + return queryResult([ + { + trace_id: "a".repeat(32), + span_id: "b".repeat(16), + skill_invocation_id: "invocation-codex-001", + source_id: "rollout-codex-001", + source_revision: "rollout-sha-001", + trace_boundary: "session", + capture_mode: "rollout", + source_authority: "source_truth", + evidence_quality: + options?.historicalEvidenceQuality === undefined + ? "source_exact" + : options.historicalEvidenceQuality, + model: "gpt-5", + }, + ]); + } if (sql.includes("SELECT DISTINCT") && sql.includes("skill_invocation_id")) { return queryResult([ { @@ -232,6 +258,37 @@ test("creates the analytical schema, derives metrics, and ignores a replayed bat ).toHaveLength(1); }); +test("checkpoints a revised batch after commit so indexed deletes are not stranded in WAL", async () => { + const statements: Statement[] = []; + const revisedBatch = { + ...batch, + source_revision: "rollout-sha-002", + spans: batch.spans.map((span) => ({ ...span, output_tokens: 31 })), + }; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* DuckDbAnalyticalStore; + yield* store.ingest(batch); + return yield* store.ingest(revisedBatch); + }).pipe( + Effect.provide(makeDuckDbAnalyticalStoreLive(makeFactory(statements), ":memory:")), + Effect.scoped, + ), + ); + + expect(result.disposition).toBe("accepted"); + const revisionDelete = statements.findIndex( + (statement) => statement.sql === "DELETE FROM observability_spans WHERE batch_id = $batch_id", + ); + const revisionCommit = statements.findIndex( + (statement, index) => index > revisionDelete && statement.sql === "COMMIT", + ); + expect(revisionDelete).toBeGreaterThan(-1); + expect(revisionCommit).toBeGreaterThan(revisionDelete); + expect(statements[revisionCommit + 1]?.sql).toBe("CHECKPOINT"); +}); + test("rejects a reversed span timestamp before beginning an analytical transaction", async () => { const statements: Statement[] = []; const layer = makeDuckDbAnalyticalStoreLive(makeFactory(statements), ":memory:"); @@ -282,6 +339,73 @@ test("selects concrete source references for a supported repeated-error pattern" }, ]); expect(statements.some((statement) => statement.sql.includes("SELECT DISTINCT"))).toBe(true); + expect( + statements.some((statement) => + statement.sql.includes("span.trace_boundary IN ('actionable_turn', 'autonomous_task')"), + ), + ).toBe(true); +}); + +test("selects neutral historical task provenance without treating session errors as outcomes", async () => { + const statements: Statement[] = []; + const references = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* DuckDbAnalyticalStore; + return yield* store.queryHistoricalSkillTaskReferences({ skill_id: "diagnose", limit: 16 }); + }).pipe( + Effect.provide(makeDuckDbAnalyticalStoreLive(makeFactory(statements), ":memory:")), + Effect.scoped, + ), + ); + + expect(references).toEqual([ + { + trace_id: "a".repeat(32), + span_id: "b".repeat(16), + skill_invocation_id: "invocation-codex-001", + source_id: "rollout-codex-001", + source_revision: "rollout-sha-001", + trace_boundary: "session", + capture_mode: "rollout", + source_authority: "source_truth", + evidence_quality: "source_exact", + model: "gpt-5", + }, + ]); + const query = statements.find((statement) => statement.sql.includes("LIMIT $limit")); + expect(query?.sql).not.toContain("error_count"); +}); + +test("treats a legacy null historical evidence quality as unknown provenance", async () => { + const statements: Statement[] = []; + const references = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* DuckDbAnalyticalStore; + return yield* store.queryHistoricalSkillTaskReferences({ skill_id: "diagnose", limit: 16 }); + }).pipe( + Effect.provide( + makeDuckDbAnalyticalStoreLive( + makeFactory(statements, { historicalEvidenceQuality: null }), + ":memory:", + ), + ), + Effect.scoped, + ), + ); + + expect(references).toEqual([ + { + trace_id: "a".repeat(32), + span_id: "b".repeat(16), + skill_invocation_id: "invocation-codex-001", + source_id: "rollout-codex-001", + source_revision: "rollout-sha-001", + trace_boundary: "session", + capture_mode: "rollout", + source_authority: "source_truth", + model: "gpt-5", + }, + ]); }); test("returns only the latest cumulative historical snapshot instead of summing it", async () => { diff --git a/tests/observability/opencode-duckdb-dashboard.integration.test.ts b/tests/observability/opencode-duckdb-dashboard.integration.test.ts index 3502120e..54938ba4 100644 --- a/tests/observability/opencode-duckdb-dashboard.integration.test.ts +++ b/tests/observability/opencode-duckdb-dashboard.integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +17,11 @@ import { TraceCandidatePreparation, makeTraceCandidatePreparationLayer, } from "../../apps/local/src/trace-candidate-service.js"; +import { + HistoricalSkillImprovement, + makeHistoricalSkillImprovementLayer, +} from "../../apps/local/src/historical-skill-improvement-service.js"; +import { qualifyVerifierInstrument } from "@selftune/skill-intelligence/verifier-instruments"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { createElement } from "react"; @@ -30,7 +35,10 @@ beforeEach(() => { sourceRoot = join(root, "opencode"); _setTestDb(openDb(join(root, "selftune.db"))); mkdirSync(join(root, ".agents", "skills", "diagnose"), { recursive: true }); - writeFileSync(join(root, ".agents", "skills", "diagnose", "SKILL.md"), "# Diagnose\n"); + writeFileSync( + join(root, ".agents", "skills", "diagnose", "SKILL.md"), + "# Diagnose\n\nUse evidence before diagnosing a failure.\n", + ); }); afterEach(() => { @@ -51,13 +59,13 @@ function writeNativeOpenCodeSource(): void { "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL)", ); - for (const index of [0, 1, 2]) { + for (const index of [0, 1, 2, 3]) { const started = Date.parse(`2026-07-23T10:0${index}:00.000Z`); const sessionId = `opencode-desktop-${index}`; database.run("INSERT INTO session VALUES (?, ?, ?, ?, ?)", [ sessionId, root, - "Diagnose deployment", + `Diagnose deployment scenario ${index + 1}`, started, started + 3_000, ]); @@ -89,7 +97,10 @@ function writeNativeOpenCodeSource(): void { sessionId, started, started, - JSON.stringify({ type: "text", text: "Diagnose the failing deployment" }), + JSON.stringify({ + type: "text", + text: `Diagnose failing deployment scenario ${index + 1}`, + }), ]); database.run("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)", [ `${sessionId}-skill-read`, @@ -157,29 +168,29 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i getDb() .query("SELECT skill_name, COUNT(*) AS count FROM skill_invocations GROUP BY skill_name") .all(), - ).toEqual([{ skill_name: "diagnose", count: 3 }]); + ).toEqual([{ skill_name: "diagnose", count: 4 }]); expect(await querySignals(analyticalPath)).toEqual([ { skill_name: "diagnose", - invocation_count: 3, - trace_count: 3, + invocation_count: 4, + trace_count: 4, error_trace_count: 2, - duration_ms: 9_000, - input_tokens: 360, - output_tokens: 90, + duration_ms: 12_000, + input_tokens: 480, + output_tokens: 120, error_count: 2, - tool_call_count: 6, + tool_call_count: 8, }, ]); const skillPath = join(root, ".agents", "skills", "diagnose", "SKILL.md"); const revision = computeSkillVersionHash(skillPath); if (!revision) throw new Error("Expected fixture skill revision."); - // Source import predates version capture; fixture the exact local revision - // needed by the review-only candidate resolver. - getDb().run("UPDATE skill_invocations SET skill_version_hash = ?", [revision]); - getDb().run( - "UPDATE skill_invocations SET query = 'Diagnose the failing deployment', triggered = 1", - ); + // Source import predates captured revision hashes. Prove the resolver can + // use the canonical prompt relation only when the installed package + // snapshot demonstrably predates every selected trace. + const stableBeforeTraces = new Date("2026-07-22T00:00:00.000Z"); + utimesSync(skillPath, stableBeforeTraces, stableBeforeTraces); + utimesSync(join(root, ".agents", "skills", "diagnose"), stableBeforeTraces, stableBeforeTraces); const report = loadSkillIntelligence({ db: getDb(), @@ -207,8 +218,8 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i expect(desktopModel.traceSignals).toEqual([ expect.objectContaining({ skillName: "diagnose", - invocationCount: 3, - traceCount: 3, + invocationCount: 4, + traceCount: 4, errorTraceCount: 2, }), ]); @@ -217,9 +228,9 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i kind: "repeated_correlated_errors", skillId: "diagnose", skillName: "diagnose", - traceCount: 3, + traceCount: 4, matchingTraceCount: 2, - ratio: 0.667, + ratio: 0.5, causalClaim: false, }), ]); @@ -239,25 +250,44 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i }), ); expect(desktopHtml).toContain("diagnose"); - expect(desktopHtml).toContain("2 of 3 traced executions reported errors"); - expect(desktopHtml).toContain("3 invocations · 6 tool calls · 2 errors · 9,000 ms"); + expect(desktopHtml).toContain("2 of 4 traced executions reported errors"); + expect(desktopHtml).toContain("4 invocations · 8 tool calls · 2 errors · 12,000 ms"); expect(desktopHtml).toContain( "Correlation only — this does not show that the skill caused errors.", ); + // Candidate generation is intentionally restricted to explicit hook-backed + // tasks. The source adapter above proves batch ingestion; promote the same + // bounded fixture rows to hook provenance for the separate review workflow. + getDb().run( + "UPDATE skill_invocations SET capture_mode = 'hook', invocation_mode = 'explicit', skill_path = ?", + [skillPath], + ); + let teacherCalls = 0; + const historicalTaskCalibrator = async (input: { arm: "current" | "candidate" }) => ({ + passed: input.arm === "candidate", + score: input.arm === "candidate" ? 1 : 0, + output: input.arm === "candidate" ? "Diagnosed with evidence." : "Diagnosis incomplete.", + feedback: input.arm === "candidate" ? "none" : "missing evidence workflow", + }); const pattern = desktopModel.executionPatterns.at(0); if (!pattern) throw new Error("Expected the OpenCode fixture to produce a supported pattern."); const review = await Effect.runPromise( Effect.gen(function* () { const preparation = yield* TraceCandidatePreparation; - return yield* preparation.prepare({ pattern_id: pattern.id }); + return yield* preparation.prepare({ + pattern_id: pattern.id, + candidate_count: 2, + calibration_repetitions: 1, + }); }).pipe( Effect.provide( Layer.provide( makeTraceCandidatePreparationLayer({ sqlite: getDb(), searchDirs: [join(root, ".agents", "skills")], + historicalTaskCalibrator, teacher: async () => { teacherCalls += 1; return { @@ -294,15 +324,137 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i Effect.scoped, ), ); - expect(teacherCalls).toBe(1); + expect(teacherCalls).toBe(2); expect(review).toMatchObject({ readiness: "review_ready", candidate: { diff: { target_section: "Workflow Routing" } }, }); + const preparedPayload = getDb() + .query("SELECT payload_json FROM evaluation_submission_drafts WHERE draft_id = ?") + .get(review.draft_id) as { payload_json: string }; + expect(JSON.parse(preparedPayload.payload_json)).toMatchObject({ + schema_version: 2, + cohort: { + pattern: { kind: "historical_task_quality" }, + entries: [ + { role: "calibration" }, + { role: "calibration" }, + { role: "selection" }, + { role: "audit_holdout" }, + ], + }, + }); expect(getDb().query("SELECT COUNT(*) AS count FROM skill_invocations").get()).toEqual({ - count: 3, + count: 4, }); + const qualifiedVerifier = qualifyVerifierInstrument({ + instrument: { + verifier_id: "diagnosis-check", + version: "v1", + kind: "deterministic", + success_contract: "The frozen diagnosis check passes.", + check_description: "Runs the diagnosis fixture check.", + }, + evidence: ["known_failure", "known_good", "boundary", "adversarial"].map((label) => ({ + evidence_id: `diagnosis-${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, + })), + }); + const historicalResult = await Effect.runPromise( + Effect.gen(function* () { + const improvement = yield* HistoricalSkillImprovement; + return yield* improvement.evaluate({ + pattern_id: pattern.id, + qualified_verifier: qualifiedVerifier, + runtime: { + harness: "opencode", + model: "gpt-5", + config_digest: `sha256:${"c".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-07-23T12:01:00.000Z", + }); + }).pipe( + Effect.provide( + Layer.provide( + makeHistoricalSkillImprovementLayer({ + sqlite: getDb(), + searchDirs: [join(root, ".agents", "skills")], + executor: { + execute: (request) => + Effect.succeed({ + kind: "scored" as const, + passed: request.arm === "candidate_skill", + executed_revision: request.revision, + }), + }, + }), + Layer.provide( + makeTraceCandidatePreparationLayer({ + sqlite: getDb(), + searchDirs: [join(root, ".agents", "skills")], + historicalTaskCalibrator, + teacher: async () => ({ + schema_version: 1, + proposed_body: + "## Workflow Routing\n\n| Trigger | Workflow |\n| --- | --- |\n| diagnose | Diagnose with evidence |", + rationale: "Keeps the observed diagnosis workflow explicit.", + confidence: 0.8, + target_section: "Workflow Routing", + scope: "section_local", + mutation_operation: "add", + principle: "Keep diagnosis evidence scoped.", + applicability: "Observed diagnose requests.", + failure_mode: "Repeated execution errors.", + preserved_constraints: [], + superseded_guidance: [], + uncertainty: [], + }), + evolutionDeps: { + validateBodyProposal: async (proposal) => ({ + proposal_id: proposal.proposal_id, + gates_passed: 3, + gates_total: 3, + gate_results: [], + improved: true, + regressions: [], + }), + }, + }), + makeDuckDbNodeApiAnalyticalStoreLive(analyticalPath), + ), + ), + ), + Effect.scoped, + ), + ); + expect(historicalResult).toMatchObject({ + status: "review_ready", + evidence_level: "E2", + reason: "selected", + cases: { calibration: 2, selection: 1, audit_holdout: 1 }, + applies_change: false, + }); + expect( + getDb().query("SELECT evidence_level, status FROM correction_candidate_evaluations").all(), + ).toEqual([{ evidence_level: "E2", status: "selected" }]); + let staleRevisionCalls = 0; const staleReview = await Effect.runPromise( Effect.gen(function* () { @@ -315,6 +467,7 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i sqlite: getDb(), searchDirs: [join(root, ".agents", "skills")], computeRevision: () => (staleRevisionCalls++ === 0 ? revision : "stale-revision"), + historicalTaskCalibrator, teacher: async () => { throw new Error("A stale target must not call the teacher."); }, @@ -331,8 +484,13 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i candidate: null, }); - getDb().run("UPDATE skill_invocations SET query = NULL WHERE skill_invocation_id = ?", [ + getDb().run("UPDATE skill_invocations SET query = NULL WHERE skill_invocation_id IN (?, ?)", [ "opencode-desktop-2:s:diagnose:0", + "opencode-desktop-3:s:diagnose:0", + ]); + getDb().run("UPDATE prompts SET prompt_text = NULL WHERE session_id IN (?, ?)", [ + "opencode-desktop-2", + "opencode-desktop-3", ]); const contrastReview = await Effect.runPromise( Effect.gen(function* () { @@ -357,7 +515,7 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i expect(contrastReview).toMatchObject({ readiness: "not_ready", failure_reason: - "Selected evidence could not be resolved at the exact target revision with both failure and success contrast.", + "Historical skill links exist, but fewer than 3 unique explicit tasks resolve to the exact installed revision for separate calibration, selection, and audit partitions.", candidate: null, }); @@ -368,11 +526,11 @@ test("ingests current OpenCode SQLite sessions through the real source adapter i getDb() .query("SELECT skill_name, COUNT(*) AS count FROM skill_invocations GROUP BY skill_name") .all(), - ).toEqual([{ skill_name: "diagnose", count: 3 }]); + ).toEqual([{ skill_name: "diagnose", count: 4 }]); expect(await querySignals(analyticalPath)).toEqual([ expect.objectContaining({ - invocation_count: 3, - trace_count: 3, + invocation_count: 4, + trace_count: 4, error_trace_count: 2, }), ]); diff --git a/tests/orchestration/local-trace-importer.test.ts b/tests/orchestration/local-trace-importer.test.ts index 2616d707..b5bfc76d 100644 --- a/tests/orchestration/local-trace-importer.test.ts +++ b/tests/orchestration/local-trace-importer.test.ts @@ -148,6 +148,7 @@ test("imports a source revision before acknowledging its SQLite checkpoint", asy ), querySkillSignals: () => Effect.succeed([]), queryEvidenceCohortCandidates: () => Effect.succeed([]), + queryHistoricalSkillTaskReferences: () => Effect.succeed([]), queryHistoricalMetricRollups: () => Effect.fail( DuckDbAnalyticalStoreFailure.make({ diff --git a/tests/registry/automatic-suggestions-loopback.test.ts b/tests/registry/automatic-suggestions-loopback.test.ts new file mode 100644 index 00000000..fb20b942 --- /dev/null +++ b/tests/registry/automatic-suggestions-loopback.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test } from "bun:test"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; + +import { + runAutomaticRegistrySuggestionScan, + type AutomaticRegistrySuggestionScanResult, +} from "../../packages/runtime/registry/automatic-suggestions.js"; +import { makeRegistryClientLayer } from "../../packages/runtime/registry/client.js"; +import type { PlatformCredentialStore } from "../../packages/runtime/credential-store.js"; +import { + makeRegistryPlatformLayer, + RegistryPlatform, +} from "../../packages/runtime/registry/platform.js"; +import { commitRegistryState } from "../../packages/runtime/registry/registry-state-store.js"; + +const sha256 = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex"); + +describe("automatic Registry suggestions over loopback", () => { + test("packages stable teammate edits, submits once, and persists the receipt", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-automatic-suggestion-loopback-")); + const configDirectory = join(root, ".selftune"); + const configPath = join(configDirectory, "config.json"); + const homeDirectory = join(root, "home"); + const skillDirectory = join(homeDirectory, ".claude", "skills", "support-playbook"); + const guideDirectory = join(skillDirectory, "references"); + mkdirSync(configDirectory, { recursive: true }); + mkdirSync(guideDirectory, { recursive: true }); + + const baselineSkill = + "---\nname: support-playbook\ndescription: Handle customer support\n---\n\nEscalate billing issues.\n"; + const editedSkill = + "---\nname: support-playbook\ndescription: Handle customer support\n---\n\nConfirm the account owner before escalating billing issues.\n"; + const editedGuide = "# Escalation guide\n\nInclude the verified account ID.\n"; + writeFileSync(join(skillDirectory, "SKILL.md"), baselineSkill); + + const credentials = new Map(); + const credentialStore: PlatformCredentialStore = { + set: (account, value) => { + credentials.set(account, value); + return { provider: "file", account }; + }, + get: (reference) => credentials.get(reference.account) ?? null, + delete: (reference) => { + credentials.delete(reference.account); + }, + }; + const credential = credentialStore.set("registry:automatic-loopback", "loopback-secret"); + + let postCount = 0; + let captured: + | { + readonly authorization: string | null; + readonly archiveHash: string; + readonly archiveName: string; + readonly archiveType: string; + readonly metadata: unknown; + readonly pathname: string; + } + | undefined; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if ( + request.method !== "POST" || + url.pathname !== "/api/v1/collaboration/registry/registry-support/contributions" + ) { + return Response.json({ error: "unexpected request" }, { status: 404 }); + } + postCount += 1; + const form = await request.formData(); + const archive = form.get("archive"); + const metadata = form.get("metadata"); + if (!(archive instanceof File) || typeof metadata !== "string") { + return Response.json({ error: "invalid multipart request" }, { status: 400 }); + } + captured = { + authorization: request.headers.get("authorization"), + archiveHash: sha256(new Uint8Array(await archive.arrayBuffer())), + archiveName: archive.name, + archiveType: archive.type, + metadata: JSON.parse(metadata), + pathname: url.pathname, + }; + return Response.json( + { id: "contribution-loopback-1", status: "pending", deduplicated: false }, + { status: 201 }, + ); + }, + }); + + try { + writeFileSync( + configPath, + JSON.stringify({ + agent_type: "unknown", + cli_path: "/usr/local/bin/selftune", + llm_mode: "agent", + agent_cli: null, + hooks_installed: false, + initialized_at: "2026-08-01T00:00:00.000Z", + alpha: { + enrolled: true, + user_id: "automatic-loopback-user", + consent_timestamp: "2026-08-01T00:00:00.000Z", + cloud_api_url: `http://127.0.0.1:${server.port}`, + credential, + }, + }), + ); + + const liveLayer = Layer.merge( + makeRegistryPlatformLayer({ + configDirectory, + cwd: root, + deviceId: "automatic-loopback-device", + homeDirectory, + }), + makeRegistryClientLayer(configPath, { credentialStore }), + ).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(BunServices.layer)); + const now = Date.parse("2026-08-01T12:34:56.000Z"); + + const outcome = await Effect.runPromise( + Effect.gen(function* () { + const platform = yield* RegistryPlatform; + const baselineHash = yield* platform.computeInstalledContentHash(skillDirectory); + yield* platform.withStateTransaction(() => + Effect.succeed( + commitRegistryState( + [ + { + entryId: "registry-support", + name: "support-playbook", + versionHash: "base-content-hash", + version: "1.4.0", + versionId: "base-version-1", + installPath: skillDirectory, + localContentHash: baselineHash, + }, + ], + undefined, + ), + ), + ); + + yield* Effect.sync(() => { + writeFileSync(join(skillDirectory, "SKILL.md"), editedSkill); + writeFileSync(join(guideDirectory, "guide.md"), editedGuide); + }); + const observedContentHash = yield* platform.computeInstalledContentHash(skillDirectory); + const options = { now: () => now, stableForMs: 0 }; + const armed = yield* runAutomaticRegistrySuggestionScan(options); + const submitted = yield* runAutomaticRegistrySuggestionScan(options); + const unchanged = yield* runAutomaticRegistrySuggestionScan(options); + const state = yield* platform.loadState(); + return { armed, observedContentHash, state, submitted, unchanged }; + }).pipe(Effect.provide(liveLayer)), + ); + + const expectedManifest = [ + { + path: "references/guide.md", + hash: sha256(editedGuide), + size: Buffer.byteLength(editedGuide), + }, + { + path: "SKILL.md", + hash: sha256(editedSkill), + size: Buffer.byteLength(editedSkill), + }, + ]; + const expectedArmed = { + managed: 1, + armed: 1, + submitted: 0, + deferred: 0, + failed: 0, + } satisfies AutomaticRegistrySuggestionScanResult; + const expectedSubmitted = { + managed: 1, + armed: 0, + submitted: 1, + deferred: 0, + failed: 0, + } satisfies AutomaticRegistrySuggestionScanResult; + const expectedUnchanged = { + managed: 1, + armed: 0, + submitted: 0, + deferred: 0, + failed: 0, + } satisfies AutomaticRegistrySuggestionScanResult; + + expect(outcome.armed).toEqual(expectedArmed); + expect(outcome.submitted).toEqual(expectedSubmitted); + expect(outcome.unchanged).toEqual(expectedUnchanged); + expect(postCount).toBe(1); + expect(captured).toEqual({ + authorization: "Bearer loopback-secret", + archiveHash: captured?.archiveHash, + archiveName: "support-playbook.tar.gz", + archiveType: "application/gzip", + metadata: { + baseVersionId: "base-version-1", + candidateVersion: `1.4.0.team.${outcome.observedContentHash.slice(0, 12)}`, + candidateContentHash: captured?.archiveHash, + summary: "Automatically captured teammate edits to support-playbook", + files: expectedManifest, + }, + pathname: "/api/v1/collaboration/registry/registry-support/contributions", + }); + + const entry = outcome.state[0]; + expect(entry?.automaticSuggestion).toBeUndefined(); + expect(entry?.lastSuggestion).toEqual({ + observedContentHash: outcome.observedContentHash, + candidateContentHash: captured?.archiveHash, + baseVersionHash: "base-content-hash", + baseVersionId: "base-version-1", + contributionId: "contribution-loopback-1", + submittedAt: "2026-08-01T12:34:56.000Z", + }); + expect( + JSON.parse(readFileSync(join(configDirectory, "registry-state.json"), "utf8")), + ).toEqual(outcome.state); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/tests/registry/automatic-suggestions.test.ts b/tests/registry/automatic-suggestions.test.ts new file mode 100644 index 00000000..56f4ece9 --- /dev/null +++ b/tests/registry/automatic-suggestions.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; + +import { Effect, Layer, Schema } from "effect"; + +import { + runAutomaticRegistrySuggestionScan, + type AutomaticRegistrySuggestionOptions, +} from "../../packages/runtime/registry/automatic-suggestions.js"; +import { + RegistryClient, + RegistryHttpError, + type RegistryClientService, + type RegistryRequestOptions, +} from "../../packages/runtime/registry/client.js"; +import { + RegistryPlatform, + type PreparedRegistryPush, + type RegistryPlatformService, +} from "../../packages/runtime/registry/platform.js"; +import type { RegistryStateEntry } from "../../packages/runtime/registry/registry-state.js"; + +const baseHash = "b".repeat(64); +const installedHash = "i".repeat(64); +const editedHash = "e".repeat(64); +const candidateHash = "c".repeat(64); + +function managedEntry(overrides: Partial = {}): RegistryStateEntry { + return { + entryId: "entry-1", + name: "deploy", + versionHash: baseHash, + version: "1.0.0", + versionId: "base-version-1", + installPath: "/repo/.claude/skills/deploy", + localContentHash: installedHash, + installationId: "installation-1", + ...overrides, + }; +} + +function prepared(): PreparedRegistryPush { + return { + name: "deploy", + description: "Deploy safely", + version: "1.0.0.team.eeeeeeeeeeee", + archiveBuffer: Buffer.from("candidate"), + archiveHash: candidateHash, + manifest: [{ path: "SKILL.md", hash: "f".repeat(64), size: 42 }], + }; +} + +function harness(input?: { + readonly entry?: RegistryStateEntry; + readonly observedHash?: string; + readonly request?: RegistryClientService["request"]; +}) { + let state = [input?.entry ?? managedEntry()]; + const packageDirectories: string[] = []; + const requests: RegistryRequestOptions[] = []; + const service: RegistryPlatformService = { + deviceId: "device-1", + computeInstalledContentHash: () => Effect.succeed(input?.observedHash ?? editedHash), + computeArchiveContentHash: () => + Effect.die(new Error("computeArchiveContentHash was not expected")), + findProtectedPaths: () => Effect.succeed([]), + installArchive: () => Effect.die(new Error("installArchive was not expected")), + installFromGithub: () => Effect.die(new Error("installFromGithub was not expected")), + loadState: () => Effect.succeed(state), + preparePackage: (directory) => { + packageDirectories.push(directory); + return Effect.succeed(prepared()); + }, + preparePush: () => Effect.die(new Error("preparePush was not expected")), + resolveInstallTarget: () => Effect.die(new Error("resolveInstallTarget was not expected")), + validatePersistedTarget: (installPath) => + Effect.succeed({ installRoot: "/repo/.claude/skills", targetDir: installPath }), + withStateTransaction: (use) => + use(state).pipe( + Effect.map((decision) => { + if (decision._tag === "Commit") state = [...decision.state]; + return decision.value; + }), + ), + }; + const client: RegistryClientService = { + download: () => Effect.die(new Error("download was not expected")), + request: (schema: Schema.Decoder, options: RegistryRequestOptions) => { + requests.push(options); + if (input?.request) return input.request(schema, options); + return Schema.decodeUnknownEffect(schema)({ + id: "contribution-1", + status: "pending", + }); + }, + }; + const layer = Layer.merge( + Layer.succeed(RegistryPlatform, service), + Layer.succeed(RegistryClient, client), + ); + return { layer, packageDirectories, requests, state: () => state }; +} + +function scan( + layer: ReturnType["layer"], + options: AutomaticRegistrySuggestionOptions, +) { + return Effect.runPromise(runAutomaticRegistrySuggestionScan(options).pipe(Effect.provide(layer))); +} + +describe("automatic Registry suggestions", () => { + test("arms a stable edit, submits its exact directory, and persists dedupe", async () => { + let now = 1_000; + const subject = harness(); + + const armed = await scan(subject.layer, { now: () => now, stableForMs: 5_000 }); + expect(armed).toMatchObject({ armed: 1, submitted: 0 }); + expect(subject.requests).toHaveLength(0); + + now = 6_000; + const submitted = await scan(subject.layer, { now: () => now, stableForMs: 5_000 }); + expect(submitted).toMatchObject({ submitted: 1, failed: 0 }); + expect(subject.packageDirectories).toEqual(["/repo/.claude/skills/deploy"]); + expect(subject.requests).toHaveLength(1); + expect(subject.requests[0]?.path).toBe("/api/v1/collaboration/registry/entry-1/contributions"); + expect(subject.state()[0]?.automaticSuggestion).toBeUndefined(); + expect(subject.state()[0]?.lastSuggestion).toMatchObject({ + observedContentHash: editedHash, + candidateContentHash: candidateHash, + contributionId: "contribution-1", + }); + + now = 60_000; + const deduplicated = await scan(subject.layer, { now: () => now, stableForMs: 5_000 }); + expect(deduplicated.submitted).toBe(0); + expect(subject.requests).toHaveLength(1); + }); + + test("keeps legacy state untrusted instead of uploading or relabeling unknown bytes", async () => { + const subject = harness({ entry: managedEntry({ localContentHash: undefined }) }); + const result = await scan(subject.layer, { now: () => 1_000 }); + + expect(result).toMatchObject({ submitted: 0, deferred: 1 }); + expect(subject.requests).toHaveLength(0); + expect(subject.packageDirectories).toHaveLength(0); + expect(subject.state()[0]?.localContentHash).toBeUndefined(); + }); + + test("backs off transport failures and retries permission failures after a bounded cooldown", async () => { + let now = 1_000; + let postAttempts = 0; + const subject = harness({ + request: (schema: Schema.Decoder, options: RegistryRequestOptions) => { + if (options.method === "GET") { + return Schema.decodeUnknownEffect(schema)({ contributions: [] }); + } + postAttempts += 1; + if (postAttempts === 3) { + return Schema.decodeUnknownEffect(schema)({ id: "contribution-1", status: "pending" }); + } + return Effect.fail( + RegistryHttpError.make({ + status: postAttempts === 1 ? 503 : 403, + message: "unavailable", + }), + ); + }, + }); + + await scan(subject.layer, { now: () => now, stableForMs: 0, retryBaseMs: 30_000 }); + await scan(subject.layer, { now: () => now, stableForMs: 0, retryBaseMs: 30_000 }); + expect(subject.state()[0]?.automaticSuggestion).toMatchObject({ + attemptCount: 1, + nextAttemptAt: 31_000, + }); + + now = 30_000; + await scan(subject.layer, { now: () => now, stableForMs: 0, retryBaseMs: 30_000 }); + expect(postAttempts).toBe(1); + + now = 31_000; + await scan(subject.layer, { + now: () => now, + stableForMs: 0, + retryBaseMs: 30_000, + blockedRetryMs: 900_000, + }); + expect(subject.state()[0]?.automaticSuggestion).toMatchObject({ + attemptCount: 2, + nextAttemptAt: 931_000, + lastFailure: { kind: "blocked", code: "http_403", at: 31_000 }, + }); + + now = 930_999; + await scan(subject.layer, { + now: () => now, + stableForMs: 0, + retryBaseMs: 30_000, + blockedRetryMs: 900_000, + }); + expect(postAttempts).toBe(2); + + now = 931_000; + const recovered = await scan(subject.layer, { + now: () => now, + stableForMs: 0, + retryBaseMs: 30_000, + blockedRetryMs: 900_000, + }); + expect(recovered.submitted).toBe(1); + expect(postAttempts).toBe(3); + }); +}); diff --git a/tests/registry/platform.test.ts b/tests/registry/platform.test.ts index 445db78e..0c171831 100644 --- a/tests/registry/platform.test.ts +++ b/tests/registry/platform.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as BunServices from "@effect/platform-bun/BunServices"; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,6 +19,43 @@ afterEach(() => { }); describe("RegistryPlatform", () => { + test("packages an explicit managed directory reproducibly without changing cwd", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-registry-platform-explicit-")); + roots.push(root); + const managed = join(root, ".claude", "skills", "deploy"); + const unrelatedCwd = join(root, "unrelated"); + mkdirSync(managed, { recursive: true }); + mkdirSync(unrelatedCwd, { recursive: true }); + writeFileSync( + join(managed, "SKILL.md"), + "---\nname: deploy\ndescription: Deploy safely\n---\n", + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const platform = yield* RegistryPlatform; + const input = { operation: "suggest" as const, version: "1.0.1" }; + const first = yield* platform.preparePackage(managed, input); + const second = yield* platform.preparePackage(managed, input); + return { first, second }; + }).pipe( + Effect.provide( + makeRegistryPlatformLayer({ + configDirectory: join(root, ".selftune"), + cwd: unrelatedCwd, + deviceId: "test-device", + homeDirectory: root, + }), + ), + Effect.provide(BunServices.layer), + ), + ); + + expect(result.first?.name).toBe("deploy"); + expect(result.first?.archiveHash).toBe(result.second?.archiveHash); + expect(result.first?.manifest.map((file) => file.path)).toEqual(["SKILL.md"]); + }); + test("rejects symlinks before building a push archive the installer cannot accept", async () => { const root = mkdtempSync(join(tmpdir(), "selftune-registry-platform-")); roots.push(root); @@ -49,4 +86,110 @@ describe("RegistryPlatform", () => { expect(outcome.failure.message).toContain("unsupported filesystem entry: linked-skill.md"); } }); + + test("blocks sensitive files before packaging and reports protected rollout paths by name only", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-registry-platform-sensitive-")); + roots.push(root); + const managed = join(root, ".claude", "skills", "deploy"); + mkdirSync(join(managed, ".git"), { recursive: true }); + mkdirSync(join(managed, "node_modules"), { recursive: true }); + writeFileSync(join(managed, "SKILL.md"), "---\nname: deploy\ndescription: Deploy\n---\n"); + writeFileSync(join(managed, ".env.local"), "SECRET=never-read"); + writeFileSync(join(managed, "credentials.prod.json"), "never-upload"); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const platform = yield* RegistryPlatform; + const protectedPaths = yield* platform.findProtectedPaths(managed); + const packaged = yield* Effect.result( + platform.preparePackage(managed, { operation: "suggest" }), + ); + return { packaged, protectedPaths }; + }).pipe( + Effect.provide( + makeRegistryPlatformLayer({ + configDirectory: join(root, ".selftune"), + cwd: root, + deviceId: "test-device", + homeDirectory: root, + }), + ), + Effect.provide(BunServices.layer), + ), + ); + + expect(result.protectedPaths).toEqual([ + ".env.local", + ".git", + "credentials.prod.json", + "node_modules", + ]); + expect(Result.isFailure(result.packaged)).toBe(true); + if (Result.isFailure(result.packaged)) { + expect(result.packaged.failure.message).toContain("protected local path"); + } + }); + + test("rejects oversized files before allocating or building an archive", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-registry-platform-large-")); + roots.push(root); + writeFileSync(join(root, "SKILL.md"), "---\nname: deploy\ndescription: Deploy\n---\n"); + writeFileSync(join(root, "large.bin"), Buffer.alloc(2 * 1_024 * 1_024 + 1)); + + const outcome = await Effect.runPromise( + Effect.result( + Effect.gen(function* () { + const platform = yield* RegistryPlatform; + return yield* platform.preparePush({ operation: "push" }); + }).pipe( + Effect.provide( + makeRegistryPlatformLayer({ + configDirectory: join(root, ".selftune"), + cwd: root, + deviceId: "test-device", + homeDirectory: root, + }), + ), + Effect.provide(BunServices.layer), + ), + ), + ); + + expect(Result.isFailure(outcome)).toBe(true); + if (Result.isFailure(outcome)) expect(outcome.failure.message).toContain("2 MiB limit"); + }); + + test("rejects a managed target reached through a symlinked skill directory", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-registry-platform-root-link-")); + roots.push(root); + const actual = join(root, "actual-deploy"); + const target = join(root, ".claude", "skills", "deploy"); + mkdirSync(actual, { recursive: true }); + mkdirSync(join(root, ".claude", "skills"), { recursive: true }); + writeFileSync(join(actual, "SKILL.md"), "---\nname: deploy\ndescription: Deploy\n---\n"); + symlinkSync(actual, target); + + const outcome = await Effect.runPromise( + Effect.result( + Effect.gen(function* () { + const platform = yield* RegistryPlatform; + return yield* platform.validatePersistedTarget(target, "deploy"); + }).pipe( + Effect.provide( + makeRegistryPlatformLayer({ + configDirectory: join(root, ".selftune"), + cwd: root, + deviceId: "test-device", + homeDirectory: root, + }), + ), + Effect.provide(BunServices.layer), + ), + ), + ); + + expect(Result.isFailure(outcome)).toBe(true); + if (Result.isFailure(outcome)) + expect(outcome.failure.message).toContain("not a real directory"); + }); }); diff --git a/tests/registry/programs.test.ts b/tests/registry/programs.test.ts index 1a2be199..e49c69e7 100644 --- a/tests/registry/programs.test.ts +++ b/tests/registry/programs.test.ts @@ -4,6 +4,7 @@ import { Effect, Layer, Schema } from "effect"; import { RegistryClient, + RegistryHttpError, type RegistryClientService, type RegistryRequestOptions, } from "../../packages/runtime/registry/client.js"; @@ -12,10 +13,13 @@ import { type RegistryPlatformService, } from "../../packages/runtime/registry/platform.js"; import { runRegistryProgram } from "../../packages/runtime/registry/programs.js"; +import { flushRegistryOutbox } from "../../packages/runtime/registry/registry-outbox.js"; +import type { RegistryStateEntry } from "../../packages/runtime/registry/registry-state.js"; +import { runRegistrySync } from "../../packages/runtime/registry/sync.js"; function clientLayer(payload: unknown) { const service: RegistryClientService = { - download: () => Effect.die("download was not expected"), + download: () => Effect.die(new Error("download was not expected")), request: (schema: Schema.Decoder) => Schema.decodeUnknownEffect(schema)(payload), }; return Layer.succeed(RegistryClient, service); @@ -43,13 +47,17 @@ function platformLayer(state: RegistryPlatformService) { const unusedPlatform: RegistryPlatformService = { deviceId: "test-device", - installArchive: () => Effect.die("installArchive was not expected"), - installFromGithub: () => Effect.die("installFromGithub was not expected"), - loadState: () => Effect.die("loadState was not expected"), - preparePush: () => Effect.die("preparePush was not expected"), - resolveInstallTarget: () => Effect.die("resolveInstallTarget was not expected"), - validatePersistedTarget: () => Effect.die("validatePersistedTarget was not expected"), - withStateTransaction: () => Effect.die("withStateTransaction was not expected"), + computeInstalledContentHash: () => Effect.succeed("local-content-hash"), + computeArchiveContentHash: () => Effect.succeed("archive-content-hash"), + findProtectedPaths: () => Effect.succeed([]), + installArchive: () => Effect.die(new Error("installArchive was not expected")), + installFromGithub: () => Effect.die(new Error("installFromGithub was not expected")), + loadState: () => Effect.die(new Error("loadState was not expected")), + preparePackage: () => Effect.die(new Error("preparePackage was not expected")), + preparePush: () => Effect.die(new Error("preparePush was not expected")), + resolveInstallTarget: () => Effect.die(new Error("resolveInstallTarget was not expected")), + validatePersistedTarget: () => Effect.die(new Error("validatePersistedTarget was not expected")), + withStateTransaction: () => Effect.die(new Error("withStateTransaction was not expected")), }; describe("runRegistryProgram", () => { @@ -192,16 +200,23 @@ describe("runRegistryProgram", () => { expect(pushRequests[1]?.path).toBe(`/${encodedId}/versions`); const installRequests: RegistryRequestOptions[] = []; + let installState: RegistryStateEntry[] = []; const installPlatform: RegistryPlatformService = { ...unusedPlatform, - loadState: () => Effect.succeed([]), + loadState: () => Effect.succeed(installState), resolveInstallTarget: () => Effect.succeed({ installRoot: "/repo/.claude/skills", targetDir: "/repo/.claude/skills/deploy", }), installArchive: () => Effect.void, - withStateTransaction: (use) => use([]).pipe(Effect.map((decision) => decision.value)), + withStateTransaction: (use) => + use(installState).pipe( + Effect.map((decision) => { + if (decision._tag === "Commit") installState = [...decision.state]; + return decision.value; + }), + ), }; await Effect.runPromise( runRegistryProgram({ operation: "install", target: "deploy", global: false }).pipe( @@ -230,7 +245,7 @@ describe("runRegistryProgram", () => { }, ], }, - {}, + { data: { id: "installation-1" } }, ], installRequests, ), @@ -319,6 +334,7 @@ describe("runRegistryProgram", () => { name: "deploy", versionHash: "old-hash", installPath: "/repo/.claude/skills/deploy", + localContentHash: "local-content-hash", }; const platform: RegistryPlatformService = { ...unusedPlatform, @@ -370,4 +386,457 @@ describe("runRegistryProgram", () => { expect(result.stderr[0]).toContain("changed while its update was downloading"); expect(JSON.parse(result.stdout.at(-1) ?? "")).toEqual({ synced: 0, failed: 1, total: 1 }); }); + + test("blocks a remote rollout before download when protected local paths exist", async () => { + const requests: RegistryRequestOptions[] = []; + let downloads = 0; + let archiveInstalls = 0; + const local: RegistryStateEntry = { + entryId: "entry-1", + name: "deploy", + versionHash: "old-hash", + version: "1.0.0", + installPath: "/repo/.claude/skills/deploy", + localContentHash: "local-content-hash", + }; + const platform: RegistryPlatformService = { + ...unusedPlatform, + loadState: () => Effect.succeed([local]), + findProtectedPaths: () => Effect.succeed([".env.local", ".git", "node_modules"]), + validatePersistedTarget: () => + Effect.succeed({ + installRoot: "/repo/.claude/skills", + targetDir: "/repo/.claude/skills/deploy", + }), + installArchive: () => { + archiveInstalls += 1; + return Effect.void; + }, + }; + + const result = await Effect.runPromise( + runRegistryProgram({ operation: "sync" }).pipe( + Effect.provide( + Layer.merge( + recordingClientLayer( + [ + { + entries: [ + { + entry_id: "entry-1", + name: "deploy", + has_update: true, + latest_version: "2.0.0", + latest_content_hash: "update-hash", + current_version: "1.0.0", + download_url: "https://objects.test/archive", + }, + ], + }, + ], + requests, + Effect.sync(() => { + downloads += 1; + return new Uint8Array([1, 2, 3]); + }), + ), + platformLayer(platform), + ), + ), + ), + ); + + expect(downloads).toBe(0); + expect(archiveInstalls).toBe(0); + expect(requests).toHaveLength(1); + expect(result.stderr[0]).toContain("Protected local paths"); + }); + + test("reconciles a completed two-phase rollout after a crash without replacing files again", async () => { + const requests: RegistryRequestOptions[] = []; + let downloads = 0; + let archiveInstalls = 0; + const installedHash = "installed-update-hash"; + let state: RegistryStateEntry[] = [ + { + entryId: "entry-1", + name: "deploy", + versionHash: "old-hash", + version: "1.0.0", + versionId: "old-version-id", + installPath: "/repo/.claude/skills/deploy", + localContentHash: "old-installed-hash", + pendingUpdate: { + receiptId: "stable-receipt-id", + targetVersionHash: "update-hash", + targetVersion: "2.0.0", + targetVersionId: "new-version-id", + previousVersionId: "old-version-id", + observedContentHashBefore: "old-installed-hash", + expectedInstalledContentHash: installedHash, + }, + }, + ]; + const platform: RegistryPlatformService = { + ...unusedPlatform, + loadState: () => Effect.succeed(state), + computeInstalledContentHash: () => Effect.succeed(installedHash), + validatePersistedTarget: () => + Effect.succeed({ + installRoot: "/repo/.claude/skills", + targetDir: "/repo/.claude/skills/deploy", + }), + installArchive: () => { + archiveInstalls += 1; + return Effect.void; + }, + withStateTransaction: (use) => + use(state).pipe( + Effect.map((decision) => { + if (decision._tag === "Commit") state = [...decision.state]; + return decision.value; + }), + ), + }; + + const result = await Effect.runPromise( + runRegistryProgram({ operation: "sync" }).pipe( + Effect.provide( + Layer.merge( + recordingClientLayer( + [ + { + entries: [ + { + entry_id: "entry-1", + name: "deploy", + has_update: true, + latest_version: "2.0.0", + latest_version_id: "new-version-id", + latest_content_hash: "update-hash", + current_version: "1.0.0", + }, + ], + }, + ], + requests, + Effect.sync(() => { + downloads += 1; + return new Uint8Array([1, 2, 3]); + }), + ), + platformLayer(platform), + ), + ), + ), + ); + + expect(result.stderr).toEqual([]); + expect(downloads).toBe(0); + expect(archiveInstalls).toBe(0); + expect(state[0]?.pendingUpdate).toBeUndefined(); + expect(state[0]).toMatchObject({ + versionHash: "update-hash", + version: "2.0.0", + versionId: "new-version-id", + localContentHash: installedHash, + receiptId: "stable-receipt-id", + }); + }); + + test("applies the canonical archive when the workspace adopted the exact local suggestion", async () => { + const requests: RegistryRequestOptions[] = []; + const candidateHash = "a".repeat(64); + const observedHash = "c".repeat(64); + let archiveInstalls = 0; + let downloads = 0; + const local = { + entryId: "entry-1", + name: "deploy", + versionHash: "b".repeat(64), + version: "1.0.0", + versionId: "base-version", + installPath: "/repo/.claude/skills/deploy", + localContentHash: "d".repeat(64), + lastSuggestion: { + observedContentHash: observedHash, + candidateContentHash: candidateHash, + baseVersionHash: "b".repeat(64), + baseVersionId: "base-version", + contributionId: "contribution-1", + submittedAt: "2026-08-01T00:00:00.000Z", + }, + }; + const platform: RegistryPlatformService = { + ...unusedPlatform, + loadState: () => Effect.succeed([local]), + computeInstalledContentHash: () => Effect.succeed(observedHash), + validatePersistedTarget: () => + Effect.succeed({ + installRoot: "/repo/.claude/skills", + targetDir: "/repo/.claude/skills/deploy", + }), + installArchive: () => { + archiveInstalls += 1; + return Effect.void; + }, + withStateTransaction: (use) => use([local]).pipe(Effect.map((decision) => decision.value)), + }; + + const result = await Effect.runPromise( + runRegistryProgram({ operation: "sync" }).pipe( + Effect.provide( + Layer.merge( + recordingClientLayer( + [ + { + entries: [ + { + entry_id: "entry-1", + name: "deploy", + has_update: true, + latest_version: "1.0.1", + latest_version_id: "adopted-version", + latest_content_hash: candidateHash, + current_version: "1.0.0", + download_url: "https://objects.test/archive", + }, + ], + }, + ], + requests, + Effect.sync(() => { + downloads += 1; + return new Uint8Array([1, 2, 3]); + }), + ), + platformLayer(platform), + ), + ), + ), + ); + + expect(result.stderr).toEqual([]); + expect(archiveInstalls).toBe(0); + expect(downloads).toBe(0); + expect(JSON.parse(result.stdout.at(-1) ?? "")).toEqual({ synced: 1, failed: 0, total: 1 }); + }); + + test("scheduled automatic sync submits stable managed edits before checking rollout updates", async () => { + const requests: RegistryRequestOptions[] = []; + const observedHash = "c".repeat(64); + const candidateHash = "a".repeat(64); + let state: RegistryStateEntry[] = [ + { + entryId: "entry-1", + name: "deploy", + versionHash: "b".repeat(64), + version: "1.0.0", + versionId: "base-version", + installPath: "/repo/.claude/skills/deploy", + localContentHash: "d".repeat(64), + }, + ]; + const platform: RegistryPlatformService = { + ...unusedPlatform, + loadState: () => Effect.succeed(state), + computeInstalledContentHash: () => Effect.succeed(observedHash), + preparePackage: () => + Effect.succeed({ + name: "deploy", + description: "Deploy safely", + version: "1.0.0.team.cccccccccccc", + archiveBuffer: Buffer.from("candidate"), + archiveHash: candidateHash, + manifest: [{ path: "SKILL.md", hash: "e".repeat(64), size: 42 }], + }), + validatePersistedTarget: () => + Effect.succeed({ + installRoot: "/repo/.claude/skills", + targetDir: "/repo/.claude/skills/deploy", + }), + withStateTransaction: (use) => + use(state).pipe( + Effect.map((decision) => { + if (decision._tag === "Commit") state = [...decision.state]; + return decision.value; + }), + ), + }; + + const result = await Effect.runPromise( + runRegistrySync({ automaticOnly: true, suggestionStableForMs: 0 }).pipe( + Effect.provide( + Layer.merge( + recordingClientLayer( + [{ id: "contribution-1", status: "pending" }, { entries: [] }], + requests, + ), + platformLayer(platform), + ), + ), + ), + ); + + expect(result.exitCode).toBe(0); + expect(requests.map((request) => request.path)).toEqual([ + "/api/v1/collaboration/registry/entry-1/contributions", + "/sync", + ]); + expect(state[0]?.lastSuggestion).toMatchObject({ + observedContentHash: observedHash, + candidateContentHash: candidateHash, + contributionId: "contribution-1", + }); + }); + + test("submits the exact local candidate against its installed base without mutating state", async () => { + const requests: RegistryRequestOptions[] = []; + const candidateHash = "a".repeat(64); + const baseHash = "b".repeat(64); + const platform: RegistryPlatformService = { + ...unusedPlatform, + loadState: () => + Effect.succeed([ + { + entryId: "entry-1", + name: "deploy", + versionHash: baseHash, + installPath: "/repo/.claude/skills/deploy", + }, + ]), + preparePush: () => + Effect.succeed({ + name: "deploy", + description: "Deploy safely", + version: "1.0.1", + archiveBuffer: Buffer.from("exact-candidate"), + archiveHash: candidateHash, + manifest: [{ path: "SKILL.md", hash: "c".repeat(64), size: 42 }], + }), + }; + const result = await Effect.runPromise( + runRegistryProgram({ operation: "suggest", summary: "Handle rollback failures" }).pipe( + Effect.provide( + Layer.merge( + recordingClientLayer( + [ + { entries: [{ id: "entry-1", name: "deploy" }] }, + { + entry: { id: "entry-1", name: "deploy" }, + versions: [ + { + id: "base-version-id", + version: "1.0.0", + content_hash: baseHash, + is_current: false, + }, + { + id: "head-version-id", + version: "2.0.0", + content_hash: "d".repeat(64), + is_current: true, + }, + ], + }, + { id: "contribution-1", status: "pending" }, + ], + requests, + ), + platformLayer(platform), + ), + ), + ), + ); + + expect(result.exitCode).toBe(0); + expect(requests[2]?.path).toBe("/api/v1/collaboration/registry/entry-1/contributions"); + const metadata = JSON.parse(String(requests[2]?.formData?.get("metadata"))); + expect(metadata).toMatchObject({ + baseVersionId: "base-version-id", + candidateVersion: "1.0.1", + candidateContentHash: candidateHash, + summary: "Handle rollback failures", + }); + expect(JSON.parse(result.stdout[0] ?? "")).toMatchObject({ + contribution_id: "contribution-1", + base_version: "1.0.0", + status: "pending", + }); + }); + + test("keeps registration and rollout receipts in a durable outbox until delivery succeeds", async () => { + let offline = true; + const requests: RegistryRequestOptions[] = []; + let state: RegistryStateEntry[] = [ + { + entryId: "entry-1", + name: "deploy", + versionHash: "version-hash", + version: "2.0.0", + installPath: "/repo/.claude/skills/deploy", + localContentHash: "installed-hash", + pendingRegistration: { + receiptId: "registration-receipt", + installPath: "/repo/.claude/skills/deploy", + installedContentHash: "installed-hash", + }, + pendingReceipts: [ + { + receiptId: "update-receipt", + installedVersion: "2.0.0", + installedContentHash: "installed-hash", + previousVersionId: "version-1", + status: "updated", + }, + ], + }, + ]; + const platform: RegistryPlatformService = { + ...unusedPlatform, + loadState: () => Effect.succeed(state), + withStateTransaction: (use) => + use(state).pipe( + Effect.map((decision) => { + if (decision._tag === "Commit") state = [...decision.state]; + return decision.value; + }), + ), + }; + const client: RegistryClientService = { + download: () => Effect.die(new Error("download was not expected")), + request: (schema: Schema.Decoder, options: RegistryRequestOptions) => { + requests.push(options); + if (offline) { + return Effect.fail( + RegistryHttpError.make({ status: 503, message: "temporarily offline" }), + ); + } + return Schema.decodeUnknownEffect(schema)( + options.path.endsWith("/install") ? { data: { id: "installation-1" } } : {}, + ); + }, + }; + const layer = Layer.merge( + Layer.succeed(RegistryPlatform, platform), + Layer.succeed(RegistryClient, client), + ); + + await Effect.runPromise(flushRegistryOutbox().pipe(Effect.provide(layer))); + expect(state[0]?.pendingRegistration?.receiptId).toBe("registration-receipt"); + expect(state[0]?.pendingReceipts?.[0]?.receiptId).toBe("update-receipt"); + + offline = false; + await Effect.runPromise(flushRegistryOutbox().pipe(Effect.provide(layer))); + expect(state[0]?.installationId).toBe("installation-1"); + expect(state[0]?.pendingRegistration).toBeUndefined(); + expect(state[0]?.pendingReceipts).toBeUndefined(); + expect(requests.map((request) => request.path)).toEqual([ + "/entry-1/install", + "/entry-1/install", + "/api/v1/collaboration/registry/entry-1/installations/installation-1/receipt", + ]); + expect(requests[1]?.body).toMatchObject({ receipt_id: "registration-receipt" }); + expect(requests[2]?.body).toMatchObject({ receiptId: "update-receipt" }); + }); }); diff --git a/tests/registry/registry-state.test.ts b/tests/registry/registry-state.test.ts index 18d672d3..121c3c4a 100644 --- a/tests/registry/registry-state.test.ts +++ b/tests/registry/registry-state.test.ts @@ -28,6 +28,59 @@ describe("registry state validation", () => { ]); }); + test("decodes durable automatic suggestion debounce and dedupe receipts", () => { + const entry = { + entryId: "entry-1", + name: "reviewer", + versionHash: "b".repeat(64), + versionId: "base-version", + installPath: "/workspace/.claude/skills/reviewer", + localContentHash: "l".repeat(64), + automaticSuggestion: { + observedContentHash: "o".repeat(64), + baseVersionHash: "b".repeat(64), + baseVersionId: "base-version", + stableAt: 1_000, + attemptCount: 2, + nextAttemptAt: 31_000, + lastFailure: { kind: "retrying" as const, code: "http_503", at: 1_000 }, + }, + pendingRegistration: { + receiptId: "registration-receipt", + installPath: "/workspace/.claude/skills/reviewer", + installedContentHash: "l".repeat(64), + }, + pendingReceipts: [ + { + receiptId: "update-receipt", + installedVersion: "2.0.0", + installedContentHash: "n".repeat(64), + previousVersionId: "base-version", + status: "updated" as const, + }, + ], + pendingUpdate: { + receiptId: "pending-update-receipt", + targetVersionHash: "t".repeat(64), + targetVersion: "2.0.0", + targetVersionId: "target-version", + previousVersionId: "base-version", + observedContentHashBefore: "l".repeat(64), + expectedInstalledContentHash: "n".repeat(64), + }, + lastSuggestion: { + observedContentHash: "p".repeat(64), + candidateContentHash: "c".repeat(64), + baseVersionHash: "a".repeat(64), + baseVersionId: "previous-base", + contributionId: "contribution-1", + submittedAt: "2026-08-01T00:00:00.000Z", + }, + }; + + expect(decodeRegistryState([entry])).toEqual([entry]); + }); + test.each([ null, {}, diff --git a/tests/runtime/daemon.test.ts b/tests/runtime/daemon.test.ts index aa914a82..076387e3 100644 --- a/tests/runtime/daemon.test.ts +++ b/tests/runtime/daemon.test.ts @@ -162,10 +162,7 @@ describe("daemon options", () => { it("finalizes startup that completes while interruption is pending", async () => { temporaryConfigRoot(); const enteredStartup = Promise.withResolvers(); - const pendingStartup = Promise.withResolvers<{ - readonly shutdown: Promise; - readonly stop: () => void; - }>(); + const pendingStartup = Promise.withResolvers<{ readonly stop: () => void }>(); let stops = 0; const input: DaemonRunInput = { foreground: true, @@ -188,10 +185,7 @@ describe("daemon options", () => { await enteredStartup.promise; const interruption = Effect.runPromise(Fiber.interrupt(fiber)); - pendingStartup.resolve({ - shutdown: new Promise(() => undefined), - stop: () => (stops += 1), - }); + pendingStartup.resolve({ stop: () => (stops += 1) }); await interruption; expect(stops).toBe(1); @@ -345,10 +339,8 @@ describe("daemon options", () => { expect(events).toEqual(["manifest:write", "manifest:remove", "server:stop", "lock:stop"]); }); - it("finalizes authenticated runtime shutdown through the daemon scope", async () => { + it("passes authenticated ownership identity to the dashboard health endpoint", async () => { const configDir = temporaryConfigRoot(); - const events: string[] = []; - const started = Promise.withResolvers(); const options = resolveDaemonRunOptions({ configDir, foreground: true, @@ -359,48 +351,22 @@ describe("daemon options", () => { }); let serverOptions: Parameters[0] | undefined; const dependencies: DaemonStartDependencies = { - acquireLock: () => { - events.push("lock:acquire"); - return { - port: 0, - stop: () => { - events.push("lock:stop"); - }, - }; - }, + acquireLock: () => ({ port: 0, stop: () => undefined }), createInstanceId: () => "instance-id", executablePath: "/Applications/SelfTune.app/Contents/MacOS/SelfTune", installedVersion: () => "1.0.0", loadAuthToken: () => "token", printReady: () => undefined, processId: 42, - removeManifest: () => events.push("manifest:remove"), + removeManifest: () => undefined, startServer: async (input) => { - events.push("server:start"); serverOptions = input; - return { - close: async () => { - events.push("server:close"); - }, - port: 4123, - stop: () => events.push("server:stop"), - }; - }, - writeManifest: () => { - events.push("manifest:write"); - started.resolve(); + return { port: 4123, stop: () => undefined }; }, + writeManifest: () => undefined, }; - const completion = Effect.runPromise( - runDaemonProgram( - { foreground: true, readySentinel: false, supervised: true }, - { - resolveOptions: () => options, - start: (resolved) => startDaemon(resolved, dependencies), - }, - ), - ); - await started.promise; + + await Effect.runPromise(Effect.scoped(startDaemon(options, dependencies))); expect(serverOptions).toMatchObject({ runtimeIdentity: { @@ -413,17 +379,6 @@ describe("daemon options", () => { }); expect(serverOptions?.runtimeIdentity?.serviceInstallationNonce).toBe(serviceInstallationNonce); expect(typeof serverOptions?.runtimeShutdown).toBe("function"); - serverOptions?.runtimeShutdown?.(); - await completion; - - expect(events).toEqual([ - "lock:acquire", - "server:start", - "manifest:write", - "manifest:remove", - "server:close", - "lock:stop", - ]); }); it("attempts every release when individual cleanup actions throw", async () => { diff --git a/tests/runtime/service-systemd-backend.test.ts b/tests/runtime/service-systemd-backend.test.ts index e112036f..6cc2828e 100644 --- a/tests/runtime/service-systemd-backend.test.ts +++ b/tests/runtime/service-systemd-backend.test.ts @@ -69,7 +69,7 @@ describe("systemd service backend", () => { expect(systemdLingerArguments(true, "tester")).toEqual(["enable-linger", "tester"]); expect(systemdLingerArguments(false, "tester")).toEqual(["disable-linger", "tester"]); expect(systemdLingerMarkerPath("/home/test/.selftune")).toBe( - join("/home/test/.selftune", "server-control", "systemd-linger-enabled-by-selftune"), + "/home/test/.selftune/server-control/systemd-linger-enabled-by-selftune", ); }); diff --git a/tests/runtime/service-windows-installation-controller.test.ts b/tests/runtime/service-windows-installation-controller.test.ts index afadfd58..9bbf1f2d 100644 --- a/tests/runtime/service-windows-installation-controller.test.ts +++ b/tests/runtime/service-windows-installation-controller.test.ts @@ -186,7 +186,6 @@ interface HarnessOptions { readonly receiptWriteFails?: boolean; readonly receiptWriteFailsAt?: number; readonly replacementDuringArtifactRemovalPath?: string; - readonly resolvedAccountSid?: string | null; readonly startFailsAt?: number; readonly tamperAfterEnd?: boolean; readonly taskDefinition?: string; @@ -280,14 +279,6 @@ function harness(options: HarnessOptions = {}) { events.push("sid:read"); return sid; }), - resolveWindowsAccountSid: (accountName) => - Effect.succeed( - options.resolvedAccountSid === undefined - ? accountName.toLocaleLowerCase("en-US") === "runneradmin" - ? sid - : null - : options.resolvedAccountSid, - ), requireLegacyCleanup: () => Effect.die("unused requireLegacyCleanup"), writeReceipt: (next, expectedPrior) => Effect.try({ @@ -520,45 +511,12 @@ describe("Windows service installation ownership controller", () => { taskDefinition: "", }); - await expect(Effect.runPromise(test.controller.inspect(plan))).resolves.toMatchObject({ - _tag: "Refused", - reason: "registered-task-definition-task-namespace-mismatch", - }); await expect(Effect.runPromise(test.controller.stop(plan))).rejects.toMatchObject({ operation: "stop", }); expect(mutationEvents(test.events)).toEqual([]); }); - it("accepts a scheduler-normalized trigger account proven by the current user token", async () => { - const owned = makeOwnedReceipt(); - const generatedDefinition = renderedArtifacts(owned.receipt).taskDefinitionXml; - const generatedTrigger = `true${sid}`; - expect(generatedDefinition).toContain(generatedTrigger); - const taskDefinition = generatedDefinition.replace( - generatedTrigger, - "runneradmin", - ); - const test = harness({ initialReceipt: owned.receipt, taskDefinition }); - - await expect(Effect.runPromise(test.controller.inspect(plan))).resolves.toMatchObject({ - _tag: "Owned", - receipt: { installId }, - }); - expect(mutationEvents(test.events)).toEqual([]); - - const foreign = harness({ - initialReceipt: owned.receipt, - resolvedAccountSid: "S-1-5-21-9999-9999-9999-9999", - taskDefinition, - }); - await expect(Effect.runPromise(foreign.controller.inspect(plan))).resolves.toMatchObject({ - _tag: "Refused", - reason: "registered-task-definition-logon-trigger-sid-mismatch", - }); - expect(mutationEvents(foreign.events)).toEqual([]); - }); - it("persists install intent before unique artifacts, exclusive create, and start", async () => { const test = harness(); const installed = await Effect.runPromise(test.controller.install(plan)); diff --git a/tests/runtime/service-windows-installation-evidence.test.ts b/tests/runtime/service-windows-installation-evidence.test.ts index ea4d817f..2cdb4218 100644 --- a/tests/runtime/service-windows-installation-evidence.test.ts +++ b/tests/runtime/service-windows-installation-evidence.test.ts @@ -95,49 +95,6 @@ describe("Windows service task definition evidence", () => { expect(matchWindowsServiceTaskDefinition(normalized, expectation)).toEqual({ matches: true }); }); - it("matches Windows Server 2025 exports that elide exact schema defaults", () => { - let exported = taskXml(); - exported = replaceOnce(exported, "LeastPrivilege", ""); - exported = replaceOnce( - exported, - `true${expectation.userSid}`, - `${expectation.userSid}`, - ); - for (const exactDefault of [ - "true", - "false", - "true", - "true", - "false", - "false", - "false", - "7", - ]) { - exported = replaceOnce(exported, exactDefault, ""); - } - expect(matchWindowsServiceTaskDefinition(exported, expectation)).toEqual({ matches: true }); - }); - - it("accepts a scheduler-normalized trigger account only after Windows proves it", () => { - const accountName = "WORKGROUP\\Test"; - const exported = replaceOnce( - taskXml(), - `true${expectation.userSid}`, - `true${accountName}`, - ); - expect( - matchWindowsServiceTaskDefinition(exported, { - ...expectation, - provenLogonTriggerUserId: accountName, - }), - ).toEqual({ matches: true }); - expectMismatch(exported, "logon-trigger-sid-mismatch"); - expectMismatch(exported, "logon-trigger-sid-mismatch", { - ...expectation, - provenLogonTriggerUserId: "WORKGROUP\\Other", - }); - }); - it("matches case-insensitive canonical Windows path and SID identities", () => { let normalized = taskXml(); normalized = replaceOnce( @@ -285,12 +242,6 @@ describe("Windows service task definition evidence", () => { for (const entry of cases) { expectMismatch(replaceOnce(taskXml(), entry.from, entry.to), entry.reason); } - const bootExpectation = { ...expectation, boot: true }; - expectMismatch( - taskXml(bootExpectation).replace("HighestAvailable", ""), - "principal-run-level-mismatch", - bootExpectation, - ); expectMismatch( taskXml().replace("", ''), "principal-count-mismatch", diff --git a/tests/runtime/service-windows-installation-live.test.ts b/tests/runtime/service-windows-installation-live.test.ts index 1a968577..2c87e7fb 100644 --- a/tests/runtime/service-windows-installation-live.test.ts +++ b/tests/runtime/service-windows-installation-live.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import { + chmod, link, mkdtemp, open, @@ -107,7 +108,7 @@ describe("live Windows service installation dependencies", () => { await Effect.runPromise(artifacts.write(path, contents)); await expect(Effect.runPromise(artifacts.read(path))).resolves.toEqual(contents); - if (process.platform !== "win32") expect((await stat(path)).mode & 0o777).toBe(0o600); + expect((await stat(path)).mode & 0o777).toBe(0o600); await expect( Effect.runPromise(artifacts.write(path, new Uint8Array([9]))), ).rejects.toMatchObject({ code: "EEXIST" }); @@ -514,10 +515,10 @@ describe("live Windows service installation dependencies", () => { ), ).rejects.toMatchObject({ code: "EEXIST" }); expect(await readFile(receiptTemp, "utf8")).toBe("first"); - if (process.platform !== "win32") expect((await stat(receiptTemp)).mode & 0o777).toBe(0o600); + expect((await stat(receiptTemp)).mode & 0o777).toBe(0o600); }); - it("renames atomically and removes files idempotently", async () => { + it("renames atomically and removes files idempotently without masking other failures", async () => { const fileSystem = makeLiveWindowsInstallationFileSystem(); const directory = await temporaryDirectory(); const source = join(directory, "receipt.tmp"); @@ -531,27 +532,16 @@ describe("live Windows service installation dependencies", () => { await Effect.runPromise(fileSystem.removeFile(destination)); await expect(Effect.runPromise(fileSystem.removeFile(destination))).resolves.toBeUndefined(); - }); - it("preserves non-missing removal failures without depending on host errno behavior", async () => { - const removalFailure = Object.assign(new Error("cannot unlink directory"), { - code: "EISDIR", - }); - const fileSystem = makeLiveWindowsInstallationFileSystem({ - makeDirectory: async () => undefined, - openExclusive: async () => { - throw new Error("unexpected open"); - }, - readUtf8File: async () => "", - remove: async () => { - throw removalFailure; - }, - rename: async () => undefined, - }); - - await expect(Effect.runPromise(fileSystem.removeFile("directory"))).rejects.toBe( - removalFailure, - ); + await chmod(directory, 0o500); + try { + await expect(Effect.runPromise(fileSystem.removeFile(directory))).rejects.toMatchObject({ + // Node reports EPERM on macOS and EISDIR on Linux for unlinking a directory. + code: expect.stringMatching(/^(?:EISDIR|EPERM)$/), + }); + } finally { + await chmod(directory, 0o700); + } }); it("passes the injected process executor and SystemRoot through to SID resolution", async () => { diff --git a/tests/runtime/service-windows-installation-store.test.ts b/tests/runtime/service-windows-installation-store.test.ts index f8df3837..84413b5d 100644 --- a/tests/runtime/service-windows-installation-store.test.ts +++ b/tests/runtime/service-windows-installation-store.test.ts @@ -9,7 +9,6 @@ import { expectWindowsServiceInstallationReceipt, makeWindowsServiceInstallationStore, parseLocalAppDataOutput, - parseResolvedWindowsAccountSid, parseWhoamiUserCsv, type WindowsInstallationCommandResult, type WindowsInstallationFileSystem, @@ -72,8 +71,6 @@ const input: WindowsServiceInstallationReceiptInput = { }; interface HarnessOptions { - readonly accountSidCode?: number; - readonly accountSidOutput?: string; readonly aclCode?: number; readonly aclOutput?: string; readonly invalidRandomLength?: boolean; @@ -177,15 +174,7 @@ function installationHarness(options: HarnessOptions = {}) { options.whoamiCode ? "whoami denied" : "", ); } - const commandIndex = args.indexOf("-Command"); - const script = commandIndex >= 0 ? (args[commandIndex + 1] ?? "") : ""; - if (script.includes("SELFTUNE_RESOLVED_ACCOUNT_SID_V1:")) { - return commandResult( - options.accountSidCode ?? 0, - options.accountSidOutput ?? `SELFTUNE_RESOLVED_ACCOUNT_SID_V1:${userSid}\r\n`, - options.accountSidCode ? "account lookup denied" : "", - ); - } + const script = args.at(-1) ?? ""; if (script.includes("LocalApplicationData")) { return commandResult( options.localAppDataCode ?? 0, @@ -243,54 +232,16 @@ describe("Windows service installation store", () => { parseWhoamiUserCsv(`"DOMAIN\\User","${userSid}"\n"OTHER\\User","S-1-5-21-2"`), ).toBeNull(); expect(parseWhoamiUserCsv(`"unterminated,${userSid}`)).toBeNull(); - expect(parseResolvedWindowsAccountSid(`SELFTUNE_RESOLVED_ACCOUNT_SID_V1:${userSid}\r\n`)).toBe( - userSid, - ); - expect(parseResolvedWindowsAccountSid("noise")).toBeNull(); }); it("resolves whoami from System32 and fails closed on command or parse errors", async () => { const valid = installationHarness(); await expect(Effect.runPromise(valid.store.resolveCurrentUserSid())).resolves.toBe(userSid); - const resolveWindowsAccountSid = valid.store.resolveWindowsAccountSid; - if (resolveWindowsAccountSid === undefined) { - throw new Error("Expected the live store to resolve Windows account identifiers."); - } - await expect(Effect.runPromise(resolveWindowsAccountSid("runneradmin"))).resolves.toBe(userSid); expect(valid.calls[0]).toEqual({ args: ["/user", "/fo", "csv", "/nh"], command: "D:\\Windows\\System32\\whoami.exe", type: "process", }); - const accountResolution = valid.calls[1]; - expect(accountResolution?.type).toBe("process"); - if (accountResolution?.type !== "process") { - throw new Error("Expected a Windows account-resolution process call."); - } - expect(accountResolution.args.at(-1)).toContain( - Buffer.from("runneradmin", "utf8").toString("base64"), - ); - expect(accountResolution.args.join(" ")).not.toContain("runneradmin"); - - const unknownAccount = installationHarness({ accountSidCode: 1 }); - const resolveUnknownAccount = unknownAccount.store.resolveWindowsAccountSid; - if (resolveUnknownAccount === undefined) { - throw new Error("Expected the live store to resolve Windows account identifiers."); - } - await expect(Effect.runPromise(resolveUnknownAccount("unknown"))).resolves.toBeNull(); - - const malformedAccount = installationHarness({ accountSidOutput: "unstructured" }); - const resolveMalformedAccount = malformedAccount.store.resolveWindowsAccountSid; - if (resolveMalformedAccount === undefined) { - throw new Error("Expected the live store to resolve Windows account identifiers."); - } - await expect(Effect.runPromise(resolveMalformedAccount("runneradmin"))).rejects.toMatchObject({ - operation: "resolve-windows-account-sid", - }); - await expect( - Effect.runPromise(resolveWindowsAccountSid(`${userSid.toLowerCase()}`)), - ).resolves.toBe(userSid); - await expect(Effect.runPromise(resolveWindowsAccountSid("bad\naccount"))).resolves.toBeNull(); const denied = installationHarness({ whoamiCode: 5 }); await expect(Effect.runPromise(denied.store.createReceipt(input))).rejects.toMatchObject({ @@ -362,15 +313,6 @@ describe("Windows service installation store", () => { expect(aclCall.args[6]).toContain("ReparsePoint"); expect(aclCall.args[6]).toContain("AreAccessRulesProtected"); expect(aclCall.args[6]).toContain("GetAccessRules"); - expect(aclCall.args[6]).toContain( - "Modules\\Microsoft.PowerShell.Security\\Microsoft.PowerShell.Security.psd1", - ); - expect(aclCall.args[6]).toContain( - "Import-Module -Name $securityModule -Force -ErrorAction Stop", - ); - expect(aclCall.args[6]?.indexOf("Import-Module")).toBeLessThan( - aclCall.args[6]?.indexOf("Set-Acl") ?? -1, - ); expect(test.calls.slice(3)).toEqual([ { contents: `${JSON.stringify(receipt)}\n`, diff --git a/tests/runtime/service-windows-legacy-cleanup-controller.test.ts b/tests/runtime/service-windows-legacy-cleanup-controller.test.ts index 4e166cff..1b2c1fed 100644 --- a/tests/runtime/service-windows-legacy-cleanup-controller.test.ts +++ b/tests/runtime/service-windows-legacy-cleanup-controller.test.ts @@ -91,7 +91,6 @@ interface HarnessOptions { readonly failure?: FailurePoint; readonly files?: ReadonlyArray<"launcher" | "taskDefinition" | "wrapper">; readonly journal?: WindowsServiceLegacyCleanupJournal; - readonly resolvedAccountSid?: string | null; readonly replacementDuringArtifactRemovalPath?: string; readonly taskDefinition?: string; readonly taskRegistered?: boolean; @@ -99,7 +98,6 @@ interface HarnessOptions { function harness(options: HarnessOptions = {}) { const events: string[] = []; - const resolvedAccountNames: string[] = []; const files = new Map(); for (const key of options.files ?? ["launcher", "taskDefinition", "wrapper"]) { files.set(paths[key], contents[key]); @@ -161,11 +159,6 @@ function harness(options: HarnessOptions = {}) { catch: (cause) => cause, }), resolveCurrentUserSid: () => Effect.succeed(sid), - resolveWindowsAccountSid: (accountName) => - Effect.sync(() => { - resolvedAccountNames.push(accountName); - return options.resolvedAccountSid === undefined ? sid : options.resolvedAccountSid; - }), writeReceipt: () => Effect.die("unused writeReceipt"), }; const scheduler: WindowsTaskScheduler = { @@ -223,7 +216,6 @@ function harness(options: HarnessOptions = {}) { events, files, journal: () => journal, - resolvedAccountNames, mutateJournal: () => { if (journal === null) throw new Error("journal missing"); journal = createWindowsServiceLegacyCleanupJournal( @@ -244,17 +236,6 @@ function harness(options: HarnessOptions = {}) { }; } -function schedulerNormalizedLegacyTaskDefinition(accountName: string): string { - return generateLegacyWindowsTaskXml({ - boot: false, - launcherPath: paths.launcher, - userId: sid, - }).replace( - `true${sid}`, - `true${accountName}`, - ); -} - function installationHarness(initial: "absent" | "legacy" | "pending") { const events: string[] = []; const files = new Map([ @@ -593,54 +574,6 @@ describe("Windows legacy cleanup controller", () => { ).resolves.toMatchObject({ _tag: "Pending", task: { registered: false, running: false } }); }); - it("reproves a scheduler-normalized trigger account before legacy cleanup mutation", async () => { - const journal = createWindowsServiceLegacyCleanupJournal(input, metadata); - const accountName = "runneradmin"; - const test = harness({ - journal, - taskDefinition: schedulerNormalizedLegacyTaskDefinition(accountName), - }); - - await expect(Effect.runPromise(test.controller.inspect(configDir, sid))).resolves.toMatchObject( - { - _tag: "Pending", - task: { registered: true, running: true }, - }, - ); - expect(test.resolvedAccountNames).toEqual([accountName]); - - await Effect.runPromise(test.controller.resume(configDir)); - expect(test.resolvedAccountNames.every((name) => name === accountName)).toBe(true); - expect(test.events).toContain("task:end"); - expect(test.events).toContain("task:delete"); - expect(test.journal()).toBeNull(); - expect(test.files.size).toBe(0); - }); - - it("refuses a scheduler-normalized trigger account proven to be a foreign SID", async () => { - const journal = createWindowsServiceLegacyCleanupJournal(input, metadata); - const accountName = "runneradmin"; - const test = harness({ - journal, - resolvedAccountSid: "S-1-5-21-9999-9999-9999-9999", - taskDefinition: schedulerNormalizedLegacyTaskDefinition(accountName), - }); - - await expect(Effect.runPromise(test.controller.inspect(configDir, sid))).resolves.toMatchObject( - { - _tag: "Refused", - reason: "legacy-cleanup-task-definition-mismatch", - }, - ); - await expect(Effect.runPromise(test.controller.resume(configDir))).rejects.toMatchObject({ - operation: "verify-legacy-cleanup-task-definition", - }); - expect(test.resolvedAccountNames.every((name) => name === accountName)).toBe(true); - expect(test.events).not.toContain("task:end"); - expect(test.events).not.toContain("task:delete"); - expect(test.journal()).toEqual(journal); - }); - it("refuses a journal whose canonical config differs from its locator", async () => { const otherConfigDir = "C:\\Users\\Other\\.selftune"; const otherControlDir = `${otherConfigDir}\\server-control`; diff --git a/tests/runtime/service-windows-legacy-migration.test.ts b/tests/runtime/service-windows-legacy-migration.test.ts index 1a5546f0..2a4d83b5 100644 --- a/tests/runtime/service-windows-legacy-migration.test.ts +++ b/tests/runtime/service-windows-legacy-migration.test.ts @@ -353,15 +353,6 @@ describe("Windows pre-receipt installation migration", () => { expect(matchLegacyWindowsServiceTaskDefinition(normalizedLegacyTaskXml, expectation)).toEqual({ matches: true, }); - expect( - matchLegacyWindowsServiceTaskDefinition( - normalizedLegacyTaskXml.replace( - "falsetrue", - "false", - ), - expectation, - ), - ).toEqual({ matches: true }); expect( matchLegacyWindowsServiceTaskDefinition(historicalTaskXml, { ...expectation, diff --git a/tests/runtime/service-windows-mutation-lock.test.ts b/tests/runtime/service-windows-mutation-lock.test.ts index 685ce33b..be2e0eb3 100644 --- a/tests/runtime/service-windows-mutation-lock.test.ts +++ b/tests/runtime/service-windows-mutation-lock.test.ts @@ -85,7 +85,7 @@ function fakeDatabases( run: (sql) => { if (closed) throw new Error("database is closed"); if (sql === options.failOn) throw new Error(`failed: ${sql}`); - if (sql === "BEGIN IMMEDIATE") { + if (sql === "BEGIN EXCLUSIVE") { if (state.owners.has(path)) throw new FakeSqliteError("SQLITE_BUSY", 5); state.owners.set(path, database); ownsTransaction = true; @@ -219,11 +219,11 @@ describe("Windows service mutation lock", () => { }); it("closes a database when initialization fails without treating it as contention", async () => { - const fake = fakeDatabases({ failOn: "BEGIN IMMEDIATE" }); + const fake = fakeDatabases({ failOn: "BEGIN EXCLUSIVE" }); const lock = makeTestLock(fake.openDatabase); await expect(Effect.runPromise(lock.acquire(userScope))).rejects.toMatchObject({ - message: "failed: BEGIN IMMEDIATE", + message: "failed: BEGIN EXCLUSIVE", operation: "initialize-user-service-mutation-lock", }); expect(fake.state.databases[0]?.closed()).toBe(true); diff --git a/tests/runtime/service.test.ts b/tests/runtime/service.test.ts index 420871ce..aa66fc30 100644 --- a/tests/runtime/service.test.ts +++ b/tests/runtime/service.test.ts @@ -332,13 +332,12 @@ describe("supervised service definitions", () => { it("terminates an owned subprocess when its Effect is interrupted", async () => { const root = mkdtempSync(join(tmpdir(), "selftune-service-process-")); roots.push(root); - const pidPath = join(root, "pid-');process.exit(99);('"); + const pidPath = join(root, "pid"); const controller = new AbortController(); const running = Effect.runPromise( runServiceProcess(process.execPath, [ "-e", - 'process.on("SIGTERM", () => {}); const pidPath = process.argv[1]; if (typeof pidPath !== "string") process.exit(64); require("node:fs").writeFileSync(pidPath, String(process.pid)); setInterval(() => {}, 1000);', - pidPath, + `process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidPath)}, String(process.pid)); setInterval(() => {}, 1000);`, ]), { signal: controller.signal }, ); @@ -367,7 +366,6 @@ describe("supervised service definitions", () => { }); it("runs daemon mode through the same SelfTune binary", () => { - if (!descriptor.resourceDir) throw new Error("Desktop test descriptor needs a resource root."); expect(serviceProgramArguments(descriptor)).toEqual([ descriptor.executablePath, "daemon", @@ -383,7 +381,7 @@ describe("supervised service definitions", () => { "--runtime-mode", "standalone", "--spa-dir", - join(descriptor.resourceDir, "dashboard"), + "/Applications/SelfTune.app/Contents/Resources/selftune/dashboard", ]); }); @@ -759,19 +757,18 @@ describe("supervised service definitions", () => { }); it("preserves ordinary Windows wrapper paths and arguments", () => { - const configDir = "C:\\Users\\test user\\.selftune"; const wrapper = generateWindowsDaemonWrapper({ ...descriptor, executableArgsPrefix: ["--channel", "stable release"], executablePath: "C:\\Program Files\\SelfTune\\selftune.exe", - configDir, + configDir: "C:\\Users\\test user\\.selftune", resourceDir: "C:\\Program Files\\SelfTune", }); expect(wrapper).not.toContain("setlocal DisableDelayedExpansion"); expect(wrapper).toContain('"C:\\Program Files\\SelfTune\\selftune.exe"'); expect(wrapper).toContain('"--channel" "stable release" "daemon"'); - expect(wrapper).toContain(`1>> "${join(configDir, "logs", "daemon.log")}"`); + expect(wrapper).toContain('1>> "C:\\Users\\test user\\.selftune/logs/daemon.log"'); }); it("escapes percent expansion and safely preserves quoted CMD metacharacters", () => { @@ -784,11 +781,7 @@ describe("supervised service definitions", () => { `SELFTUNE_CONFIG_DIR=C:\\Users\\test${renderedCharacter}profile\\.selftune`, ); expect(wrapper).toContain( - `1>> "${join( - `C:\\Users\\test${renderedCharacter}profile\\.selftune`, - "logs", - "daemon.log", - )}"`, + `1>> "C:\\Users\\test${renderedCharacter}profile\\.selftune/logs/daemon.log"`, ); expect(wrapper.includes("setlocal DisableDelayedExpansion")).toBe(character === "!"); } diff --git a/tests/schedule/schedule.test.ts b/tests/schedule/schedule.test.ts index 4d939399..86650ffa 100644 --- a/tests/schedule/schedule.test.ts +++ b/tests/schedule/schedule.test.ts @@ -48,9 +48,11 @@ describe("SCHEDULE_ENTRIES", () => { expect(orchestrate?.command).toContain("selftune run"); }); - test("frequent sync imports incrementally without rebuilding all history", () => { + test("frequent sync imports incrementally and applies policy-approved registry updates", () => { const sync = SCHEDULE_ENTRIES.find((entry) => entry.name === "selftune-sync"); - expect(sync?.command).toBe("selftune sync --no-repair"); + expect(sync?.command).toBe( + "selftune registry sync --automatic-only; selftune sync --no-repair", + ); }); test("derives from DEFAULT_CRON_JOBS (shared source of truth)", () => { @@ -155,10 +157,11 @@ describe("generateSystemd", () => { expect(output).toMatch(/sync && .*status/); }); - test("uses resolved binary path for simple entries", () => { + test("uses resolved binary paths for the chained sync entry", () => { const output = generateSystemd(); - // sync command should use absolute path, not bare "selftune" - expect(output).toMatch(/ExecStart=\/.*selftune sync --no-repair\n/); + expect(output).toMatch( + /ExecStart=\/bin\/sh -c ".*selftune registry sync --automatic-only; .*selftune sync --no-repair"/, + ); }); test("includes Environment with PATH and HOME", () => { diff --git a/tests/scripts/release-workflows.test.ts b/tests/scripts/release-workflows.test.ts index bcdc0b51..816d360d 100644 --- a/tests/scripts/release-workflows.test.ts +++ b/tests/scripts/release-workflows.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -119,12 +119,6 @@ describe("release version invariant", () => { }); describe("Changesets release ownership", () => { - test("runs the POSIX Effect preparation script through a portable shell", () => { - const prepareCommand = repositoryJson("package.json").scripts.prepare as string; - - expect(prepareCommand).toMatch(/^sh \.\/scripts\/prepare-effect\.sh &&/); - }); - test("uses the workspace-visible desktop package to version the coupled release train", () => { expect(repositoryJson("package.json")).toMatchObject({ name: "selftune", @@ -161,6 +155,15 @@ describe("Changesets release ownership", () => { expect(workflow("ci.yml")).toMatchObject({ on: { pull_request: { branches: ["main"] }, workflow_dispatch: {} }, }); + + const changesetDirectory = resolve(repositoryRoot, ".changeset"); + const pendingChangesets = readdirSync(changesetDirectory) + .filter((name) => name.endsWith(".md") && name !== "README.md") + .map((name) => readFileSync(resolve(changesetDirectory, name), "utf8")); + expect(pendingChangesets.length).toBeGreaterThan(0); + for (const changeset of pendingChangesets) { + expect(changeset).not.toContain('"selftune":'); + } }); test("requires changesets read-only for every shipped pull-request surface", () => { @@ -184,8 +187,6 @@ describe("Changesets release ownership", () => { "bun.lock", "cli/selftune/**", "package.json", - "packages/api-contract/**", - "packages/app-core/**", "packages/control-plane/**", "packages/dashboard-core/**", "packages/harnesses/**", @@ -208,60 +209,15 @@ describe("Changesets release ownership", () => { expect(changesetGate).toContain("github.head_ref == 'changeset-release/main'"); expect(changesetGate).not.toContain("bump-patch"); expect(changesetGate).not.toContain("git push"); - expect(workflow("desktop.yml")).toMatchObject({ - on: { - pull_request: { - paths: expect.arrayContaining(["packages/api-contract/**", "packages/app-core/**"]), - }, - }, - }); }); test("routes workspace suites through their native test and typecheck commands", () => { - const testCommand = repositoryJson("package.json").scripts.test as string; - const typecheckCommand = repositoryJson("package.json").scripts.typecheck as string; - for (const name of ["ci.yml", "publish.yml"]) { const source = workflowText(name); expect(source).toContain("bun run test"); expect(source).toContain("bun run typecheck"); - expect(source).not.toContain("bun run test:legacy"); expect(source).not.toContain("bun test tests/ packages/telemetry-contract/"); } - expect(repositoryJson("package.json").scripts.check).not.toContain("bun run test:legacy"); - - for (const packageName of [ - "api-contract", - "app-core", - "control-plane", - "dashboard-core", - "ui", - ]) { - expect(repositoryJson(`packages/${packageName}/package.json`).scripts.test).toBe( - "vitest run", - ); - expect(testCommand).toContain(`--filter=@selftune/${packageName}`); - } - expect(testCommand).not.toContain("--filter=@selftune/telemetry-contract"); - expect(testCommand).toContain("--concurrency=1"); - for (const packageName of ["api-contract", "app-core"]) { - expect(typecheckCommand).toContain(`--filter=@selftune/${packageName}`); - } - - const codexHarnessTest = repositoryJson("packages/harnesses/codex/package.json").scripts - .test as string; - for (const path of [ - "codex-internal-target-safety.test.ts", - "codex-rollout-discovery.test.ts", - "codex-rollout-explicit-skills.test.ts", - "codex-trace-projection.test.ts", - "rollout-line-scanner.test.ts", - ]) { - expect(codexHarnessTest).toContain(path); - } - expect(repositoryJson("packages/harnesses/core/package.json").scripts.test).toContain( - "harness-source-adapters.test.ts", - ); }); }); @@ -330,11 +286,27 @@ describe("parsed release workflow graph", () => { workflow_dispatch: { inputs: immutableInputs }, }, jobs: { - "attach-release": { needs: "build" }, + "attach-release": { + needs: "build", + steps: expect.arrayContaining([ + expect.objectContaining({ + with: expect.objectContaining({ pattern: "selftune-desktop-*" }), + }), + ]), + }, build: { needs: "build-windows-sidecar", "timeout-minutes": 60 }, "build-windows-sidecar": { env: { BUN_TARGET: "bun-windows-x64" }, "runs-on": "ubuntu-latest", + steps: expect.arrayContaining([ + expect.objectContaining({ run: "bun run --cwd apps/desktop build:sidecar" }), + expect.objectContaining({ + with: expect.objectContaining({ + name: "selftune-runtime-windows-x64", + path: "apps/desktop/resources/selftune/selftune.exe", + }), + }), + ]), }, }, }); @@ -362,20 +334,10 @@ describe("parsed release workflow graph", () => { test("keeps exact image and packaged-app smoke gates before promotion", () => { const desktop = workflowText("desktop.yml"); const selfhost = workflowText("selfhost-image.yml"); - const selfhostCompose = readFileSync( - resolve(repositoryRoot, "apps/selfhost/docker-compose.yml"), - "utf8", - ); - const selfhostDockerfile = readFileSync( - resolve(repositoryRoot, "apps/selfhost/Dockerfile"), - "utf8", - ); - const selfhostSmoke = readFileSync( - resolve(repositoryRoot, ".github/scripts/smoke-selfhost-image.sh"), - "utf8", - ); expect(desktop).toContain("smoke:packaged"); + expect(desktop).toContain("SELFTUNE_PREBUILT_SIDECAR"); + expect(desktop).toContain("Download cross-compiled Windows sidecar"); expect(desktop).toContain("Smoke Windows service lifecycle"); expect(desktop).toContain("scripts/smoke-windows-service.ps1"); expect(desktop).toContain("codesign --verify --deep --strict"); @@ -385,26 +347,6 @@ describe("parsed release workflow graph", () => { expect(selfhost).toContain("smoke-selfhost-image.sh"); expect(selfhost).toContain('"${REGISTRY_IMAGE}@${DIGEST}"'); expect(selfhost).toContain("--metadata-file /tmp/selfhost-release-metadata.json"); - expect(selfhostSmoke).toContain("docker image inspect --format '{{.Id}}'"); - expect(selfhostSmoke).toContain("docker inspect --format '{{.Image}}'"); - expect(selfhostSmoke).toContain("read -r token < <(openssl rand -hex 32)"); - expect(selfhostSmoke).toContain("read -r member_token < <(openssl rand -hex 32)"); - expect(selfhostSmoke).toContain('[[ "$token" == "$member_token" ]]'); - expect(selfhostSmoke).not.toContain("TOKEN_PLACEHOLDER"); - expect(selfhostSmoke).toContain("length == 1"); - expect(selfhostSmoke).toContain('.[0] == "no-new-privileges:true"'); - expect(selfhostSmoke).toContain("Self-host image proof failed"); - expect(selfhostDockerfile).toContain( - 'BUN_TARGET="$bun_target" bun run --cwd apps/desktop build:sidecar', - ); - expect(selfhostDockerfile).toContain( - "/build/apps/desktop/resources/selftune/node_modules /app/node_modules", - ); - expect(selfhostDockerfile).toContain("SELFTUNE_DESKTOP_RESOURCE_DIR=/app"); - expect(selfhostCompose).toContain( - "SELFTUNE_AUTH_TOKEN: ${SELFTUNE_AUTH_TOKEN:?Generate a token with openssl rand -hex 32 and set SELFTUNE_AUTH_TOKEN in .env}", - ); - expect(selfhostCompose).not.toContain("SELFTUNE_AUTH_TOKEN_PLACEHOLDER"); }); test("binds signed macOS handoff metadata to repository-owned release identity pins", () => { @@ -455,33 +397,6 @@ describe("parsed release workflow graph", () => { expect(smoke).toContain('ConnectAsync("127.0.0.1", $port)'); }); - test("cross-builds and reuses the complete Windows runtime bundle", () => { - const desktop = workflowText("desktop.yml"); - const sidecarBuild = readFileSync( - resolve(repositoryRoot, "apps/desktop/scripts/build-sidecar.ts"), - "utf8", - ); - - expect(desktop).toContain("selftune-report-worker.exe"); - expect(desktop).toContain( - "node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node", - ); - expect(desktop).toContain("path: apps/desktop/resources/selftune"); - expect(desktop).toContain("bun install --frozen-lockfile --os='*' --cpu=x64"); - expect(desktop.match(/SELFTUNE_PREBUILT_RUNTIME_DIR/gu)).toHaveLength(3); - expect(sidecarBuild).toContain("SELFTUNE_PREBUILT_RUNTIME_DIR"); - expect(sidecarBuild).toContain( - 'Bun.resolveSync("@duckdb/node-bindings/package.json", apiRoot)', - ); - expect(sidecarBuild).toContain( - "Bun.resolveSync(`@duckdb/${bindingPackage}/package.json`, bindingsRoot)", - ); - expect(sidecarBuild).toContain('"selftune-report-worker.exe"'); - expect(sidecarBuild).toContain( - '"node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node"', - ); - }); - test("bounds macOS release packaging retries and clears transient DMG state", () => { const desktop = workflowText("desktop.yml"); @@ -507,22 +422,6 @@ describe("parsed release workflow graph", () => { ).not.toContain("--no-sandbox"); }); - test("bounds the slower Windows wrong-origin preload readiness window", () => { - const packagedSmoke = readFileSync( - resolve(repositoryRoot, "apps/desktop/scripts/smoke-packaged.ts"), - "utf8", - ); - const desktopWindow = readFileSync( - resolve(repositoryRoot, "apps/desktop/src/main/desktop-window.ts"), - "utf8", - ); - - expect(packagedSmoke).toContain('process.platform === "win32" ? 120_000 : 60_000'); - expect(packagedSmoke).toContain('"open wrong-origin probe window"'); - expect(desktopWindow).toContain('process.platform === "win32" ? 60_000 : 10_000'); - expect(desktopWindow).toContain("pendingIpcProbeTimeoutMs / 1_000"); - }); - test("gives stable-channel promotion to the parent workflow only", () => { const publish = workflowText("publish.yml"); const desktop = workflowText("desktop.yml"); diff --git a/tests/settings/onboarding.test.ts b/tests/settings/onboarding.test.ts index 5f14fba0..e6ca226b 100644 --- a/tests/settings/onboarding.test.ts +++ b/tests/settings/onboarding.test.ts @@ -156,11 +156,11 @@ describe("desktop onboarding", () => { which: (command) => (command === "claude" || command === "codex" ? command : null), }), run: () => 0, + sourceSync: async () => syncResult(0), installHarness: (harnessId) => { installed.push(harnessId); return { ok: true, changed: true, message: `${harnessId} installed` }; }, - sourceSync: async () => syncResult(0), }, ); @@ -289,13 +289,13 @@ describe("desktop onboarding", () => { binPath: "/tmp/selftune", loadHarnessConnections: connections, run: () => 0, + sourceSync: async () => syncResult(0), installHarness: (harnessId) => harnessId === "claude_code" ? { ok: true, changed: true, message: "installed" } : harnessId === "codex" ? { ok: true, changed: false, message: "already current" } : { ok: false, changed: false, message: "installer failed" }, - sourceSync: async () => syncResult(0), }, ); @@ -352,12 +352,12 @@ describe("desktop onboarding", () => { which: (command) => (command === "claude" ? command : null), }), run: () => 0, + sourceSync: async () => syncResult(0), installHarness: (_harnessId, binPath) => { const installer = desktopHookCapabilities.hooks.claude_code; if (!installer) throw new Error("Claude Code installer is unavailable."); return installer({ homeDir: desktopHome, cliPath: binPath }); }, - sourceSync: async () => syncResult(0), }, ); @@ -562,11 +562,11 @@ describe("desktop onboarding", () => { loadHarnessConnections: () => detectLocalHarnessConnections({ homeDir: home, which: () => "claude" }), run: () => 0, + sourceSync: async () => syncResult(0), installHarness: () => { installs += 1; return { ok: true, changed: false, message: "reconciled" }; }, - sourceSync: async () => syncResult(0), }, ); diff --git a/tests/skill-sets/blind-benchmark.test.ts b/tests/skill-sets/blind-benchmark.test.ts index 50ddc5f3..1fa8c52d 100644 --- a/tests/skill-sets/blind-benchmark.test.ts +++ b/tests/skill-sets/blind-benchmark.test.ts @@ -94,6 +94,17 @@ const executor: BlindBenchmarkExecutor = { kind: "scored", passed, executed_revision: input.revision, + process: { + turns: input.arm === "candidate_skill" ? 2 : 1, + input_tokens: 20, + output_tokens: 10, + tool_calls: input.arm === "candidate_skill" ? 1 : 0, + failed_tool_calls: 0, + repeated_actions: 0, + user_corrections: input.arm === "candidate_skill" ? 0 : 1, + progress_events: input.arm === "candidate_skill" ? 2 : 0, + wall_time_ms: input.arm === "candidate_skill" ? 12 : 4, + }, }); }, }; @@ -108,7 +119,13 @@ describe("immutable blind benchmark", () => { applies_change: false, }); expect(result.selection_scores).toEqual({ no_skill: 0, current_skill: 0, candidate_skill: 1 }); + expect(result.trials.filter((trial) => trial.case_id === "cal")).toHaveLength(0); expect(result.trials.filter((trial) => trial.partition === "audit_holdout")).toHaveLength(3); + expect( + result.trials.find((trial) => trial.case_id === "select" && trial.arm === "candidate_skill"), + ).toMatchObject({ + process: { turns: 6, tool_calls: 3, user_corrections: 0, progress_events: 6 }, + }); }); test("rejects selection or audit case leakage into candidate generation before execution", async () => { @@ -240,7 +257,7 @@ describe("immutable blind benchmark", () => { const retried = await Effect.runPromise( runBlindBenchmark(protocol, { execute(input) { - if (input.case.case_id === "cal" && input.arm === "no_skill" && input.attempt === 1) + if (input.case.case_id === "select" && input.arm === "no_skill" && input.attempt === 1) return Effect.succeed({ kind: "infrastructure", retryable: true }); return executor.execute(input); }, diff --git a/tests/skill-sets/paired-replay.test.ts b/tests/skill-sets/paired-replay.test.ts index 01d60395..0b43b27f 100644 --- a/tests/skill-sets/paired-replay.test.ts +++ b/tests/skill-sets/paired-replay.test.ts @@ -112,6 +112,17 @@ function executorFor(input: { ? (input.pre ?? "fail") === "pass" : (input.post ?? "pass") === "pass", executed_revision: input.wrongRevision ? sha("f") : request.revision, + process: { + turns: request.arm === "pre_edit" ? 3 : 2, + input_tokens: 100, + output_tokens: 40, + tool_calls: request.arm === "pre_edit" ? 2 : 1, + failed_tool_calls: request.arm === "pre_edit" ? 1 : 0, + repeated_actions: request.arm === "pre_edit" ? 1 : 0, + user_corrections: 0, + progress_events: 1, + wall_time_ms: request.arm === "pre_edit" ? 30 : 20, + }, }); }, }; @@ -132,6 +143,10 @@ describe("managed paired replay", () => { ["fail", "pass"], ["fail", "pass"], ]); + expect(result.trials[0]).toMatchObject({ + pre_edit_process: { turns: 3, failed_tool_calls: 1 }, + post_edit_process: { turns: 2, failed_tool_calls: 0 }, + }); }); test("retries and censors explicit infrastructure without treating it as a quality loss", async () => { diff --git a/tests/skill-sets/skill-intelligence-catalog-duckdb-failsoft.test.ts b/tests/skill-sets/skill-intelligence-catalog-duckdb-failsoft.test.ts index 875d2518..7c1baa67 100644 --- a/tests/skill-sets/skill-intelligence-catalog-duckdb-failsoft.test.ts +++ b/tests/skill-sets/skill-intelligence-catalog-duckdb-failsoft.test.ts @@ -76,6 +76,13 @@ function storeWithQuery(querySkillSignals: DuckDbAnalyticalStoreService["querySk message: "unused", }), ), + queryHistoricalSkillTaskReferences: () => + Effect.fail( + DuckDbAnalyticalStoreFailure.make({ + operation: "test query historical skill tasks", + message: "unused", + }), + ), queryHistoricalMetricRollups: () => Effect.fail( DuckDbAnalyticalStoreFailure.make({ diff --git a/tests/skill-sets/skill-set-packs.test.ts b/tests/skill-sets/skill-set-packs.test.ts new file mode 100644 index 00000000..7fe5f2e1 --- /dev/null +++ b/tests/skill-sets/skill-set-packs.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createSkillSet, + exportPortableSkillSetPackBytes, + importPortableSkillSetPack, +} from "@selftune/library"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("portable Skill Set Packs", () => { + test("round-trips sealed skill contents into an independent local library", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-pack-roundtrip-")); + temporaryDirectories.push(root); + const sourceRoot = join(root, "source"); + const targetRoot = join(root, "target"); + const skillRoot = join(root, "review"); + mkdirSync(skillRoot, { recursive: true }); + writeFileSync( + join(skillRoot, "SKILL.md"), + "---\nname: review\ndescription: Review code\nlicense: MIT\n---\n# Review\n", + ); + writeFileSync(join(skillRoot, "reference.md"), "Pinned reference\n"); + const source = createSkillSet( + { + name: "Engineering", + description: "Pinned engineering workflow", + harnesses: ["codex"], + skills: [{ name: "review", package_path: skillRoot }], + }, + { configRoot: sourceRoot }, + ); + + const bytes = exportPortableSkillSetPackBytes(source.set_id, { configRoot: sourceRoot }); + const imported = importPortableSkillSetPack(bytes, { configRoot: targetRoot }); + + expect(imported.manifest.name).toBe("Engineering"); + expect(imported.manifest.skills).toHaveLength(1); + expect( + readFileSync(join(imported.manifest.skills[0]!.library_package_path, "reference.md"), "utf8"), + ).toBe("Pinned reference\n"); + expect(imported.sourceRevisionSha256).toMatch(/^[a-f0-9]{64}$/); + expect(imported.objectSha256).toMatch(/^[a-f0-9]{64}$/); + }); + + test("blocks Pack export when a component has no distributable license evidence", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-pack-unlicensed-")); + temporaryDirectories.push(root); + const skillRoot = join(root, "private-skill"); + mkdirSync(skillRoot, { recursive: true }); + writeFileSync( + join(skillRoot, "SKILL.md"), + "---\nname: private-skill\ndescription: Internal\n---\n# Private\n", + ); + const set = createSkillSet( + { + name: "Private", + harnesses: ["codex"], + skills: [{ name: "private-skill", package_path: skillRoot }], + }, + { configRoot: join(root, "config") }, + ); + expect(() => + exportPortableSkillSetPackBytes(set.set_id, { configRoot: join(root, "config") }), + ).toThrow("requires a license"); + }); +}); diff --git a/tests/skill-sets/skill-sets.test.ts b/tests/skill-sets/skill-sets.test.ts index 5c43d38d..2cb8f75a 100644 --- a/tests/skill-sets/skill-sets.test.ts +++ b/tests/skill-sets/skill-sets.test.ts @@ -18,9 +18,12 @@ import { applySkillSet, captureSkillSetFromProject, createSkillSet, + deleteSkillSet, deriveSkillSetFromProject, exportPortableSkillSet, + exportSkillSetPluginArchive, importPortableSkillSet, + isSkillSetDeleted, listSkillSetRevisions, planSkillSet, rollbackSkillSet, @@ -39,6 +42,41 @@ function createSkill(root: string, name: string): string { } describe("project Skill Sets", () => { + test("deletes the active manifest while preserving immutable revisions and cached skills", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-skill-set-delete-")); + try { + const configRoot = join(root, "config"); + const packagePath = createSkill(join(root, "installed"), "research"); + const manifest = createSkillSet( + { + name: "Research project", + harnesses: ["codex"], + skills: [{ name: "research", package_path: packagePath }], + }, + { configRoot }, + ); + + expect(deleteSkillSet(manifest.set_id, { configRoot })).toEqual({ deleted: true }); + expect(isSkillSetDeleted(manifest.set_id, { configRoot })).toBe(true); + expect(listSkillSetRevisions(manifest.set_id, { configRoot })).toHaveLength(1); + expect(existsSync(manifest.skills[0]!.library_package_path)).toBe(true); + expect(() => deleteSkillSet(manifest.set_id, { configRoot })).toThrow(/was not found/); + + const recreated = createSkillSet( + { + name: "Research project", + harnesses: ["codex"], + skills: [{ name: "research", package_path: packagePath }], + }, + { configRoot }, + ); + expect(recreated.set_id).toBe(manifest.set_id); + expect(isSkillSetDeleted(manifest.set_id, { configRoot })).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("records immutable revisions and round-trips a portable repository manifest", () => { const root = mkdtempSync(join(tmpdir(), "selftune-skill-set-revisions-")); try { @@ -100,6 +138,36 @@ describe("project Skill Sets", () => { } }); + test("exports a deterministic plugin archive bound to the pinned Skill Set revision", () => { + const root = mkdtempSync(join(tmpdir(), "selftune-plugin-export-")); + try { + const configRoot = join(root, "config"); + const packagePath = createSkill(join(root, "installed"), "research"); + const manifest = createSkillSet( + { + name: "Research project", + description: "Pinned research tools", + harnesses: ["codex"], + skills: [{ name: "research", package_path: packagePath }], + }, + { configRoot }, + ); + const first = exportSkillSetPluginArchive(manifest.set_id, "all", { configRoot }); + const second = exportSkillSetPluginArchive(manifest.set_id, "all", { configRoot }); + const archive = Buffer.from(first.content_base64, "base64"); + const archiveText = archive.toString("utf8"); + expect(first).toEqual(second); + expect(archive.readUInt32LE(0)).toBe(0x04034b50); + expect(archiveText).toContain(".claude-plugin/plugin.json"); + expect(archiveText).toContain(".codex-plugin/plugin.json"); + expect(archiveText).toContain("skills/research/SKILL.md"); + expect(archiveText).toContain("plugin.json"); + expect(archiveText).toContain(manifest.revision_hash); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("derives a deduplicated Skill Set from active project harness registries", () => { const root = mkdtempSync(join(tmpdir(), "selftune-skill-set-derive-")); try { diff --git a/tests/skill-sets/study-drafts.test.ts b/tests/skill-sets/study-drafts.test.ts index c1dd804b..65689607 100644 --- a/tests/skill-sets/study-drafts.test.ts +++ b/tests/skill-sets/study-drafts.test.ts @@ -70,31 +70,6 @@ describe("study draft builder", () => { expect(JSON.stringify(draft.hidden_references)).not.toContain("Selected file has no upload"); }); - test("redacts every typed private-key block and handles a long near miss in one pass", () => { - const firstKey = - "-----begin rsa private key-----\nfirst private material\n-----end rsa private key-----"; - const secondKey = - "-----BEGIN PRIVATE KEY-----\nsecond private material\n-----END PRIVATE KEY-----"; - const redacted = buildStudyDraft({ - ...explicitInput, - hypothesis: { - ...explicitInput.hypothesis, - task: `before ${firstKey} between ${secondKey} after`, - }, - }); - - expect(redacted.task_capsule.task).toBe( - "before [redacted-private-key] between [redacted-private-key] after", - ); - - const nearMiss = `-----BEGIN ${"PRIVATE KEY ".repeat(500)}!`; - const preserved = buildStudyDraft({ - ...explicitInput, - hypothesis: { ...explicitInput.hypothesis, task: nearMiss }, - }); - expect(preserved.task_capsule.task).toBe(nearMiss); - }); - test("supports a pinned missed-opportunity hypothesis without inventing a pre/post pair", () => { const draft = buildStudyDraft({ ...explicitInput, diff --git a/tests/testing-readiness.test.ts b/tests/testing-readiness.test.ts index 7fb7d15f..7b62dbc7 100644 --- a/tests/testing-readiness.test.ts +++ b/tests/testing-readiness.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -34,6 +34,55 @@ afterEach(() => { }); describe("listSkillTestingReadiness", () => { + it("stores portable eval contracts beside SKILL.md and prefers them over cached copies", async () => { + const mod = await loadTestingReadinessModule(); + const skillRoot = join(tempRoot, "skills"); + const skillDir = join(skillRoot, "Research"); + const skillPath = join(skillDir, "SKILL.md"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(skillPath, "# Research\n"); + + const routingPath = mod.writeCanonicalEvalSet( + "Research", + [ + { query: "Research this company", should_trigger: true }, + { query: "Tell me a joke", should_trigger: false }, + ], + skillPath, + ); + const behaviorPath = mod.writeCanonicalUnitTests( + "Research", + [ + { + id: "research-output", + skill_name: "Research", + query: "Research this company", + assertions: [{ type: "contains", value: "Sources" }], + }, + ], + undefined, + skillPath, + ); + + expect(routingPath).toBe(join(skillDir, "evals", "routing.json")); + expect(behaviorPath).toBe(join(skillDir, "evals", "evals.json")); + expect(JSON.parse(readFileSync(routingPath, "utf-8"))).toHaveLength(2); + const portableEvals = JSON.parse(readFileSync(behaviorPath, "utf-8")); + expect(portableEvals.skill_name).toBe("Research"); + expect(portableEvals.evals[0]).toMatchObject({ + prompt: "Research this company", + expected_output: "The result satisfies 1 objective behavior assertion.", + files: [], + assertions: ['The output passes the contains check for "Sources".'], + selftune_assertions: [{ type: "contains", value: "Sources" }], + }); + + const row = mod.getSkillTestingReadiness(db, "Research", [skillRoot]); + expect(row?.eval_set_entries).toBe(2); + expect(row?.unit_test_cases).toBe(1); + expect(row?.next_step).toBe("run_replay_dry_run"); + }); + it("advances a skill from evals to replay dry-run once canonical evals and unit tests exist", async () => { const mod = await loadTestingReadinessModule(); const skillRoot = join(tempRoot, "skills"); diff --git a/tests/trust-floor/health.test.ts b/tests/trust-floor/health.test.ts index 4b315039..40eb57c3 100644 --- a/tests/trust-floor/health.test.ts +++ b/tests/trust-floor/health.test.ts @@ -24,7 +24,7 @@ beforeAll(async () => { }); afterAll(async () => { - if (server) await server.close(); + if (server) await server.stop(); try { rmSync(testSpaDir, { recursive: true, force: true }); } catch { @@ -50,137 +50,131 @@ describe("/api/health runtime identity", () => { ).rejects.toThrow("Runtime identity requires authenticated dashboard health"); }); - it( - "returns all expected fields", - async () => { - server = await startDashboardServer({ - port: 0, - host: "127.0.0.1", - spaDir: testSpaDir, - skillSetConfigRoot: testSpaDir, - openBrowser: false, - manageProcessSignals: false, - authToken: healthAuthToken, - runtimeMode: "test", - runtimeIdentity: { - configDir: testSpaDir, - instanceId: "health-test-instance", - owner: "desktop", - serviceInstallationNonce: "abcdefghijklmnopqrstuvwxyz_ABCDE", - supervision: "os-service", - ownerExecutablePath: "/Applications/SelfTune.app/Contents/MacOS/SelfTune", + it("returns all expected fields", async () => { + server = await startDashboardServer({ + port: 0, + host: "127.0.0.1", + spaDir: testSpaDir, + openBrowser: false, + authToken: healthAuthToken, + runtimeMode: "test", + runtimeIdentity: { + configDir: testSpaDir, + instanceId: "health-test-instance", + owner: "desktop", + serviceInstallationNonce: "abcdefghijklmnopqrstuvwxyz_ABCDE", + supervision: "os-service", + ownerExecutablePath: "/Applications/SelfTune.app/Contents/MacOS/SelfTune", + }, + runtimeShutdown: () => { + shutdownRequests += 1; + }, + overviewLoader: () => ({ + 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: [], }, - runtimeShutdown: () => { - shutdownRequests += 1; + skills: [], + watched_skills: [], + autonomy_status: { + level: "healthy", + summary: "No issues", + last_run: null, + skills_observed: 0, + pending_reviews: 0, + attention_required: 0, }, - overviewLoader: () => ({ - 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: [], + attention_queue: [], + trust_watchlist: [], + recent_decisions: [], + }), + }); + + const origin = `http://127.0.0.1:${server.port}`; + expect((await fetch(`${origin}/api/health`)).status).toBe(401); + const res = await fetch(`${origin}/api/health`, { + headers: { Authorization: `Bearer ${healthAuthToken}` }, + }); + expect(res.status).toBe(200); + + const body: HealthResponse = await res.json(); + + // Original fields + expect(body.ok).toBe(true); + expect(body.service).toBe("selftune-dashboard"); + expect(typeof body.version).toBe("string"); + expect(body).toHaveProperty("latest_version"); + expect(typeof body.update_available).toBe("boolean"); + expect(typeof body.auto_update_supported).toBe("boolean"); + expect(body).toHaveProperty("update_hint"); + expect(typeof body.spa).toBe("boolean"); + expect(typeof body.v2_data_available).toBe("boolean"); + + // New runtime identity fields + expect(typeof body.workspace_root).toBe("string"); + expect(body.workspace_root).toBeTruthy(); + + expect(typeof body.git_sha).toBe("string"); + + expect(typeof body.db_path).toBe("string"); + + expect(typeof body.log_dir).toBe("string"); + expect(typeof body.config_dir).toBe("string"); + + expect(["wal", "jsonl", "none"]).toContain(body.watcher_mode); + expect(body.process_mode).toBe("test"); + expect(body.runtime_instance_id).toBe("health-test-instance"); + expect(body.runtime_owner).toBe("desktop"); + expect(body.runtime_supervision).toBe("os-service"); + expect(body.service_installation_nonce).toBe("abcdefghijklmnopqrstuvwxyz_ABCDE"); + expect(body.owner_executable_path).toBe("/Applications/SelfTune.app/Contents/MacOS/SelfTune"); + expect(body.config_dir).toBe(testSpaDir); + + expect(body.host).toBe("127.0.0.1"); + expect(typeof body.port).toBe("number"); + expect(body.port).toBeGreaterThan(0); + + expect( + ( + await fetch(`${origin}/api/runtime/shutdown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ runtime_instance_id: "health-test-instance" }), + }) + ).status, + ).toBe(401); + expect( + ( + await fetch(`${origin}/api/runtime/shutdown`, { + method: "POST", + headers: { + Authorization: `Bearer ${healthAuthToken}`, + "Content-Type": "application/json", }, - skills: [], - watched_skills: [], - autonomy_status: { - level: "healthy", - summary: "No issues", - last_run: null, - skills_observed: 0, - pending_reviews: 0, - attention_required: 0, + body: JSON.stringify({ runtime_instance_id: "replacement-instance" }), + }) + ).status, + ).toBe(409); + expect(shutdownRequests).toBe(0); + expect( + ( + await fetch(`${origin}/api/runtime/shutdown`, { + method: "POST", + headers: { + Authorization: `Bearer ${healthAuthToken}`, + "Content-Type": "application/json", }, - attention_queue: [], - trust_watchlist: [], - recent_decisions: [], - }), - }); - - const origin = `http://127.0.0.1:${server.port}`; - expect((await fetch(`${origin}/api/health`)).status).toBe(401); - const res = await fetch(`${origin}/api/health`, { - headers: { Authorization: `Bearer ${healthAuthToken}` }, - }); - expect(res.status).toBe(200); - - const body: HealthResponse = await res.json(); - - // Original fields - expect(body.ok).toBe(true); - expect(body.service).toBe("selftune-dashboard"); - expect(typeof body.version).toBe("string"); - expect(body).toHaveProperty("latest_version"); - expect(typeof body.update_available).toBe("boolean"); - expect(typeof body.auto_update_supported).toBe("boolean"); - expect(body).toHaveProperty("update_hint"); - expect(typeof body.spa).toBe("boolean"); - expect(typeof body.v2_data_available).toBe("boolean"); - - // New runtime identity fields - expect(typeof body.workspace_root).toBe("string"); - expect(body.workspace_root).toBeTruthy(); - - expect(typeof body.git_sha).toBe("string"); - - expect(typeof body.db_path).toBe("string"); - - expect(typeof body.log_dir).toBe("string"); - expect(typeof body.config_dir).toBe("string"); - - expect(["wal", "jsonl", "none"]).toContain(body.watcher_mode); - expect(body.process_mode).toBe("test"); - expect(body.runtime_instance_id).toBe("health-test-instance"); - expect(body.runtime_owner).toBe("desktop"); - expect(body.runtime_supervision).toBe("os-service"); - expect(body.service_installation_nonce).toBe("abcdefghijklmnopqrstuvwxyz_ABCDE"); - expect(body.owner_executable_path).toBe("/Applications/SelfTune.app/Contents/MacOS/SelfTune"); - expect(body.config_dir).toBe(testSpaDir); - - expect(body.host).toBe("127.0.0.1"); - expect(typeof body.port).toBe("number"); - expect(body.port).toBeGreaterThan(0); - - expect( - ( - await fetch(`${origin}/api/runtime/shutdown`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ runtime_instance_id: "health-test-instance" }), - }) - ).status, - ).toBe(401); - expect( - ( - await fetch(`${origin}/api/runtime/shutdown`, { - method: "POST", - headers: { - Authorization: `Bearer ${healthAuthToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ runtime_instance_id: "replacement-instance" }), - }) - ).status, - ).toBe(409); - expect(shutdownRequests).toBe(0); - expect( - ( - await fetch(`${origin}/api/runtime/shutdown`, { - method: "POST", - headers: { - Authorization: `Bearer ${healthAuthToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ runtime_instance_id: "health-test-instance" }), - }) - ).status, - ).toBe(202); - await Bun.sleep(10); - expect(shutdownRequests).toBe(1); - }, - { timeout: 15_000 }, - ); + body: JSON.stringify({ runtime_instance_id: "health-test-instance" }), + }) + ).status, + ).toBe(202); + await Bun.sleep(10); + expect(shutdownRequests).toBe(1); + }); }); diff --git a/tests/trust-floor/publish-deps.test.ts b/tests/trust-floor/publish-deps.test.ts index 47f82947..41ea749a 100644 --- a/tests/trust-floor/publish-deps.test.ts +++ b/tests/trust-floor/publish-deps.test.ts @@ -27,58 +27,6 @@ const developmentOnlyPackageFiles = [ "!**/__tests__/**", ]; -function extractGenerateSbomStep(workflow: string): string { - const marker = " - name: Generate SBOM\n"; - const start = workflow.indexOf(marker); - if (start === -1) { - throw new Error("Publish workflow is missing the Generate SBOM step."); - } - const end = workflow.indexOf("\n - name:", start + marker.length); - return workflow.slice(start, end === -1 ? undefined : end); -} - -function extractCycloneDxInvocation(workflow: string): string { - const step = extractGenerateSbomStep(workflow); - const commandMarker = "cyclonedx-npm/bin/cyclonedx-npm-cli.js"; - const commandLine = step.split("\n").findIndex((line) => line.includes(commandMarker)); - if (commandLine === -1) { - throw new Error( - "Publish workflow should invoke the locked CycloneDX npm generator. Next: restore the locked binary invocation in the Generate SBOM step.", - ); - } - - const lines = step.split("\n").slice(commandLine); - const command: string[] = []; - for (const line of lines) { - command.push(line); - if (!line.trimEnd().endsWith("\\")) break; - } - return command.join("\n"); -} - -function assertCycloneDxInvocation(workflow: string): void { - const invocation = extractCycloneDxInvocation(workflow); - for (const requiredArgument of [ - "--package-lock-only", - "--omit dev", - "--no-workspaces", - "--validate", - "-v -v", - '--output-file "$GITHUB_WORKSPACE/sbom.cdx.json"', - ]) { - if (!invocation.includes(requiredArgument)) { - throw new Error( - `Publish workflow's CycloneDX invocation is missing ${requiredArgument}. Next: restore the complete validated packed-tarball SBOM command.`, - ); - } - } - if (invocation.includes("--ignore-npm-errors")) { - throw new Error( - "Publish workflow must not ignore npm resolution errors during SBOM generation. Next: normalize the bounded packed metadata mismatch and let CycloneDX fail closed.", - ); - } -} - describe("publish dependency protocol", () => { test("the root and bundled workspaces agree on external runtimes", () => { const expectedRuntimes = new Map([ @@ -302,93 +250,29 @@ describe("publish dependency protocol", () => { test("publish workflow generates SBOM from the packed tarball in an isolated npm tree", () => { const workflow = readFileSync(join(ROOT, ".github/workflows/publish.yml"), "utf-8"); - const sbomStep = extractGenerateSbomStep(workflow); - const sbomRun = sbomStep.slice(sbomStep.indexOf(" run: |")); - if ( - !sbomStep.includes("TARBALL_PATH: ${{ steps.pack.outputs.tarball }}") || - !sbomRun.includes('tar -xzf "$TARBALL_PATH" -C "$TMPDIR"') || - sbomRun.includes("${{ steps.pack.outputs.tarball }}") - ) { + if (!workflow.includes('tar -xzf "${{ steps.pack.outputs.tarball }}" -C "$TMPDIR"')) { throw new Error( - "Publish workflow should pass the packed tarball path through the step environment and unpack it into a temp dir before generating the SBOM. Next: bind steps.pack.outputs.tarball to TARBALL_PATH and extract $TARBALL_PATH instead of interpolating the expression directly into shell code.", + "Publish workflow should unpack the packed tarball into a temp dir before generating the SBOM. Next: update .github/workflows/publish.yml to generate SBOMs from the packaged artifact instead of the Bun workspace tree.", ); } if ( - !workflow.includes( - "npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null", - ) + !workflow.includes("npm install --package-lock-only --ignore-scripts --omit=dev >/dev/null") ) { throw new Error( - "Publish workflow should create an isolated production package-lock while resolving bundled peer-only package metadata. Next: run npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund inside the unpacked tarball directory.", + "Publish workflow should create an isolated npm package-lock before generating the SBOM. Next: run npm install --package-lock-only inside the unpacked tarball directory.", ); } if ( - !workflow.includes("sparse-checkout: .github/sbom-toolchain") || !workflow.includes( - 'npm ci\n --prefix "$GITHUB_WORKSPACE/.release-workflow/.github/sbom-toolchain"', + 'npm sbom --sbom-format cyclonedx --package-lock-only --workspaces=false > "$GITHUB_WORKSPACE/sbom.cdx.json"', ) ) { throw new Error( - "Publish workflow should install the committed SBOM toolchain lock from the workflow source. Next: restore the sparse toolchain checkout and locked npm ci step.", - ); - } - - if (sbomStep.includes("npx ")) { - throw new Error( - "Publish workflow must not download and execute an unlocked SBOM tool through npx. Next: invoke the generator installed from .github/sbom-toolchain/package-lock.json.", + "Publish workflow should generate the SBOM with `npm sbom --sbom-format cyclonedx --package-lock-only --workspaces=false` from the unpacked tarball. Next: update .github/workflows/publish.yml to use npm sbom in the isolated temp dir.", ); } - for (const validationGate of [ - 'grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"', - 'grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"', - "SBOM root dependency coverage:", - ]) { - if (!sbomStep.includes(validationGate)) { - throw new Error( - `Publish workflow is missing the fail-closed SBOM gate ${validationGate}. Next: require explicit schema-validation success and complete root dependency coverage.`, - ); - } - } - - assertCycloneDxInvocation(workflow); - }); - - test("SBOM invocation checks cannot borrow flags from another command", () => { - const workflow = readFileSync(join(ROOT, ".github/workflows/publish.yml"), "utf-8"); - const mutatedWorkflow = workflow.replace( - " --package-lock-only \\\n --omit dev", - " --omit dev", - ); - - expect(mutatedWorkflow).not.toBe(workflow); - expect(() => assertCycloneDxInvocation(mutatedWorkflow)).toThrow( - "Publish workflow's CycloneDX invocation is missing --package-lock-only. Next: restore the complete validated packed-tarball SBOM command.", - ); - }); - - test("SBOM generator dependencies are exactly locked with registry integrity", () => { - const toolchain = JSON.parse( - readFileSync(join(ROOT, ".github/sbom-toolchain/package.json"), "utf-8"), - ); - const lock = JSON.parse( - readFileSync(join(ROOT, ".github/sbom-toolchain/package-lock.json"), "utf-8"), - ); - - expect(toolchain.dependencies?.["@cyclonedx/cyclonedx-npm"]).toBe("6.0.0"); - expect(toolchain.dependencies?.ajv).toBe("8.20.0"); - expect(lock.lockfileVersion).toBe(3); - expect(lock.packages?.["node_modules/@cyclonedx/cyclonedx-npm"]?.version).toBe("6.0.0"); - - for (const [path, dependency] of Object.entries>(lock.packages ?? {})) { - if (!path || dependency.link || !dependency.resolved?.startsWith("https://")) continue; - if (!dependency.integrity?.startsWith("sha512-")) { - throw new Error( - `SBOM toolchain dependency ${path} is missing a locked sha512 registry integrity. Next: regenerate and review .github/sbom-toolchain/package-lock.json.`, - ); - } - } }); }); diff --git a/tests/utils/llm-call.test.ts b/tests/utils/llm-call.test.ts index eeb82282..6664fd6d 100644 --- a/tests/utils/llm-call.test.ts +++ b/tests/utils/llm-call.test.ts @@ -510,6 +510,41 @@ describe("callViaSubagent", () => { Bun.spawn = originalSpawn; }); + it("honors an explicit codex agent even when claude is also installed", async () => { + let capturedCmd: string[] | undefined; + + Bun.which = ((name: string) => + name === "claude" || name === "codex" ? `/usr/bin/${name}` : null) as typeof Bun.which; + Bun.spawn = ((cmd: string[], _opts: unknown) => { + capturedCmd = cmd; + return { + stdout: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("ok")); + controller.close(); + }, + }), + stderr: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + exited: Promise.resolve(0), + kill: () => {}, + }; + }) as typeof Bun.spawn; + + const result = await callViaSubagent({ + agent: "codex", + agentName: "evidence-cohort-teacher", + prompt: "test", + maxTurns: 1, + }); + + expect(result).toBe("ok"); + expect(capturedCmd?.slice(0, 3)).toEqual(["codex", "exec", "--skip-git-repo-check"]); + }); + it("constructs a pi subagent call when only pi is available", async () => { let capturedCmd: string[] | undefined;

    Sh&X*gNiNo4EeyU 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�%I^o^~ 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 { return nested.flat(); } -async function writeRuntimeManifest(): Promise { +async function writeRuntimeManifest(executable: string): Promise { const files = await Promise.all( (await listFiles(resourceRoot)).map(async (path) => { const contents = await readFile(path); const info = await stat(path); - const relativePath = relative(resourceRoot, path).split(sep).join("/"); return { - path: relativePath, - signing_mutable: isSigningMutableRuntimePath(relativePath), + path: relative(resourceRoot, path).split(sep).join("/"), + signing_mutable: relative(resourceRoot, path).split(sep).join("/") === executable, sha256: createHash("sha256").update(contents).digest("hex"), size: info.size, }; @@ -98,10 +95,8 @@ async function stageDuckDbNativeBindings( ); } const apiRoot = await realpath(duckDbApiPackageRoot); - const bindingsRoot = dirname(Bun.resolveSync("@duckdb/node-bindings/package.json", apiRoot)); - const nativeRoot = dirname( - Bun.resolveSync(`@duckdb/${bindingPackage}/package.json`, bindingsRoot), - ); + const bindingsRoot = await realpath(join(dirname(apiRoot), "node-bindings")); + const nativeRoot = await realpath(join(dirname(bindingsRoot), bindingPackage)); const destination = join(resourceRoot, "node_modules/@duckdb"); const apiDestination = join(destination, "node-api"); const bindingShimRoot = join(apiDestination, "node_modules/@duckdb/node-bindings"); @@ -145,64 +140,36 @@ async function stageDuckDbNativeBindings( ); } -async function requireNonEmptyFile(root: string, relativePath: string): Promise { - const path = join(root, relativePath); - const info = await stat(path); - if (!info.isFile() || info.size === 0) { - throw new Error(`Prebuilt runtime file is missing or empty: ${path}`); - } -} - -async function stagePrebuiltWindowsRuntime(source: string, windowsTarget: boolean): Promise { - if (!windowsTarget) { - throw new Error("A prebuilt runtime may only be staged for a Windows target."); - } - const sourceRoot = resolve(source); - const sourceInfo = await stat(sourceRoot); - if (!sourceInfo.isDirectory()) { - throw new Error(`Prebuilt runtime is not a directory: ${sourceRoot}`); - } - - await Promise.all( - [ - "selftune.exe", - "selftune-report-worker.exe", - "runtime-manifest.json", - "dashboard/index.html", - "settings_snippet.json", - "node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node", - ].map((path) => requireNonEmptyFile(sourceRoot, path)), - ); - - for (const entry of await readdir(sourceRoot)) { - await cp(join(sourceRoot, entry), join(resourceRoot, entry), { - recursive: true, - dereference: true, - }); - } -} - await rm(resourceRoot, { recursive: true, force: true }); await mkdir(resourceRoot, { recursive: true }); +await writeFile(join(resourceRoot, "package.json"), '{ "private": true }\n'); const target = compileTarget(process.env.BUN_TARGET); const windowsTarget = target?.startsWith("bun-windows-") ?? process.platform === "win32"; const executable = windowsTarget ? "selftune.exe" : "selftune"; +const executablePath = join(resourceRoot, executable); const reportWorkerExecutable = windowsTarget ? "selftune-report-worker.exe" : "selftune-report-worker"; -const prebuiltRuntime = process.env.SELFTUNE_PREBUILT_RUNTIME_DIR?.trim(); +const prebuiltExecutable = process.env.SELFTUNE_PREBUILT_SIDECAR?.trim(); -if (prebuiltRuntime) { - await stagePrebuiltWindowsRuntime(prebuiltRuntime, windowsTarget); +if (prebuiltExecutable) { + if (!windowsTarget) { + throw new Error("A prebuilt sidecar may only be staged for a Windows target."); + } + const prebuiltPath = resolve(prebuiltExecutable); + const prebuiltInfo = await stat(prebuiltPath); + if (!prebuiltInfo.isFile() || prebuiltInfo.size === 0) { + throw new Error(`Prebuilt sidecar is not a non-empty file: ${prebuiltPath}`); + } + await cp(prebuiltPath, executablePath); } else { - await writeFile(join(resourceRoot, "package.json"), '{ "private": true }\n'); const result = await Bun.build({ entrypoints: [join(selfTuneRoot, "apps/cli/src/main.ts")], define: { SELFTUNE_DESKTOP_SIDECAR_BUILD: "true" }, minify: true, compile: { - outfile: join(resourceRoot, executable), + outfile: executablePath, ...(target ? { target } : {}), }, }); @@ -210,33 +177,33 @@ if (prebuiltRuntime) { if (!result.success) { throw new Error(result.logs.map((entry) => entry.message).join("\n")); } +} - const reportWorkerResult = await Bun.build({ - entrypoints: [join(selfTuneRoot, "apps/local/src/report-worker.ts")], - minify: true, - compile: { - outfile: join(resourceRoot, reportWorkerExecutable), - ...(target ? { target } : {}), - }, - }); - - if (!reportWorkerResult.success) { - throw new Error(reportWorkerResult.logs.map((entry) => entry.message).join("\n")); - } +const reportWorkerResult = await Bun.build({ + entrypoints: [join(selfTuneRoot, "apps/local/src/report-worker.ts")], + minify: true, + compile: { + outfile: join(resourceRoot, reportWorkerExecutable), + ...(target ? { target } : {}), + }, +}); + +if (!reportWorkerResult.success) { + throw new Error(reportWorkerResult.logs.map((entry) => entry.message).join("\n")); +} - if (!windowsTarget) { - await chmod(join(resourceRoot, executable), 0o755); - await chmod(join(resourceRoot, reportWorkerExecutable), 0o755); - } - await stageDuckDbNativeBindings(target); - await cp(join(selfTuneRoot, "apps/local-dashboard/dist"), join(resourceRoot, "dashboard"), { - recursive: true, - }); - await cp( - join(selfTuneRoot, "skill/settings_snippet.json"), - join(resourceRoot, "settings_snippet.json"), - ); - await writeRuntimeManifest(); +if (!windowsTarget) { + await chmod(executablePath, 0o755); + await chmod(join(resourceRoot, reportWorkerExecutable), 0o755); } +await stageDuckDbNativeBindings(target); +await cp(join(selfTuneRoot, "apps/local-dashboard/dist"), join(resourceRoot, "dashboard"), { + recursive: true, +}); +await cp( + join(selfTuneRoot, "skill/settings_snippet.json"), + join(resourceRoot, "settings_snippet.json"), +); +await writeRuntimeManifest(executable); process.stdout.write(`Staged the SelfTune runtime and dashboard at ${resourceRoot}\n`); diff --git a/apps/desktop/scripts/duckdb-sidecar-probe.ts b/apps/desktop/scripts/duckdb-sidecar-probe.ts index 207e7ba9..470490bf 100644 --- a/apps/desktop/scripts/duckdb-sidecar-probe.ts +++ b/apps/desktop/scripts/duckdb-sidecar-probe.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { join, toNamespacedPath } from "node:path"; import { pathToFileURL } from "node:url"; const databasePath = process.argv[2]; @@ -6,7 +6,8 @@ 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."); const { DuckDBInstance } = (await import( - pathToFileURL(join(resourceRoot, "node_modules/@duckdb/node-api/lib/index.js")).href + pathToFileURL(toNamespacedPath(join(resourceRoot, "node_modules/@duckdb/node-api/lib/index.js"))) + .href )) as typeof import("@duckdb/node-api"); const instance = await DuckDBInstance.create(databasePath); diff --git a/apps/desktop/scripts/smoke-packaged.ts b/apps/desktop/scripts/smoke-packaged.ts index ac3007c1..d20ebc90 100644 --- a/apps/desktop/scripts/smoke-packaged.ts +++ b/apps/desktop/scripts/smoke-packaged.ts @@ -402,39 +402,20 @@ const assert = Effect.fn("SelfTuneDesktop.smoke.assert")(function* ( if (!condition) return yield* Effect.fail(failure(operation, message)); }); -const openPreloadWindow = Effect.fn("SelfTuneDesktop.smoke.preloadWindow")(function* ( +const openApplicationWindow = Effect.fn("SelfTuneDesktop.smoke.applicationWindow")(function* ( application: ElectronApplication, operation: string, - timeoutMs = 60_000, + matches: (page: Page) => boolean, ) { return yield* Effect.tryPromise({ try: async () => { - const deadline = Date.now() + timeoutMs; - const poll = async (): Promise => { - const candidates = await Promise.all( - application.windows().map(async (page) => ({ - page, - hasProbe: await page - .evaluate(() => { - const bridge = Reflect.get(window, "selftuneDesktopTest"); - return ( - typeof bridge === "object" && - bridge !== null && - typeof Reflect.get(bridge, "pendingWindowIpc") === "function" - ); - }) - .catch(() => false), - })), - ); - const ready = candidates.find(({ hasProbe }) => hasProbe); - if (ready) return ready.page; - if (Date.now() >= deadline) { - throw new Error("No packaged SelfTune preload window became ready."); - } + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const page = application.windows().find(matches); + if (page) return page; await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); - return poll(); - }; - return poll(); + } + throw new Error("The expected packaged application window did not open within 60 seconds."); }, catch: (cause) => failure(operation, cause), }); @@ -490,10 +471,10 @@ const proveWrongOriginPendingWindowRejected = Effect.fn( initialPath: PENDING_WINDOW_IPC_TEST_DOCUMENT, probePendingWindowIpc: true, }); - const page = yield* openPreloadWindow( + const page = yield* openApplicationWindow( application, "open wrong-origin probe window", - process.platform === "win32" ? 120_000 : 60_000, + (candidate) => candidate.url() === PENDING_WINDOW_IPC_TEST_DOCUMENT, ); return yield* readPendingWindowIpcProbe(page); }), @@ -554,7 +535,11 @@ const smoke = Effect.scoped( `Expected ${userDataDir}, received ${appInfo.userData}.`, ); - const page = yield* openPreloadWindow(application, "open packaged dashboard window"); + const page = yield* openApplicationWindow( + application, + "open packaged dashboard window", + (candidate) => candidate.url().startsWith("http://127.0.0.1:"), + ); yield* Effect.tryPromise({ try: () => page.waitForFunction( diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index dc1cf671..4a16d856 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -7,7 +7,6 @@ import { join, resolve } from "node:path"; import { promisify } from "node:util"; import * as Effect from "effect/Effect"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { localAuthPath, readServerManifest } from "@selftune/local/local-runtime"; @@ -72,11 +71,6 @@ const PackageBundleSmokeResponse = Schema.Struct({ encoded_bytes: Schema.Number, }); -const ServiceDoctorResponse = Schema.Struct({ - action: Schema.Literal("doctor"), - ok: Schema.Literal(true), -}); - const execFileAsync = promisify(execFile); interface RuntimePaths { @@ -112,19 +106,6 @@ function isolatedRuntimeEnvironment(paths: RuntimePaths): NodeJS.ProcessEnv { }; } -function compiledRuntimeEnvironment(paths: RuntimePaths): NodeJS.ProcessEnv { - return { - ...isolatedRuntimeEnvironment(paths), - NODE_PATH: join(paths.root, "node_modules"), - SELFTUNE_BIN_PATH: paths.binary, - SELFTUNE_DESKTOP: "1", - SELFTUNE_DESKTOP_RESOURCE_DIR: paths.root, - SELFTUNE_RUNTIME_OWNER: "desktop", - SELFTUNE_SUPERVISED: "0", - SELFTUNE_VERSION: process.env.npm_package_version ?? "0.0.0-smoke", - }; -} - function failure(operation: string, cause: unknown): SidecarSmokeFailure { return SidecarSmokeFailure.make({ operation, @@ -177,7 +158,10 @@ async function stopProcess(child: ChildProcess): Promise { async function requestRuntimeStop(paths: RuntimePaths): Promise { await execFileAsync(paths.binary, ["daemon", "stop", "--config-dir", paths.configDir], { cwd: paths.root, - env: compiledRuntimeEnvironment(paths), + env: { + ...isolatedRuntimeEnvironment(paths), + SELFTUNE_VERSION: process.env.npm_package_version ?? "0.0.0-smoke", + }, timeout: 15_000, }); } @@ -244,7 +228,15 @@ const startRuntime = Effect.fn("SelfTuneSidecar.smoke.start")(function* (paths: ], { cwd: paths.root, - env: compiledRuntimeEnvironment(paths), + env: { + ...isolatedRuntimeEnvironment(paths), + SELFTUNE_BIN_PATH: paths.binary, + SELFTUNE_DESKTOP: "1", + SELFTUNE_DESKTOP_RESOURCE_DIR: paths.root, + SELFTUNE_RUNTIME_OWNER: "desktop", + SELFTUNE_SUPERVISED: "0", + SELFTUNE_VERSION: process.env.npm_package_version ?? "0.0.0-smoke", + }, stdio: ["ignore", "pipe", "pipe"], }, ), @@ -252,17 +244,10 @@ const startRuntime = Effect.fn("SelfTuneSidecar.smoke.start")(function* (paths: }), (activeChild) => Effect.gen(function* () { - const gracefulStop = yield* Effect.result( - Effect.tryPromise({ - try: () => requestRuntimeStop(paths), - catch: (cause) => failure("request compiled runtime shutdown", cause), - }), - ); - if (Result.isFailure(gracefulStop)) { - yield* Effect.logWarning( - `Compiled runtime graceful shutdown failed before fallback cleanup: ${gracefulStop.failure.message}`, - ); - } + yield* Effect.tryPromise({ + try: () => requestRuntimeStop(paths), + catch: (cause) => failure("request compiled runtime shutdown", cause), + }).pipe(Effect.ignore); yield* Effect.tryPromise({ try: () => stopProcess(activeChild), catch: (cause) => failure("stop isolated compiled runtime", cause), @@ -453,36 +438,6 @@ const verifyCompiledPackageCollection = Effect.fn("SelfTuneSidecar.smoke.package }, ); -const verifySelfLocatingCompiledRuntime = Effect.fn("SelfTuneSidecar.smoke.selfLocating")( - function* (paths: RuntimePaths) { - const environment = isolatedRuntimeEnvironment(paths); - delete environment.NODE_PATH; - delete environment.SELFTUNE_DESKTOP_RESOURCE_DIR; - const output = yield* Effect.tryPromise({ - try: () => - execFileAsync(paths.binary, ["service", "doctor", "--json"], { - cwd: paths.root, - env: environment, - timeout: 15_000, - }), - catch: (cause) => failure("run self-locating compiled service doctor", cause), - }); - const parsed = yield* Effect.try({ - try: () => { - const line = output.stdout - .split(/\r?\n/) - .map((value) => value.trim()) - .findLast(Boolean); - if (!line) throw new Error("Compiled service doctor returned no JSON response."); - const value: unknown = JSON.parse(line); - return value; - }, - catch: (cause) => failure("parse self-locating compiled service doctor", cause), - }); - yield* decode("decode self-locating compiled service doctor", ServiceDoctorResponse, parsed); - }, -); - const smoke = Effect.scoped( Effect.gen(function* () { const temporaryRoot = yield* Effect.acquireRelease( @@ -498,7 +453,6 @@ const smoke = Effect.scoped( ); const paths = yield* prepareRuntime(temporaryRoot); const fixturePath = join(paths.homeDir, ".agents", "skills", "compiled-smoke"); - yield* verifySelfLocatingCompiledRuntime(paths); yield* verifyCompiledPackageCollection(paths, fixturePath); const setId = yield* Effect.scoped( diff --git a/apps/desktop/src/desktop-test-contract.ts b/apps/desktop/src/desktop-test-contract.ts index b5256f5f..3d445a93 100644 --- a/apps/desktop/src/desktop-test-contract.ts +++ b/apps/desktop/src/desktop-test-contract.ts @@ -1,3 +1,4 @@ export const PENDING_WINDOW_IPC_TEST_CHANNEL = "selftune:test-pending-window-ipc"; +export const PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT = "--selftune-test-pending-window-ipc"; export const PENDING_WINDOW_IPC_TEST_DOCUMENT = "data:text/html;charset=utf-8,%3Ctitle%3EUntrusted%20SelfTune%20probe%3C%2Ftitle%3E"; diff --git a/apps/desktop/src/main/desktop-activation.test.ts b/apps/desktop/src/main/desktop-activation.test.ts new file mode 100644 index 00000000..f6f384b7 --- /dev/null +++ b/apps/desktop/src/main/desktop-activation.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; + +import { createDesktopActivationController } from "./desktop-activation"; + +describe("Desktop activation", () => { + it("repairs an unavailable local runtime before revealing the existing app", async () => { + const events: string[] = []; + const activation = createDesktopActivationController({ + runtimeState: async () => "unavailable", + restartRuntime: async () => { + events.push("restart"); + }, + show: async () => { + events.push("show"); + }, + showCrash: async () => { + events.push("crash"); + }, + }); + + await activation.activate(); + + expect(events).toEqual(["restart", "show"]); + }); + + it("coalesces concurrent activation events into one runtime repair", async () => { + const restartStarted = Promise.withResolvers(); + const releaseRestart = Promise.withResolvers(); + let restarts = 0; + let shows = 0; + const activation = createDesktopActivationController({ + runtimeState: async () => "unavailable", + restartRuntime: async () => { + restarts += 1; + restartStarted.resolve(); + await releaseRestart.promise; + }, + show: async () => { + shows += 1; + }, + showCrash: async () => undefined, + }); + + const first = activation.activate(); + await restartStarted.promise; + const second = activation.activate(); + releaseRestart.resolve(); + await Promise.all([first, second]); + + expect({ restarts, shows }).toEqual({ restarts: 1, shows: 1 }); + }); +}); diff --git a/apps/desktop/src/main/desktop-activation.ts b/apps/desktop/src/main/desktop-activation.ts new file mode 100644 index 00000000..8a443f4d --- /dev/null +++ b/apps/desktop/src/main/desktop-activation.ts @@ -0,0 +1,38 @@ +export type DesktopActivationRuntimeState = "starting" | "ready" | "unavailable"; + +export interface DesktopActivationController { + readonly activate: () => Promise; +} + +interface DesktopActivationControllerOptions { + readonly restartRuntime: () => Promise; + readonly runtimeState: () => Promise; + readonly show: () => Promise; + readonly showCrash: (cause: unknown) => Promise; +} + +export function createDesktopActivationController( + options: DesktopActivationControllerOptions, +): DesktopActivationController { + let activationInFlight: Promise | null = null; + const activate = async (): Promise => { + try { + if ((await options.runtimeState()) === "unavailable") { + await options.restartRuntime(); + } + await options.show(); + } catch (cause) { + await options.showCrash(cause); + } + }; + + return { + activate() { + if (activationInFlight) return activationInFlight; + activationInFlight = activate().finally(() => { + activationInFlight = null; + }); + return activationInFlight; + }, + }; +} diff --git a/apps/desktop/src/main/desktop-agent-detection.test.ts b/apps/desktop/src/main/desktop-agent-detection.test.ts index 00c7c24b..e4f8e0b0 100644 --- a/apps/desktop/src/main/desktop-agent-detection.test.ts +++ b/apps/desktop/src/main/desktop-agent-detection.test.ts @@ -1,16 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { join } from "node:path"; import { detectDesktopInstallerAgents } from "./desktop-agent-detection"; describe("Desktop installer agent detection", () => { it("reports read-only evidence without selecting an agent", () => { - const homeDirectory = "/home/user"; - const existing = new Set([ - join(homeDirectory, ".codex"), - join(homeDirectory, ".config", "opencode"), - ]); - expect(detectDesktopInstallerAgents(homeDirectory, (path) => existing.has(path))).toEqual([ + const existing = new Set(["/home/user/.codex", "/home/user/.config/opencode"]); + expect(detectDesktopInstallerAgents("/home/user", (path) => existing.has(path))).toEqual([ { agent: "codex", evidence: ["Codex configuration detected"] }, { agent: "claude_code", evidence: [] }, { agent: "opencode", evidence: ["OpenCode configuration detected"] }, diff --git a/apps/desktop/src/main/desktop-builder-config.test.ts b/apps/desktop/src/main/desktop-builder-config.test.ts index 3d3d26d2..05427550 100644 --- a/apps/desktop/src/main/desktop-builder-config.test.ts +++ b/apps/desktop/src/main/desktop-builder-config.test.ts @@ -23,8 +23,10 @@ describe("Desktop builder protocol configuration", () => { it("loads protocol metadata only for a pinned signed target", () => { const signed = createDesktopBuilderConfig(MAC_RELEASE_ENVIRONMENT, "linux"); expect(signed.forceCodeSigning).toBeTrue(); - expect(signed.protocols).toEqual([{ name: "SelfTune install handoff", schemes: ["selftune"] }]); - expect(signed.mac?.identity).toBe("PragSys Collaborative LLC (ABC123XYZ9)"); + expect(signed.protocols).toEqual([ + { name: "SelfTune Pack and install handoff", schemes: ["selftune"] }, + ]); + expect(signed.mac?.identity).toBe(MAC_RELEASE_ENVIRONMENT.DESKTOP_MACOS_CERTIFICATE_AUTHORITY); const unsigned = createDesktopBuilderConfig( { ...MAC_RELEASE_ENVIRONMENT, DESKTOP_REQUIRE_CODE_SIGNING: "false" }, @@ -61,6 +63,27 @@ describe("Desktop builder protocol configuration", () => { ); }); + it("builds a branded macOS drag-to-Applications installer", () => { + const config = createDesktopBuilderConfig(MAC_RELEASE_ENVIRONMENT, "darwin"); + + expect(config.extraResources).toContainEqual({ + from: "resources/selftune/node_modules", + to: "selftune/node_modules", + filter: ["**/*"], + }); + expect(config.dmg).toEqual({ + title: "SelfTune ${version}", + icon: "build/icon.icns", + iconSize: 144, + background: "build/dmg-background.png", + window: { width: 720, height: 460 }, + contents: [ + { x: 195, y: 235, type: "file" }, + { x: 525, y: 235, type: "link", path: "/Applications" }, + ], + }); + }); + it("never emits Linux protocol metadata even when signing is requested", () => { const config = createDesktopBuilderConfig( { ...MAC_RELEASE_ENVIRONMENT, BUN_TARGET: "bun-linux-x64" }, @@ -76,13 +99,4 @@ describe("Desktop builder protocol configuration", () => { expect(config.protocols).toBeUndefined(); expect(config.mac).toMatchObject({ identity: null, notarize: false, target: ["dir"] }); }); - - it("copies the compiled runtime's nested native dependencies as explicit resources", () => { - const config = createDesktopBuilderConfig({}, "darwin"); - expect(config.extraResources).toContainEqual({ - from: "resources/selftune/node_modules", - to: "selftune/node_modules", - filter: ["**/*"], - }); - }); }); diff --git a/apps/desktop/src/main/desktop-install-credential.test.ts b/apps/desktop/src/main/desktop-install-credential.test.ts deleted file mode 100644 index 0f5b3b4b..00000000 --- a/apps/desktop/src/main/desktop-install-credential.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { randomBytes } from "node:crypto"; - -import { describe, expect, it } from "bun:test"; -import { - DesktopInstallFinalizeResponseSchema, - type DesktopInstallFinalizeRequest, -} from "@selftune/api-contract/install-credentials"; -import type { DurableInstallReceipt } from "@selftune/runtime/installer/materializer"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import type { DesktopRecipientPreview } from "./desktop-install-bootstrap"; -import { - coordinatePostCommitInstallFinalization, - type DesktopInstallFinalizationCloudClient, -} from "./desktop-install-credential"; - -const uuid = (digit: string) => - `${digit.repeat(8)}-${digit.repeat(4)}-4${digit.repeat(3)}-8${digit.repeat(3)}-${digit.repeat(12)}`; -const opaque = () => randomBytes(32).toString("base64url"); - -function preview(): DesktopRecipientPreview { - return { - invitationId: uuid("1"), - shareId: uuid("2"), - distributionId: uuid("3"), - sealedObjectId: uuid("4"), - packagedSha256: "a".repeat(64), - termsDisclosureSha256: "b".repeat(64), - termsAcceptance: "accepted", - contributorSignals: { - _tag: "signals_unavailable", - signalDisclosureSha256: "c".repeat(64), - signalRecipientOrganizationId: null, - allowedFields: [], - capability: "not_capable", - defaultState: "off", - contributorConsent: "not_applicable", - enabled: false, - }, - installLifecycleReporting: { - _tag: "installed_status", - lifecycleDisclosureSha256: "2".repeat(64), - consent: "not_granted", - senderVisibleInstalledStatus: "disabled", - }, - status: "preview", - expiresAt: "2026-07-21T12:00:00.000Z", - supportedTargetAgents: ["codex"], - targetAgentSelectionRequired: true, - scopeChoices: ["project", "global"], - scopeSelectionRequired: true, - installModeDefault: "copy", - conflictPolicyChoices: ["prompt", "replace", "keep_both"], - conflictPolicyDefault: "prompt", - customPathPolicy: "unsupported_v1", - automaticDesktopInstall: "not_authorized", - automaticSkillInstall: "not_authorized", - }; -} - -function receipt(state: DurableInstallReceipt["state"] = "active"): DurableInstallReceipt { - return { - receiptId: uuid("6"), - state, - agent: "codex", - scope: "project", - projectRoot: "/private/project", - registryRoot: "/private/project/.agents/skills", - targetPath: "/private/project/.agents/skills/example", - skillName: "example", - logicalSkillId: "example", - sealedPackageSha256: "a".repeat(64), - subjectKind: "standalone", - skillSet: null, - logicalVersion: "d".repeat(64), - distributionId: uuid("3"), - shareId: uuid("2"), - handoffId: uuid("7"), - sealedObjectId: uuid("4"), - signature: { algorithm: "Ed25519", keyId: "test", value: "signature" }, - license: { spdxExpression: "MIT", licenseFile: null, notices: [] }, - platform: "darwin", - strategy: "copy", - conflictDecision: "cancel", - backupPath: null, - consent: { - consentId: uuid("8"), - recipientPrincipalId: uuid("9"), - recordedAt: "2026-07-21T10:00:00.000Z", - action: "install_with_selftune", - disclosureSha256: "b".repeat(64), - termsAccepted: true, - contributorSignals: "not_granted", - contributorSignalRecipientOwnerId: null, - contributorSignalAllowedFields: [], - lifecycleReporting: "not_granted", - lifecycleAllowedFields: [], - }, - source: { kind: "remote_sealed", objectId: uuid("4") }, - previewFingerprint: "e".repeat(64), - operationId: uuid("a"), - previousReceiptId: null, - supersededByReceiptId: null, - createdAt: "2026-07-21T10:01:00.000Z", - updatedAt: "2026-07-21T10:01:00.000Z", - removedAt: null, - files: [ - { path: "SKILL.md", sha256: "f".repeat(64), byteLength: 12, durableSnapshotRef: "snapshot" }, - ], - }; -} - -function cloud(record: DesktopInstallFinalizeRequest[]): DesktopInstallFinalizationCloudClient { - return { - finalize: async (request) => { - record.push(request); - return Schema.decodeUnknownSync(DesktopInstallFinalizeResponseSchema)({ - finalizationId: uuid("e"), - status: "finalized", - lifecycleReporting: request.lifecycleReporting, - finalizedAt: "2026-07-21T10:01:00.000Z", - }); - }, - }; -} - -describe("Desktop post-commit install finalization", () => { - it("finalizes a committed receipt and keeps lifecycle consent independent", async () => { - const finalizations: DesktopInstallFinalizeRequest[] = []; - const result = await coordinatePostCommitInstallFinalization( - { - receiptId: uuid("6"), - bootstrapToken: opaque(), - preview: preview(), - installLifecycleConsent: "not_granted", - }, - { receipts: { readReceipt: () => Effect.succeed(receipt()) }, cloud: cloud(finalizations) }, - ); - expect(result).toEqual({ status: "finalized", installLifecycle: "not_reported" }); - expect(finalizations).toHaveLength(1); - expect(finalizations[0]).not.toHaveProperty("targetPath"); - expect(finalizations[0]).not.toHaveProperty("machineId"); - }); - - it("reports only separately granted installed lifecycle status", async () => { - const finalizations: DesktopInstallFinalizeRequest[] = []; - const result = await coordinatePostCommitInstallFinalization( - { - receiptId: uuid("6"), - bootstrapToken: opaque(), - preview: preview(), - installLifecycleConsent: "granted", - }, - { receipts: { readReceipt: () => Effect.succeed(receipt()) }, cloud: cloud(finalizations) }, - ); - expect(result).toEqual({ status: "finalized", installLifecycle: "reported" }); - expect(finalizations[0]?.lifecycleReporting).toMatchObject({ - consent: "granted", - senderVisibleInstalledStatus: "enabled", - }); - }); - - it("requires an active receipt with the exact preview binding", async () => { - const finalizations: DesktopInstallFinalizeRequest[] = []; - const inactive = await coordinatePostCommitInstallFinalization( - { - receiptId: uuid("6"), - bootstrapToken: opaque(), - preview: preview(), - installLifecycleConsent: "not_granted", - }, - { - receipts: { readReceipt: () => Effect.succeed(receipt("removed")) }, - cloud: cloud(finalizations), - }, - ); - expect(inactive).toEqual({ - status: "not_finalized", - reason: "receipt_not_committed", - installLifecycle: "not_reported", - }); - expect(finalizations).toHaveLength(0); - }); -}); diff --git a/apps/desktop/src/main/desktop-install-credential.ts b/apps/desktop/src/main/desktop-install-credential.ts deleted file mode 100644 index 719e3b1a..00000000 --- a/apps/desktop/src/main/desktop-install-credential.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { createHash } from "node:crypto"; - -import { - DesktopInstallFinalizeRequestSchema, - DesktopInstallFinalizeResponseSchema, - type DesktopInstallFinalizeRequest, - type DesktopInstallFinalizeResponse, -} from "@selftune/api-contract/install-credentials"; -import type { - DurableInstallReceipt, - DurableInstallReceiptAuthority, -} from "@selftune/runtime/installer/materializer"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import type { DesktopRecipientPreview } from "./desktop-install-bootstrap"; -import type { SecureDesktopCloudSession } from "./desktop-recipient-preview"; - -const MaximumResponseBytes = 64 * 1_024; -const RequestTimeoutMilliseconds = 15_000; - -export interface DesktopInstallFinalizationCloudClient { - readonly finalize: ( - request: DesktopInstallFinalizeRequest, - ) => Promise; -} - -export interface DesktopPostCommitFinalizationInput { - readonly receiptId: string; - readonly bootstrapToken: string; - readonly preview: DesktopRecipientPreview; - readonly installLifecycleConsent: "not_granted" | "granted"; -} - -export type DesktopPostCommitFinalizationResult = - | { readonly status: "finalized"; readonly installLifecycle: "reported" | "not_reported" } - | { - readonly status: "not_finalized"; - readonly reason: - | "receipt_not_committed" - | "receipt_binding_mismatch" - | "authority_unavailable"; - readonly installLifecycle: "not_reported"; - }; - -interface CoordinatorDependencies { - readonly receipts: Pick; - readonly cloud: DesktopInstallFinalizationCloudClient; -} - -function sha256(value: string): string { - return createHash("sha256").update(value, "utf8").digest("hex"); -} - -function canonicalReceiptEvidence(receipt: DurableInstallReceipt): string { - // Intentionally excludes paths, filenames, machine identity, and file-level receipt details. - return JSON.stringify({ - version: 1, - receiptId: receipt.receiptId, - operationId: receipt.operationId, - state: receipt.state, - distributionId: receipt.distributionId, - shareId: receipt.shareId, - sealedPackageSha256: receipt.sealedPackageSha256, - logicalSkillId: receipt.logicalSkillId, - logicalVersion: receipt.logicalVersion, - previewFingerprint: receipt.previewFingerprint, - consentId: receipt.consent.consentId, - disclosureSha256: receipt.consent.disclosureSha256, - termsAccepted: receipt.consent.termsAccepted, - createdAt: receipt.createdAt, - }); -} - -function receiptBindingFailure( - receipt: DurableInstallReceipt, - input: DesktopPostCommitFinalizationInput, -): DesktopPostCommitFinalizationResult | null { - if (receipt.state !== "active") { - return { - status: "not_finalized", - reason: "receipt_not_committed", - installLifecycle: "not_reported", - }; - } - if ( - receipt.distributionId !== input.preview.distributionId || - receipt.shareId !== input.preview.shareId || - receipt.sealedPackageSha256 !== input.preview.packagedSha256 || - receipt.consent.termsAccepted !== true || - receipt.consent.disclosureSha256 !== input.preview.termsDisclosureSha256 - ) { - return { - status: "not_finalized", - reason: "receipt_binding_mismatch", - installLifecycle: "not_reported", - }; - } - return null; -} - -/** Finalizes separately consented install lifecycle after an active local receipt reload. */ -export async function coordinatePostCommitInstallFinalization( - input: DesktopPostCommitFinalizationInput, - dependencies: CoordinatorDependencies, -): Promise { - const receipt = await Effect.runPromise(dependencies.receipts.readReceipt(input.receiptId)).catch( - () => null, - ); - if (receipt === null) { - return { - status: "not_finalized", - reason: "receipt_not_committed", - installLifecycle: "not_reported", - }; - } - const bindingFailure = receiptBindingFailure(receipt, input); - if (bindingFailure !== null) return bindingFailure; - const lifecycleDisclosure = input.preview.installLifecycleReporting; - if (lifecycleDisclosure === undefined) { - return { - status: "not_finalized", - reason: "authority_unavailable", - installLifecycle: "not_reported", - }; - } - const evidence = canonicalReceiptEvidence(receipt); - try { - const response = await dependencies.cloud.finalize( - Schema.decodeUnknownSync(DesktopInstallFinalizeRequestSchema)({ - bootstrapToken: input.bootstrapToken, - distributionId: receipt.distributionId, - sealedPackageSha256: receipt.sealedPackageSha256, - pseudonymousInstallKey: sha256(`selftune:install-key:v1:${receipt.receiptId}`), - receiptEvidenceSha256: sha256(evidence), - lifecycleReporting: - input.installLifecycleConsent === "granted" - ? { - ...lifecycleDisclosure, - consent: "granted", - senderVisibleInstalledStatus: "enabled", - } - : lifecycleDisclosure, - }), - ); - return { - status: "finalized", - installLifecycle: - response.lifecycleReporting.consent === "granted" ? "reported" : "not_reported", - }; - } catch { - return { - status: "not_finalized", - reason: "authority_unavailable", - installLifecycle: "not_reported", - }; - } -} - -async function readBoundedJson(response: Response): Promise { - const declared = Number(response.headers.get("content-length")); - if (Number.isFinite(declared) && declared > MaximumResponseBytes) { - throw new Error("response_too_large"); - } - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.byteLength > MaximumResponseBytes) throw new Error("response_too_large"); - return JSON.parse(new TextDecoder().decode(bytes)); -} - -export function createDesktopInstallFinalizationCloudClient( - session: SecureDesktopCloudSession, - request: typeof fetch = fetch, -): DesktopInstallFinalizationCloudClient { - const origin = new URL(session.origin); - if ( - origin.protocol !== "https:" || - origin.username || - origin.password || - origin.port || - (origin.hostname !== "selftune.dev" && !origin.hostname.endsWith(".selftune.dev")) - ) { - throw new Error("invalid_cloud_origin"); - } - return { - finalize: async (payload) => { - const response = await request( - new URL("/api/v1/recipient-actions/desktop/install-finalize", origin), - { - method: "POST", - headers: { - authorization: `Bearer ${session.accessToken}`, - "content-type": "application/json", - }, - body: JSON.stringify(payload), - cache: "no-store", - credentials: "omit", - redirect: "manual", - signal: AbortSignal.timeout(RequestTimeoutMilliseconds), - }, - ); - if (!response.ok || response.status >= 300) throw new Error("finalization_authority_failed"); - return Schema.decodeUnknownSync(DesktopInstallFinalizeResponseSchema)( - await readBoundedJson(response), - ); - }, - }; -} diff --git a/apps/desktop/src/main/desktop-install-handoff-events.test.ts b/apps/desktop/src/main/desktop-install-handoff-events.test.ts index f633a6f9..1cd3490c 100644 --- a/apps/desktop/src/main/desktop-install-handoff-events.test.ts +++ b/apps/desktop/src/main/desktop-install-handoff-events.test.ts @@ -16,6 +16,8 @@ describe("Desktop install handoff platform events", () => { let prevented = 0; const events = createDesktopInstallHandoffEventBridge({ controller, + trustedBuild: true, + openPack: () => undefined, show: () => { shows += 1; }, @@ -53,6 +55,8 @@ describe("Desktop install handoff platform events", () => { let shows = 0; const events = createDesktopInstallHandoffEventBridge({ controller, + trustedBuild: true, + openPack: () => undefined, show: () => { shows += 1; }, @@ -84,6 +88,8 @@ describe("Desktop install handoff platform events", () => { }); const events = createDesktopInstallHandoffEventBridge({ controller, + trustedBuild: true, + openPack: () => undefined, show: () => { shows += 1; }, @@ -103,4 +109,31 @@ describe("Desktop install handoff platform events", () => { expect(shows).toBe(0); expect(resolves).toBe(0); }); + + it("opens a trusted Pack handoff on the local Skill Sets review surface", () => { + const controller = createDesktopInstallBootstrapController({ + trustedBuild: true, + resolvePreview: async () => ({ status: "unauthenticated" }), + detectAgents: async () => [], + }); + const opened: string[] = []; + const events = createDesktopInstallHandoffEventBridge({ + controller, + trustedBuild: true, + openPack: (packUrl) => { + opened.push(packUrl); + }, + show: () => undefined, + }); + const token = "D".repeat(43); + const handoff = `selftune://pack/aHR0cHM6Ly9jbG91ZC5zZWxmdHVuZS5kZXY/${token}`; + expect(events.coldStart(["SelfTune", handoff])).toEqual({ accepted: true }); + expect(opened).toEqual([]); + events.markReady(); + expect(opened).toEqual([`https://cloud.selftune.dev/p/${token}`]); + expect(events.secondInstance(["SelfTune", handoff])).toEqual({ + accepted: false, + reason: "duplicate", + }); + }); }); diff --git a/apps/desktop/src/main/desktop-install-handoff-events.ts b/apps/desktop/src/main/desktop-install-handoff-events.ts index db76b512..fbc31925 100644 --- a/apps/desktop/src/main/desktop-install-handoff-events.ts +++ b/apps/desktop/src/main/desktop-install-handoff-events.ts @@ -2,10 +2,13 @@ import type { DesktopInstallBootstrapController, DesktopInstallHandoffIngestResult, } from "./desktop-install-bootstrap"; +import { parseDesktopPackHandoff } from "./desktop-pack-handoff"; interface DesktopInstallHandoffEventBridgeOptions { readonly controller: DesktopInstallBootstrapController; + readonly trustedBuild: boolean; readonly show: () => void | Promise; + readonly openPack: (packUrl: string) => void | Promise; } interface PreventableOpenEvent { @@ -24,6 +27,8 @@ export function createDesktopInstallHandoffEventBridge( ): DesktopInstallHandoffEventBridge { let ready = false; let revealPending = false; + let pendingPackUrl: string | null = null; + let lastPackUrl: string | null = null; const revealIfAccepted = (result: DesktopInstallHandoffIngestResult) => { if (result.accepted) { if (ready) void options.show(); @@ -31,18 +36,38 @@ export function createDesktopInstallHandoffEventBridge( } return result; }; + const ingestUrl = (url: string): DesktopInstallHandoffIngestResult => { + const pack = parseDesktopPackHandoff(url); + if (!pack) return revealIfAccepted(options.controller.ingestUrl(url)); + if (!options.trustedBuild) return { accepted: false, reason: "untrusted_build" }; + if (pack.packUrl === lastPackUrl) return { accepted: false, reason: "duplicate" }; + lastPackUrl = pack.packUrl; + if (ready) void options.openPack(pack.packUrl); + else pendingPackUrl = pack.packUrl; + return { accepted: true }; + }; + const ingestArgv = (argv: ReadonlyArray): DesktopInstallHandoffIngestResult => { + const links = argv.filter((value) => value.startsWith("selftune://")); + if (links.length > 1) return { accepted: false, reason: "multiple" }; + return links[0] ? ingestUrl(links[0]) : revealIfAccepted(options.controller.ingestArgv(argv)); + }; return { - coldStart: (argv) => revealIfAccepted(options.controller.ingestArgv(argv)), + coldStart: ingestArgv, markReady() { ready = true; + if (pendingPackUrl) { + const packUrl = pendingPackUrl; + pendingPackUrl = null; + void options.openPack(packUrl); + } if (!revealPending) return; revealPending = false; void options.show(); }, openUrl(event, url) { event.preventDefault(); - return revealIfAccepted(options.controller.ingestUrl(url)); + return ingestUrl(url); }, - secondInstance: (argv) => revealIfAccepted(options.controller.ingestArgv(argv)), + secondInstance: ingestArgv, }; } diff --git a/apps/desktop/src/main/desktop-pack-handoff.test.ts b/apps/desktop/src/main/desktop-pack-handoff.test.ts new file mode 100644 index 00000000..11e35e61 --- /dev/null +++ b/apps/desktop/src/main/desktop-pack-handoff.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; + +import { parseDesktopPackHandoff } from "./desktop-pack-handoff"; + +const TOKEN = "A".repeat(43); + +describe("Desktop Pack handoff", () => { + it("reconstructs a branded Pack URL from a normalized origin and opaque token", () => { + expect(parseDesktopPackHandoff(`selftune://pack/aHR0cHM6Ly90ZWFtLmV4YW1wbGU/${TOKEN}`)).toEqual( + { packUrl: `https://team.example/p/${TOKEN}` }, + ); + }); + + it("rejects decorated, credentialed, malformed, and ambiguous handoffs", () => { + const invalid = [ + `selftune://pack/aHR0cHM6Ly90ZWFtLmV4YW1wbGU/${TOKEN}?next=/tmp`, + `selftune://pack/aHR0cDovL3VzZXI6cGFzc0B0ZWFtLmV4YW1wbGU/${TOKEN}`, + `selftune://pack/aHR0cHM6Ly90ZWFtLmV4YW1wbGUvZXh0cmE/${TOKEN}`, + `selftune://pack/not-base64!/${TOKEN}`, + `selftune://pack/aHR0cHM6Ly90ZWFtLmV4YW1wbGU/${"B".repeat(42)}`, + ]; + for (const value of invalid) expect(parseDesktopPackHandoff(value)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/desktop-pack-handoff.ts b/apps/desktop/src/main/desktop-pack-handoff.ts new file mode 100644 index 00000000..4c904629 --- /dev/null +++ b/apps/desktop/src/main/desktop-pack-handoff.ts @@ -0,0 +1,38 @@ +const PACK_HANDOFF = /^selftune:\/\/pack\/([A-Za-z0-9_-]{1,684})\/([A-Za-z0-9_-]{43})$/u; + +export interface DesktopPackHandoff { + readonly packUrl: string; +} + +function decodeOrigin(value: string): string | null { + try { + const bytes = Buffer.from(value, "base64url"); + if (bytes.toString("base64url") !== value) return null; + const origin = bytes.toString("ascii"); + if (!bytes.equals(Buffer.from(origin, "ascii"))) return null; + const url = new URL(origin); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + url.origin !== origin + ) { + return null; + } + return origin; + } catch { + return null; + } +} + +/** Deep links carry only one normalized Pack origin and its opaque authority token. */ +export function parseDesktopPackHandoff(input: string): DesktopPackHandoff | null { + if (input.length > 800) return null; + const match = PACK_HANDOFF.exec(input); + if (!match?.[1] || !match[2]) return null; + const origin = decodeOrigin(match[1]); + return origin ? { packUrl: `${origin}/p/${match[2]}` } : null; +} diff --git a/apps/desktop/src/main/desktop-protocol.test.ts b/apps/desktop/src/main/desktop-protocol.test.ts index 4e8bed61..cfce62f3 100644 --- a/apps/desktop/src/main/desktop-protocol.test.ts +++ b/apps/desktop/src/main/desktop-protocol.test.ts @@ -60,7 +60,7 @@ describe("Desktop protocol registration", () => { expect(desktopProtocolConfiguration("linux", MAC_PINS)).toBeUndefined(); expect(desktopProtocolConfiguration("darwin", null)).toBeUndefined(); expect(desktopProtocolConfiguration("darwin", MAC_PINS)).toEqual([ - { name: "SelfTune install handoff", schemes: ["selftune"] }, + { name: "SelfTune Pack and install handoff", schemes: ["selftune"] }, ]); }); @@ -138,18 +138,11 @@ describe("Desktop protocol registration", () => { it("fails closed when release pin configuration is partial or not official", () => { expect(desktopReleaseTrustPinsFromEnvironment("darwin", {})).toBeNull(); - expect( - desktopReleaseTrustPinsFromEnvironment("darwin", { - DESKTOP_MACOS_TEAM_IDENTIFIER: "ABC123XYZ9", - DESKTOP_MACOS_CERTIFICATE_AUTHORITY: "Developer ID Application: SelfTune LLC (ABC123XYZ9)", - DESKTOP_MACOS_DESIGNATED_REQUIREMENT_SHA256: "a".repeat(64), - }), - ).toBeNull(); expect( desktopReleaseTrustPinsFromEnvironment("darwin", { DESKTOP_MACOS_TEAM_IDENTIFIER: "ABC123XYZ9", DESKTOP_MACOS_CERTIFICATE_AUTHORITY: - "Developer ID Application: PragSys Collaborative LLC (DIFFERENT1)", + "Developer ID Application: Third Party LLC (ABC123XYZ9)", DESKTOP_MACOS_DESIGNATED_REQUIREMENT_SHA256: "a".repeat(64), }), ).toBeNull(); diff --git a/apps/desktop/src/main/desktop-protocol.ts b/apps/desktop/src/main/desktop-protocol.ts index 964d2f49..2259df50 100644 --- a/apps/desktop/src/main/desktop-protocol.ts +++ b/apps/desktop/src/main/desktop-protocol.ts @@ -125,7 +125,7 @@ export function desktopProtocolConfiguration( pins: DesktopReleaseTrustPins | null, ): Array<{ name: string; schemes: string[] }> | undefined { return pins?.platform === platform && (platform === "darwin" || platform === "win32") - ? [{ name: "SelfTune install handoff", schemes: [DESKTOP_PROTOCOL] }] + ? [{ name: "SelfTune Pack and install handoff", schemes: [DESKTOP_PROTOCOL] }] : undefined; } diff --git a/apps/desktop/src/main/desktop-recipient-preview.ts b/apps/desktop/src/main/desktop-recipient-preview.ts index c02e508a..767c71bc 100644 --- a/apps/desktop/src/main/desktop-recipient-preview.ts +++ b/apps/desktop/src/main/desktop-recipient-preview.ts @@ -161,7 +161,7 @@ export function loadSecureDesktopCloudSession( const alpha = config?.alpha; const reference = alpha?.credential; if (!alpha?.enrolled || !reference || reference.provider === "file") return null; - const origin = selfTuneHttpsOrigin(alpha.cloud_api_url ?? "https://api.selftune.dev"); + const origin = selfTuneHttpsOrigin(alpha.cloud_api_url ?? "https://cloud.selftune.dev"); if (!origin) return null; const configRoot = dirname(configPath); const accessToken = ( diff --git a/apps/desktop/src/main/desktop-shell.ts b/apps/desktop/src/main/desktop-shell.ts index a1acf09c..c9dae325 100644 --- a/apps/desktop/src/main/desktop-shell.ts +++ b/apps/desktop/src/main/desktop-shell.ts @@ -53,7 +53,7 @@ export function createDesktopShell(options: DesktopShellOptions): DesktopShellCo options.runtime.requestJson("/api/v2/settings", TraySettingsResponseSchema), ), options.runRuntime( - options.runtime.requestJson("/api/v2/overview", TrayOverviewResponseSchema), + options.runtime.requestJson("/api/v2/tray-status", TrayOverviewResponseSchema), ), options.runRuntime(options.runtime.requestJson("/api/health", TrayHealthResponseSchema)), options.runRuntime( diff --git a/apps/desktop/src/main/desktop-window.ts b/apps/desktop/src/main/desktop-window.ts index a995f3a9..b8e38d06 100644 --- a/apps/desktop/src/main/desktop-window.ts +++ b/apps/desktop/src/main/desktop-window.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from "node:url"; import { BrowserWindow, session, shell, type IpcMainInvokeEvent, type Session } from "electron"; -import { PENDING_WINDOW_IPC_TEST_DOCUMENT } from "../desktop-test-contract"; +import { + PENDING_WINDOW_IPC_TEST_DOCUMENT, + PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT, +} from "../desktop-test-contract"; import { runtimeCrashHtml } from "./crash-screen"; import { errorReportingEnabled, reportRuntimeFailure } from "./diagnostics"; import { runtimeLaunchHtml } from "./launch-screen"; @@ -53,11 +56,11 @@ interface OwnedWindow { readonly baseUrl: string | null; readonly dispose: () => void; readonly pendingIpcProbe: PendingIpcProbeBarrier | null; + trustedRecoveryUrl: string | null; readonly window: BrowserWindow; } const preloadPath = fileURLToPath(new URL("../preload/index.js", import.meta.url)); -const pendingIpcProbeTimeoutMs = process.platform === "win32" ? 60_000 : 10_000; function htmlDataUrl(html: string): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; @@ -95,13 +98,8 @@ async function waitForPendingIpcProbe(barrier: PendingIpcProbeBarrier | null): P barrier.promise, new Promise((_resolve, reject) => { timeout = setTimeout( - () => - reject( - new Error( - `Pending-window IPC probe timed out after ${pendingIpcProbeTimeoutMs / 1_000} seconds.`, - ), - ), - pendingIpcProbeTimeoutMs, + () => reject(new Error("Pending-window IPC probe timed out after 10 seconds.")), + 10_000, ); }), ]); @@ -185,6 +183,9 @@ export function createDesktopWindowController( nodeIntegration: false, sandbox: true, preload: preloadPath, + ...(testProbeEnabled + ? { additionalArguments: [PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT] } + : {}), ...(authenticatedSession ? { session: authenticatedSession.session } : {}), }, }); @@ -193,6 +194,7 @@ export function createDesktopWindowController( baseUrl: connection.baseUrl, window, pendingIpcProbe: createPendingIpcProbeBarrier(), + trustedRecoveryUrl: null, dispose: () => { if (disposed) return; disposed = true; @@ -348,6 +350,7 @@ export function createDesktopWindowController( baseUrl: null, window, pendingIpcProbe: null, + trustedRecoveryUrl: null, dispose: () => { disposed = true; }, @@ -360,9 +363,9 @@ export function createDesktopWindowController( }); mainWindow = owned; } - await mainWindow.window.loadURL( - htmlDataUrl(runtimeCrashHtml({ detail, reported: errorReportingEnabled })), - ); + const recoveryUrl = htmlDataUrl(runtimeCrashHtml({ detail, reported: errorReportingEnabled })); + mainWindow.trustedRecoveryUrl = recoveryUrl; + await mainWindow.window.loadURL(recoveryUrl); focus(); void options.checkForUpdates(false); } @@ -388,7 +391,7 @@ export function createDesktopWindowController( } try { const senderUrl = event.senderFrame?.url; - if (owner === active && recoveryMode && senderUrl?.startsWith("data:text/html")) { + if (owner === active && recoveryMode && senderUrl === owner.trustedRecoveryUrl) { return "recovery"; } if (senderUrl && owner.baseUrl && isInternalDashboardUrl(senderUrl, owner.baseUrl)) { diff --git a/apps/desktop/src/main/diagnostics.test.ts b/apps/desktop/src/main/diagnostics.test.ts index 6a41d7b5..4ec57677 100644 --- a/apps/desktop/src/main/diagnostics.test.ts +++ b/apps/desktop/src/main/diagnostics.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +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"; @@ -54,6 +55,7 @@ mock.module("@sentry/electron/main", () => ({ })); const { + guardConsoleTransport, hasExplicitNativeCrashConsent, initializeDiagnostics, isUnavailableLogStream, @@ -109,6 +111,25 @@ describe("desktop diagnostics privacy", () => { expect(currentConsoleLevel()).toBe(false); }); + it("disables console logging when a detached terminal reports an asynchronous EIO", () => { + const terminal = new EventEmitter(); + const unexpectedErrors: unknown[] = []; + consoleTransport.level = "info"; + consoleTransport.writeFn = (input: unknown) => { + consoleWrites.push(input); + }; + + guardConsoleTransport(consoleTransport, [terminal], (cause) => { + unexpectedErrors.push(cause); + }); + + expect(() => + terminal.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" })), + ).not.toThrow(); + expect(currentConsoleLevel()).toBe(false); + expect(unexpectedErrors).toEqual([]); + }); + it("redacts credentials, authorization headers, secret fields, and local paths", () => { const configDir = "/Users/alice/.selftune"; const text = [ diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts index 8caa62b8..ab342914 100644 --- a/apps/desktop/src/main/diagnostics.ts +++ b/apps/desktop/src/main/diagnostics.ts @@ -28,6 +28,50 @@ export function isUnavailableLogStream(cause: unknown): boolean { ); } +interface ConsoleTransportGuard { + level: string | false; + writeFn(input: TInput): void; +} + +interface LogErrorStream { + on(event: "error", listener: (cause: Error) => void): unknown; +} + +function rethrowUnexpectedLogStreamError(cause: Error): void { + process.nextTick(() => { + throw cause; + }); +} + +/** + * Keep a detached development terminal from turning diagnostics into a recursive + * `write EIO` loop. Console writes can fail synchronously, but Node streams also + * report a closed terminal asynchronously through their `error` event. Disable + * only the console transport in either case so the file transport remains usable. + */ +export function guardConsoleTransport( + consoleTransport: ConsoleTransportGuard, + outputStreams: readonly LogErrorStream[], + reportUnexpectedError: (cause: Error) => void = rethrowUnexpectedLogStreamError, +): void { + const writeToConsole = consoleTransport.writeFn; + consoleTransport.writeFn = (input) => { + try { + writeToConsole(input); + } catch (cause) { + if (!isUnavailableLogStream(cause)) throw cause; + consoleTransport.level = false; + } + }; + + for (const stream of outputStreams) { + stream.on("error", (cause) => { + consoleTransport.level = false; + if (!isUnavailableLogStream(cause)) reportUnexpectedError(cause); + }); + } +} + const nativeCrashConsent = hasExplicitNativeCrashConsent( process.env.SELFTUNE_DESKTOP_NATIVE_CRASH_REPORTING, ); @@ -317,16 +361,7 @@ export function logRuntimeEvent( export function initializeDiagnostics(): void { log.initialize({ preload: true }); log.transports.file.level = "info"; - const consoleTransport = log.transports.console; - const writeToConsole = consoleTransport.writeFn; - consoleTransport.writeFn = (input) => { - try { - writeToConsole(input); - } catch (cause) { - if (!isUnavailableLogStream(cause)) throw cause; - consoleTransport.level = false; - } - }; + guardConsoleTransport(log.transports.console, [process.stdout, process.stderr]); if (errorReportingEnabled) { Sentry.init({ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f0e4feaf..35dfa061 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -8,6 +8,10 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import { initializeDiagnostics, logRuntimeEvent } from "./diagnostics"; import { startDevelopmentSidecarReloader } from "./development-sidecar-reloader"; +import { + createDesktopActivationController, + type DesktopActivationRuntimeState, +} from "./desktop-activation"; import { detectDesktopInstallerAgents } from "./desktop-agent-detection"; import { registerDesktopIpc, type DesktopIpcController } from "./desktop-ipc"; import { createDesktopInstallBootstrapController } from "./desktop-install-bootstrap"; @@ -54,6 +58,7 @@ let desktopRuntimeService: DesktopRuntimeService | null = null; let stopDevelopmentSidecarReloader: (() => void) | null = null; let shutdownStarted = false; let shutdownComplete = false; +let desktopActivationRuntimeState: DesktopActivationRuntimeState = "starting"; const desktopWindow = createDesktopWindowController({ checkForUpdates: (interactive) => desktopShell?.checkForUpdates(interactive) ?? Promise.resolve(), @@ -71,13 +76,22 @@ const desktopInstallBootstrap = createDesktopInstallBootstrapController({ }); const desktopInstallHandoffEvents = createDesktopInstallHandoffEventBridge({ controller: desktopInstallBootstrap, + trustedBuild: trustedPackagedBuild, show: desktopWindow.show, + openPack: (packUrl) => + desktopWindow.openDashboardPath(`/projects?pack=${encodeURIComponent(packUrl)}`), }); const desktopRuntime = ManagedRuntime.make( makeDesktopRuntimeLayer(runtimeDependencies, { rebindConnection: desktopWindow.rebindConnection, - onConnectionActivated: () => desktopShell?.refreshTray() ?? Promise.resolve(), - onRecoveryFailed: desktopWindow.showCrash, + onConnectionActivated: async () => { + desktopActivationRuntimeState = "ready"; + await (desktopShell?.refreshTray() ?? Promise.resolve()); + }, + onRecoveryFailed: async (cause) => { + desktopActivationRuntimeState = "unavailable"; + await desktopWindow.showCrash(cause); + }, }), ); @@ -85,6 +99,22 @@ function runRuntime(effect: Effect.Effect): Promise { + if (desktopActivationRuntimeState === "starting") return "starting"; + const runtime = desktopRuntimeService; + if (!runtime) return "unavailable"; + return (await runRuntime(runtime.connection)) ? "ready" : "unavailable"; + }, + restartRuntime: async () => { + const runtime = desktopRuntimeService; + if (!runtime) throw new Error("SelfTune local runtime is not ready to restart."); + await runRuntime(runtime.restart); + }, + show: desktopWindow.show, + showCrash: desktopWindow.showCrash, +}); + async function boot(): Promise { desktopWindow.showLaunching(); const runtime: DesktopRuntimeService = await desktopRuntime.runPromise( @@ -117,6 +147,7 @@ async function boot(): Promise { await desktopShell.createTray(); const openedAtLogin = app.isPackaged && app.getLoginItemSettings().wasOpenedAtLogin; await desktopWindow.createInitial(connection, !openedAtLogin); + desktopActivationRuntimeState = "ready"; desktopInstallHandoffEvents.markReady(); stopDevelopmentSidecarReloader = startDevelopmentSidecarReloader({ appPath: app.getAppPath(), @@ -162,7 +193,7 @@ app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); app.on("activate", () => { - void desktopWindow.show(); + void desktopActivation.activate(); }); app.on("before-quit", (event) => { if (shutdownComplete) return; @@ -180,7 +211,7 @@ if (!app.requestSingleInstanceLock()) { }); app.on("second-instance", (_event, argv) => { const result = desktopInstallHandoffEvents.secondInstance(argv); - if (!result.accepted) void desktopWindow.show(); + if (!result.accepted) void desktopActivation.activate(); }); app .whenReady() @@ -189,6 +220,9 @@ if (!app.requestSingleInstanceLock()) { return boot(); }) .catch((cause: unknown) => { - if (!shutdownStarted) void desktopWindow.showCrash(cause); + if (!shutdownStarted) { + desktopActivationRuntimeState = "unavailable"; + void desktopWindow.showCrash(cause); + } }); } diff --git a/apps/desktop/src/main/reset-state.test.ts b/apps/desktop/src/main/reset-state.test.ts index b50f768d..fe8693f9 100644 --- a/apps/desktop/src/main/reset-state.test.ts +++ b/apps/desktop/src/main/reset-state.test.ts @@ -17,16 +17,28 @@ describe("local state recovery", () => { roots.push(root); writeFileSync(join(root, "selftune.db"), "database"); writeFileSync(join(root, "selftune.db-wal"), "wal"); + writeFileSync(join(root, "observability.duckdb"), "analytical database"); + writeFileSync(join(root, "observability.duckdb.wal"), "analytical wal"); writeFileSync(join(root, "remote-library.json"), "settings"); mkdirSync(join(root, "server-control")); writeFileSync(join(root, "server-control", "auth.json"), "auth"); const result = resetSelfTuneState(root); - expect(result.moved).toEqual(["selftune.db", "selftune.db-wal", "server-control"]); + expect(result.moved).toEqual([ + "selftune.db", + "selftune.db-wal", + "observability.duckdb", + "observability.duckdb.wal", + "server-control", + ]); expect(existsSync(join(result.backupDir, "selftune.db"))).toBe(true); + expect(existsSync(join(result.backupDir, "observability.duckdb"))).toBe(true); + expect(existsSync(join(result.backupDir, "observability.duckdb.wal"))).toBe(true); expect(existsSync(join(result.backupDir, "server-control", "auth.json"))).toBe(true); expect(existsSync(join(root, "selftune.db"))).toBe(false); + expect(existsSync(join(root, "observability.duckdb"))).toBe(false); + expect(existsSync(join(root, "observability.duckdb.wal"))).toBe(false); expect(existsSync(join(root, "remote-library.json"))).toBe(true); }); }); diff --git a/apps/desktop/src/main/runtime-install.test.ts b/apps/desktop/src/main/runtime-install.test.ts index d1df2646..88734279 100644 --- a/apps/desktop/src/main/runtime-install.test.ts +++ b/apps/desktop/src/main/runtime-install.test.ts @@ -1,40 +1,28 @@ import { afterEach, describe, expect, it } from "bun:test"; import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { - isSigningMutableRuntimePath, parseDeveloperIdSigningIdentity, runtimeMatchesSignedSource, verifyRuntimeDirectory, } from "./runtime-integrity"; const roots: string[] = []; -const DUCKDB_NODE_PATH = - "node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node"; -const DUCKDB_LIBRARY_PATH = - "node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/libduckdb.dylib"; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function runtimeFixture(options: { readonly settingsSigningMutable?: boolean } = {}): string { +function runtimeFixture(): string { const root = mkdtempSync(join(tmpdir(), "selftune-runtime-install-")); roots.push(root); const executable = Buffer.from("selftune-runtime"); - const reportWorker = Buffer.from("selftune-report-worker"); const settings = Buffer.from('{"version":1}\n'); - const duckDbNode = Buffer.from("duckdb-node"); - const duckDbLibrary = Buffer.from("duckdb-library"); writeFileSync(join(root, "selftune"), executable, { mode: 0o700 }); - writeFileSync(join(root, "selftune-report-worker"), reportWorker, { mode: 0o700 }); writeFileSync(join(root, "settings_snippet.json"), settings); - mkdirSync(dirname(join(root, DUCKDB_NODE_PATH)), { recursive: true }); - writeFileSync(join(root, DUCKDB_NODE_PATH), duckDbNode, { mode: 0o644 }); - writeFileSync(join(root, DUCKDB_LIBRARY_PATH), duckDbLibrary, { mode: 0o644 }); writeFileSync( join(root, "runtime-manifest.json"), JSON.stringify({ @@ -46,30 +34,12 @@ function runtimeFixture(options: { readonly settingsSigningMutable?: boolean } = size: executable.byteLength, sha256: createHash("sha256").update(executable).digest("hex"), }, - { - path: "selftune-report-worker", - signing_mutable: true, - size: reportWorker.byteLength, - sha256: createHash("sha256").update(reportWorker).digest("hex"), - }, { path: "settings_snippet.json", - signing_mutable: options.settingsSigningMutable ?? false, + signing_mutable: false, size: settings.byteLength, sha256: createHash("sha256").update(settings).digest("hex"), }, - { - path: DUCKDB_NODE_PATH, - signing_mutable: true, - size: duckDbNode.byteLength, - sha256: createHash("sha256").update(duckDbNode).digest("hex"), - }, - { - path: DUCKDB_LIBRARY_PATH, - signing_mutable: true, - size: duckDbLibrary.byteLength, - sha256: createHash("sha256").update(duckDbLibrary).digest("hex"), - }, ], }), ); @@ -105,21 +75,6 @@ describe("stable desktop runtime integrity", () => { expect(verifyRuntimeDirectory(runtimeFixture())).toBeTrue(); }); - it("strictly allowlists runtime paths that packaging may sign", () => { - expect(isSigningMutableRuntimePath("selftune")).toBeTrue(); - expect(isSigningMutableRuntimePath("selftune.exe")).toBeTrue(); - expect(isSigningMutableRuntimePath("selftune-report-worker")).toBeTrue(); - expect(isSigningMutableRuntimePath("selftune-report-worker.exe")).toBeTrue(); - expect(isSigningMutableRuntimePath(DUCKDB_NODE_PATH)).toBeTrue(); - expect(isSigningMutableRuntimePath(DUCKDB_LIBRARY_PATH)).toBeTrue(); - expect(isSigningMutableRuntimePath("node_modules/untrusted/native.node")).toBeFalse(); - expect( - verifyRuntimeDirectory(runtimeFixture({ settingsSigningMutable: true }), { - allowPlatformSigningMutation: true, - }), - ).toBeFalse(); - }); - it("rejects a byte-identical installed runtime that cannot execute", () => { if (process.platform === "win32") return; const root = runtimeFixture(); @@ -196,30 +151,6 @@ describe("stable desktop runtime integrity", () => { ).toBeFalse(); }); - it("accepts packaging-signed native binaries only when the verified source and copy match", () => { - const source = runtimeFixture(); - const candidate = runtimeFixture(); - for (const path of ["selftune-report-worker", DUCKDB_NODE_PATH, DUCKDB_LIBRARY_PATH]) { - writeFileSync(join(source, path), `developer-id-signed-${path}`); - writeFileSync(join(candidate, path), `developer-id-signed-${path}`); - } - - expect(verifyRuntimeDirectory(source)).toBeFalse(); - expect(verifyRuntimeDirectory(source, { allowPlatformSigningMutation: true })).toBeTrue(); - expect( - runtimeMatchesSignedSource(source, candidate, { - allowPlatformSigningMutation: true, - }), - ).toBeTrue(); - - writeFileSync(join(candidate, DUCKDB_NODE_PATH), "different-signed-duckdb-node"); - expect( - runtimeMatchesSignedSource(source, candidate, { - allowPlatformSigningMutation: true, - }), - ).toBeFalse(); - }); - it("never exempts non-executable runtime assets from their build hash", () => { const root = runtimeFixture(); writeFileSync(join(root, "settings_snippet.json"), "tampered"); diff --git a/apps/desktop/src/main/runtime-integrity.ts b/apps/desktop/src/main/runtime-integrity.ts index a9309c0e..9420bdc3 100644 --- a/apps/desktop/src/main/runtime-integrity.ts +++ b/apps/desktop/src/main/runtime-integrity.ts @@ -16,22 +16,6 @@ const RuntimeManifest = Schema.Struct({ ), }); -const PRIMARY_RUNTIME_EXECUTABLE_PATHS: ReadonlySet = new Set(["selftune", "selftune.exe"]); -const RUNTIME_EXECUTABLE_PATHS: ReadonlySet = new Set([ - "selftune", - "selftune.exe", - "selftune-report-worker", - "selftune-report-worker.exe", -]); -const SIGNING_MUTABLE_RUNTIME_PATHS: ReadonlySet = new Set([ - "selftune", - "selftune.exe", - "selftune-report-worker", - "selftune-report-worker.exe", - "node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node", - "node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/libduckdb.dylib", -]); - export interface RuntimeIntegrityOptions { readonly allowPlatformSigningMutation?: boolean; } @@ -41,10 +25,6 @@ export interface DeveloperIdSigningIdentity { readonly teamIdentifier: string; } -export function isSigningMutableRuntimePath(path: string): boolean { - return SIGNING_MUTABLE_RUNTIME_PATHS.has(path); -} - export function parseDeveloperIdSigningIdentity( details: string, ): DeveloperIdSigningIdentity | null { @@ -71,12 +51,9 @@ export function verifyRuntimeDirectory( const manifest = readRuntimeManifest(root); if (manifest.files.length === 0) return false; const signingMutable = manifest.files.filter((entry) => entry.signing_mutable); - const signingMutableExecutables = signingMutable.filter((entry) => - PRIMARY_RUNTIME_EXECUTABLE_PATHS.has(entry.path), - ); if ( - signingMutableExecutables.length !== 1 || - signingMutable.some((entry) => !isSigningMutableRuntimePath(entry.path)) + signingMutable.length !== 1 || + !["selftune", "selftune.exe"].includes(signingMutable[0]?.path ?? "") ) { return false; } @@ -93,11 +70,7 @@ export function verifyRuntimeDirectory( if (!existsSync(path)) return false; const info = statSync(path); if (!info.isFile()) return false; - if ( - RUNTIME_EXECUTABLE_PATHS.has(entry.path) && - process.platform !== "win32" && - (info.mode & 0o111) === 0 - ) { + if (entry.signing_mutable && process.platform !== "win32" && (info.mode & 0o111) === 0) { return false; } const matchesBuildHash = info.size === entry.size && sha256(path) === entry.sha256; diff --git a/apps/desktop/src/main/state-backup.ts b/apps/desktop/src/main/state-backup.ts index 880c8270..339e67f2 100644 --- a/apps/desktop/src/main/state-backup.ts +++ b/apps/desktop/src/main/state-backup.ts @@ -1,7 +1,14 @@ import { existsSync, mkdirSync, renameSync } from "node:fs"; import { join } from "node:path"; -const STATE_ENTRIES = ["selftune.db", "selftune.db-wal", "selftune.db-shm", "server-control"]; +const STATE_ENTRIES = [ + "selftune.db", + "selftune.db-wal", + "selftune.db-shm", + "observability.duckdb", + "observability.duckdb.wal", + "server-control", +]; export interface ResetStateResult { readonly backupDir: string; diff --git a/apps/desktop/src/main/tray-state.test.ts b/apps/desktop/src/main/tray-state.test.ts index ef2574e4..ab183cbc 100644 --- a/apps/desktop/src/main/tray-state.test.ts +++ b/apps/desktop/src/main/tray-state.test.ts @@ -92,7 +92,7 @@ describe("desktop tray state", () => { ...remoteState.settings, remote_library: { configured: true, url: "https://api.selftune.dev" }, }), - ).toBe("Sync & Backup: SelfTune Cloud"); + ).toBe("Cloud inventory: Connected"); expect( remoteLibrarySummary({ ...remoteState.settings, diff --git a/apps/desktop/src/main/tray-state.ts b/apps/desktop/src/main/tray-state.ts index 7d13d20d..cd9f00b4 100644 --- a/apps/desktop/src/main/tray-state.ts +++ b/apps/desktop/src/main/tray-state.ts @@ -170,8 +170,8 @@ export function remoteLibrarySummary(settings: TraySettingsResponse | null): str if (!settings.remote_library.configured) return "Sync & Backup: Not configured"; try { const hostname = new URL(settings.remote_library.url ?? "").hostname.toLowerCase(); - return hostname === "api.selftune.dev" || hostname.endsWith("-api.selftune.dev") - ? "Sync & Backup: SelfTune Cloud" + return hostname === "cloud.selftune.dev" || hostname === "api.selftune.dev" + ? "Cloud inventory: Connected" : "Sync & Backup: Self-hosted"; } catch { return "Sync & Backup: Connected"; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 55b5a7ad..c4f12419 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,9 @@ import { contextBridge, ipcRenderer } from "electron"; -import { PENDING_WINDOW_IPC_TEST_CHANNEL } from "../desktop-test-contract"; +import { + PENDING_WINDOW_IPC_TEST_CHANNEL, + PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT, +} from "../desktop-test-contract"; import type { DesktopThisMacProfile } from "../main/this-mac-profile"; import type { DesktopInstallBootstrapPreviewResult, @@ -31,10 +34,9 @@ function createPendingWindowIpcProbe(): Promise { }); } -const pendingWindowIpcProbe = - process.env.SELFTUNE_DESKTOP_TEST_PENDING_WINDOW_IPC === "1" - ? createPendingWindowIpcProbe() - : null; +const pendingWindowIpcProbe = process.argv.includes(PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT) + ? createPendingWindowIpcProbe() + : null; const desktop = { getRuntime(): Promise<{ version: string; platform: NodeJS.Platform }> { diff --git a/apps/local-dashboard/CHANGELOG.md b/apps/local-dashboard/CHANGELOG.md deleted file mode 100644 index 1981e5d6..00000000 --- a/apps/local-dashboard/CHANGELOG.md +++ /dev/null @@ -1,14 +0,0 @@ -# @selftune/local-dashboard - -## 0.1.1 - -### Patch Changes - -- [#141](https://github.com/selftune-dev/selftune/pull/141) [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262) Thanks [@WellDunDun](https://github.com/WellDunDun)! - Render Local source merges and Cloud improve runs through one typed review contract, with a redacted, versioned Run Package for portable and agent-readable summaries. - -- [#141](https://github.com/selftune-dev/selftune/pull/141) [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262) Thanks [@WellDunDun](https://github.com/WellDunDun)! - Add protected This Mac, SelfTune Cloud, and custom Self-host server profiles to the shared dashboard, with explicit validation states, host-state isolation, and native-only Desktop credentials. - -- Updated dependencies [[`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262), [`1b0094b`](https://github.com/selftune-dev/selftune/commit/1b0094b228b298577d32a761738f645759a7d262)]: - - @selftune/dashboard-core@1.1.0 - - @selftune/app-core@0.1.1 - - @selftune/runtime@1.0.1 diff --git a/apps/local-dashboard/package.json b/apps/local-dashboard/package.json index 7ba4f2b8..5ea77881 100644 --- a/apps/local-dashboard/package.json +++ b/apps/local-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/local-dashboard", - "version": "0.1.1", + "version": "0.1.0", "private": true, "type": "module", "scripts": { @@ -43,6 +43,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.1", diff --git a/apps/local-dashboard/src/App.tsx b/apps/local-dashboard/src/App.tsx index 760deb11..18bd559e 100644 --- a/apps/local-dashboard/src/App.tsx +++ b/apps/local-dashboard/src/App.tsx @@ -5,6 +5,7 @@ import { type DashboardLinkRenderer, } from "@selftune/dashboard-core/chrome"; import { LockedRoute } from "@selftune/dashboard-core/gates"; +import { PluginsScreen } from "@selftune/dashboard-core/screens/plugins"; import { createBrowserServerProfileController, consumeServerProfilesHandoff, @@ -44,6 +45,7 @@ import { LiveRun } from "@/pages/LiveRun"; import { SkillReport } from "@/pages/SkillReport"; import { Settings } from "@/pages/Settings"; import { Status } from "@/pages/Status"; +import { Team } from "@/pages/Team"; import { localDashboardHost } from "@/runtime-host"; import { composeLocalPrimaryRoutes, @@ -54,7 +56,7 @@ import { matchLocalAppCoreShellRoute, } from "@/shared-app-routes"; import { - createLocalHostAdapter, + createLocalDashboardModules, LOCAL_CAPABILITIES, SELF_HOST_CAPABILITIES, } from "@/dashboard-host"; @@ -214,17 +216,17 @@ function DashboardShell({ runtime }: { runtime: ServerRuntimeProfile }) { ); if (firstBackup.status === "completed") { toast.success("selftune Cloud connected", { - description: `First backup complete: ${firstBackup.uploaded} uploaded, ${firstBackup.unchanged} unchanged.`, + description: `Cloud inventory updated: ${firstBackup.uploaded} reported, ${firstBackup.unchanged} unchanged.`, }); } else { toast.warning("selftune Cloud connected", { - description: `The first backup did not finish: ${firstBackup.message}`, + description: `The first inventory update did not finish: ${firstBackup.message}`, }); } }, manage: () => navigate("/settings?section=remote-library"), openDashboard: async () => { - const url = "https://app.selftune.dev"; + const url = "https://cloud.selftune.dev"; if (window.selftuneDesktop) { await window.selftuneDesktop.openExternal(url); return; @@ -317,10 +319,11 @@ function DashboardShell({ runtime }: { runtime: ServerRuntimeProfile }) { } /> {LOCAL_APP_CORE_ROUTE_MANIFEST.map(({ Component, id, path }) => ( - } /> + : } /> ))} } /> } /> + } /> } /> } /> } /> @@ -371,8 +374,8 @@ export function App({ runtime }: { runtime: ServerRuntimeProfile }) { }), [capabilities.features, runtime], ); - const hostAdapter = useMemo( - () => createLocalHostAdapter(runtimeHost, serverProfiles), + const hostModules = useMemo( + () => createLocalDashboardModules(runtimeHost, serverProfiles), [runtimeHost, serverProfiles], ); @@ -395,7 +398,7 @@ export function App({ runtime }: { runtime: ServerRuntimeProfile }) { - + diff --git a/apps/local-dashboard/src/api.test.ts b/apps/local-dashboard/src/api.test.ts index 29c5b2d6..c2b4fc8e 100644 --- a/apps/local-dashboard/src/api.test.ts +++ b/apps/local-dashboard/src/api.test.ts @@ -2,7 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { DashboardApiError, fetchCloudBillingStatus } from "./api"; +import { + DashboardApiError, + deleteProjectSkillSet, + exportProjectSkillSetPlugin, + fetchCloudBillingStatus, + fetchPlugins, + installProjectSkillSetPlugin, + managePlugin, + previewProjectSkillSetPluginInstall, +} from "./api"; afterEach(() => { vi.restoreAllMocks(); @@ -24,4 +33,117 @@ describe("dashboard API response boundary", () => { status: 404, } satisfies Partial); }); + + it("requests a plugin archive for an exact Skill Set and target", async () => { + const request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + Response.json({ filename: "research-agent-plugins-v1.zip", content_base64: "UEs=" }), + ); + + await expect( + exportProjectSkillSetPlugin({ set_id: "research", target: "agent-plugins-v1" }), + ).resolves.toEqual({ filename: "research-agent-plugins-v1.zip", content_base64: "UEs=" }); + expect(request).toHaveBeenCalledWith( + "/api/v2/skill-sets/plugin-export", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ set_id: "research", target: "agent-plugins-v1" }), + }), + ); + }); + + it("deletes an exact local Skill Set through the authenticated Desktop boundary", async () => { + const request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ deleted: true })); + + await expect(deleteProjectSkillSet("research/project")).resolves.toBeUndefined(); + expect(request).toHaveBeenCalledWith("/api/v2/skill-sets/research%2Fproject", { + method: "DELETE", + }); + }); + + it("previews and confirms native host plugin installation as separate requests", async () => { + const preview = { + setId: "research", + setName: "Research", + revisionHash: "a".repeat(64), + pluginName: "research", + pluginVersion: "0.0.0-selftune.aaaaaaaaaaaa", + marketplaceName: "selftune-market", + skillNames: ["review"], + hosts: [], + }; + const receipt = { ...preview, installedAt: "2026-08-09T12:00:00.000Z", hosts: [] }; + const request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json(preview)) + .mockResolvedValueOnce(Response.json(receipt)); + + await expect(previewProjectSkillSetPluginInstall("research")).resolves.toEqual(preview); + await expect( + installProjectSkillSetPlugin({ + skillSetId: "research", + expectedRevisionHash: preview.revisionHash, + hosts: ["claude", "codex"], + }), + ).resolves.toEqual(receipt); + expect(request).toHaveBeenNthCalledWith( + 1, + "/api/v2/skill-sets/plugin-install/preview", + expect.objectContaining({ method: "POST", body: JSON.stringify({ set_id: "research" }) }), + ); + expect(request).toHaveBeenNthCalledWith( + 2, + "/api/v2/skill-sets/plugin-install", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + set_id: "research", + expected_revision_hash: preview.revisionHash, + hosts: ["claude", "codex"], + }), + }), + ); + }); + + it("loads the detected plugin inventory and sends an explicit management action", async () => { + const inventory = { + hosts: [], + plugins: [], + totalPlugins: 0, + managedPlugins: 0, + refreshedAt: "2026-08-11T09:30:00.000Z", + }; + const receipt = { + host: "claude", + pluginId: "paper-desktop@paper", + action: "disable", + completedAt: "2026-08-11T09:31:00.000Z", + inventory, + }; + const request = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json(inventory)) + .mockResolvedValueOnce(Response.json(receipt)); + + await expect(fetchPlugins()).resolves.toEqual(inventory); + await expect( + managePlugin({ host: "claude", pluginId: "paper-desktop@paper", action: "disable" }), + ).resolves.toEqual(receipt); + expect(request).toHaveBeenNthCalledWith(1, "/api/v2/plugins"); + expect(request).toHaveBeenNthCalledWith( + 2, + "/api/v2/plugins/manage", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + host: "claude", + plugin_id: "paper-desktop@paper", + action: "disable", + }), + }), + ); + }); }); diff --git a/apps/local-dashboard/src/api.ts b/apps/local-dashboard/src/api.ts index ea10ad00..3f0bf8bf 100644 --- a/apps/local-dashboard/src/api.ts +++ b/apps/local-dashboard/src/api.ts @@ -49,6 +49,7 @@ import type { WorkspaceSkillSetPolicyAction, WorkspaceMemberRole, WorkspaceMembersResponse, + WorkspaceTeamOverview, UpdateSkillSetRequest, UpdateSkillClassificationRequest, ReviewSkillSetSuggestionRequest, @@ -58,10 +59,17 @@ import type { DurableDashboardDecision, } from "./types"; import type { + PluginInventoryModel, + PluginManagementInputModel, + PluginManagementReceiptModel, ProjectProvisionInput, ProjectProvisionPlanModel, ProjectProvisionResultModel, + ProjectSkillSetPluginInstallInput, + ProjectSkillSetPluginInstallPreviewModel, + ProjectSkillSetPluginInstallReceiptModel, } from "@selftune/dashboard-core/models"; +import type { SkillSetPackManagementList, SkillSetPackPreview } from "@selftune/control-plane"; const BASE = ""; @@ -129,6 +137,22 @@ export async function fetchSettings(): Promise { return res.json(); } +export async function fetchPlugins(): Promise { + const res = await fetch(`${BASE}/api/v2/plugins`); + if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); + return res.json(); +} + +export function managePlugin( + input: PluginManagementInputModel, +): Promise { + return portfolioRequest("/api/v2/plugins/manage", { + host: input.host, + plugin_id: input.pluginId, + action: input.action, + }); +} + export function startCloudAccountLink(): Promise { return portfolioRequest("/api/v2/settings/cloud-account/link/start", {}); } @@ -208,6 +232,49 @@ export function shareLibrarySkill( ); } +export interface LicenseDraftTermsInput { + copyrightHolder: string; + licensedOrganization: string; + year: number; +} + +export interface LicenseDraftPreviewResponse { + previewId: string; + skillPath: string; + licenseExpression: string; + files: Array<{ path: "SKILL.md" | "LICENSE"; patch: string }>; +} + +function licenseDraftRequest( + path: "/api/v2/library/license/preview" | "/api/v2/library/license/apply", + input: { skillId: string; terms: LicenseDraftTermsInput; previewId?: string }, +): Promise { + return portfolioRequest(path, { + skill_id: input.skillId, + ...(input.previewId ? { preview_id: input.previewId } : {}), + terms: { + copyright_holder: input.terms.copyrightHolder, + licensed_organization: input.terms.licensedOrganization, + year: input.terms.year, + }, + }); +} + +export function previewLibrarySkillLicense(input: { + skillId: string; + terms: LicenseDraftTermsInput; +}): Promise { + return licenseDraftRequest("/api/v2/library/license/preview", input); +} + +export function applyLibrarySkillLicense(input: { + skillId: string; + previewId: string; + terms: LicenseDraftTermsInput; +}): Promise { + return licenseDraftRequest("/api/v2/library/license/apply", input); +} + export function installLibrarySkill(input: { skillId: string; targetAgent: "codex" | "claude_code" | "opencode" | "openclaw" | "pi"; @@ -412,6 +479,10 @@ export function fetchWorkspaceMembers(): Promise { return portfolioRequest("/api/v2/settings/workspace/members"); } +export function fetchWorkspaceTeamOverview(): Promise { + return portfolioRequest("/api/v2/team"); +} + export function inviteWorkspaceMember(input: { email: string; role: WorkspaceMemberRole; @@ -727,6 +798,16 @@ export function updateProjectSkillSet(input: UpdateSkillSetRequest): Promise("/api/v2/skill-sets/update", input); } +export async function deleteProjectSkillSet(setId: string): Promise { + const response = await fetch(`${BASE}/api/v2/skill-sets/${encodeURIComponent(setId)}`, { + method: "DELETE", + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `API error: ${response.status} ${response.statusText}`); + } +} + export function deriveProjectSkillSet(input: DeriveSkillSetRequest): Promise { return portfolioRequest("/api/v2/skill-sets/derive", input); } @@ -737,6 +818,73 @@ export function exportProjectSkillSet( return portfolioRequest<{ output_path: string }>("/api/v2/skill-sets/export", input); } +export type LocalPluginExportTarget = "claude" | "openai" | "agent-plugins-v1" | "dual" | "all"; + +export function exportProjectSkillSetPlugin(input: { + set_id: string; + target: LocalPluginExportTarget; +}): Promise<{ filename: string; content_base64: string }> { + return portfolioRequest("/api/v2/skill-sets/plugin-export", input); +} + +export function previewProjectSkillSetPluginInstall( + skillSetId: string, +): Promise { + return portfolioRequest("/api/v2/skill-sets/plugin-install/preview", { + 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, + }); +} + +export function previewProjectSkillSetPack(packUrl: string): Promise<{ + packUrl: string; + preview: SkillSetPackPreview; +}> { + return portfolioRequest("/api/v2/skill-sets/packs/preview", { pack_url: packUrl }); +} + +export function importProjectSkillSetPack(input: { + packUrl: string; + expectedObjectSha256: string; +}): Promise<{ + manifest: SkillSetManifest; + sourceRevisionSha256: string; + objectSha256: string; +}> { + return portfolioRequest("/api/v2/skill-sets/packs/import", { + pack_url: input.packUrl, + expected_object_sha256: input.expectedObjectSha256, + }); +} + +export function fetchProjectSkillSetPacks(): Promise { + return portfolioRequest("/api/v2/skill-sets/packs"); +} + +export async function revokeProjectSkillSetPack(packId: string): Promise { + const response = await fetch(`${BASE}/api/v2/skill-sets/packs/${encodeURIComponent(packId)}`, { + 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); +} + export function shareProjectSkillSet(input: { skillSetId: string; mode: "reusable_unlisted" | "private_single_claim"; diff --git a/apps/local-dashboard/src/components/SetupWizard.test.tsx b/apps/local-dashboard/src/components/SetupWizard.test.tsx index 36d992bc..1cdf6ed2 100644 --- a/apps/local-dashboard/src/components/SetupWizard.test.tsx +++ b/apps/local-dashboard/src/components/SetupWizard.test.tsx @@ -44,6 +44,11 @@ import { SetupWizard } from "./SetupWizard"; const settings: DesktopSettingsResponse = { harnesses: [], + agent_skill: { + installed: false, + locations: [], + install_command: "npx skills add selftune-dev/selftune", + }, onboarding: { version: 1, completed: false, @@ -91,6 +96,22 @@ afterEach(() => { }); describe("SetupWizard", () => { + it("offers a bounded prompt when the SelfTune agent skill is not detected", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render(); + 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(); + }); + it("opens the cleanup overview after selected history is processed", () => { mutate.mockImplementation((_request, callbacks) => { callbacks.onSuccess({ @@ -104,6 +125,7 @@ describe("SetupWizard", () => { render(); fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("button", { name: /continue/i })); + fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("button", { name: /apply setup/i })); expect(navigate).toHaveBeenCalledWith("/", { replace: true }); diff --git a/apps/local-dashboard/src/components/SetupWizard.tsx b/apps/local-dashboard/src/components/SetupWizard.tsx index ec7ddb97..33508d7d 100644 --- a/apps/local-dashboard/src/components/SetupWizard.tsx +++ b/apps/local-dashboard/src/components/SetupWizard.tsx @@ -1,7 +1,9 @@ import { ArrowLeftIcon, ArrowRightIcon, + BotIcon, CheckIcon, + CopyIcon, DatabaseIcon, HeartPulseIcon, SparklesIcon, @@ -34,7 +36,15 @@ type HookHarnessId = Exclude; type Selection = Record; type HookSelection = Record; -const STEPS = ["Import history", "Install hooks", "Choose features"] as const; +const STEPS = ["Connect agent", "Import history", "Install hooks", "Choose features"] as const; + +const AGENT_SETUP_PROMPT = `Connect this AI agent to the SelfTune Mac app. + +First inspect the current agent harness and installed skills. If the SelfTune skill is not already installed, install it from selftune-dev/selftune using the standard agent-skills installer. Verify the installed skill can be discovered by this harness and report its installation path. + +Keep the setup local. Do not install hooks, import session history, enable a background service, connect a cloud account, upload data, publish a skill, change credentials, or modify an existing skill. Those choices will be handled separately in the SelfTune app. + +If this harness loads skills only at startup, tell me to start a new session after installation.`; const FEATURES: Array<{ id: OnboardingFeatureId; @@ -147,6 +157,15 @@ export function SetupWizard({ settings }: { settings: DesktopSettingsResponse }) }); } + async function copyAgentPrompt() { + try { + await navigator.clipboard.writeText(AGENT_SETUP_PROMPT); + toast.success("Agent setup prompt copied"); + } catch { + toast.error("Could not copy the agent setup prompt"); + } + } + return ( <> +