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 00000000..6adae9c9 Binary files /dev/null and b/apps/desktop/build/dmg-background.png differ diff --git a/apps/desktop/build/icon-mac.png b/apps/desktop/build/icon-mac.png new file mode 100644 index 00000000..c44fa60a Binary files /dev/null and b/apps/desktop/build/icon-mac.png differ 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 214baeab..1951c6f9 100644 Binary files a/apps/desktop/build/icon.icns and b/apps/desktop/build/icon.icns differ diff --git a/apps/desktop/desktop-builder-config.ts b/apps/desktop/desktop-builder-config.ts index 1260dd43..901c8912 100644 --- a/apps/desktop/desktop-builder-config.ts +++ b/apps/desktop/desktop-builder-config.ts @@ -7,8 +7,6 @@ import { type DesktopReleaseTrustEnvironment, } from "./src/main/desktop-protocol"; -const DEVELOPER_ID_APPLICATION_PREFIX = "Developer ID Application: "; - export interface DesktopBuilderEnvironment extends DesktopReleaseTrustEnvironment { readonly BUN_TARGET?: string; readonly DESKTOP_REQUIRE_CODE_SIGNING?: string; @@ -63,9 +61,7 @@ export function createDesktopBuilderConfig( category: "public.app-category.developer-tools", icon: "build/icon.icns", identity: - signingRequired && pins?.platform === "darwin" - ? pins.certificateAuthority.slice(DEVELOPER_ID_APPLICATION_PREFIX.length) - : undefined, + signingRequired && pins?.platform === "darwin" ? pins.certificateAuthority : undefined, target: ["dmg", "zip"], hardenedRuntime: true, gatekeeperAssess: false, @@ -73,6 +69,17 @@ export function createDesktopBuilderConfig( entitlementsInherit: "build/entitlements.mac.plist", notarize: true, }, + dmg: { + 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" }, + ], + }, win: { icon: "build/icon.ico", signtoolOptions: diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 395f4167..199da2e1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "dev": "bun run scripts/dev.ts", "build:ui": "bun run --cwd ../local-dashboard build", "build:sidecar": "bun run scripts/build-sidecar.ts", + "build:icon:mac": "bun run scripts/build-mac-icon.ts", "build": "bun run build:ui && bun run build:sidecar && electron-vite build", "package": "bun run build && electron-builder --config electron-builder.config.ts", "package:mac": "bun run build && electron-builder --mac --config electron-builder.config.ts", @@ -29,7 +30,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@selftune/api-contract": "workspace:*", "@selftune/config": "workspace:*", "@sentry/electron": "^7.8.0", "@zip.js/zip.js": "^2.8.17", diff --git a/apps/desktop/scripts/build-mac-icon.ts b/apps/desktop/scripts/build-mac-icon.ts new file mode 100644 index 00000000..08ebe12d --- /dev/null +++ b/apps/desktop/scripts/build-mac-icon.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const source = new URL("../build/icon-mac.png", import.meta.url).pathname; +const destination = new URL("../build/icon.icns", import.meta.url).pathname; +const workDirectory = await mkdtemp(join(tmpdir(), "selftune-mac-icon-")); + +const representations = [ + { type: "icp4", size: 16 }, + { type: "icp5", size: 32 }, + { type: "icp6", size: 64 }, + { type: "ic07", size: 128 }, + { type: "ic08", size: 256 }, + { type: "ic09", size: 512 }, + { type: "ic10", size: 1024 }, +] as const; + +try { + const chunks: Buffer[] = []; + + for (const representation of representations) { + const pngPath = join(workDirectory, `${representation.size}.png`); + const process = Bun.spawn( + [ + "/usr/bin/sips", + "-z", + `${representation.size}`, + `${representation.size}`, + source, + "--out", + pngPath, + ], + { stdout: "ignore", stderr: "inherit" }, + ); + if ((await process.exited) !== 0) { + throw new Error(`Failed to render the ${representation.size}px macOS icon.`); + } + + const png = await readFile(pngPath); + const header = Buffer.alloc(8); + header.write(representation.type, 0, 4, "ascii"); + header.writeUInt32BE(png.length + header.length, 4); + chunks.push(header, png); + } + + const body = Buffer.concat(chunks); + const header = Buffer.alloc(8); + header.write("icns", 0, 4, "ascii"); + header.writeUInt32BE(body.length + header.length, 4); + await writeFile(destination, Buffer.concat([header, body])); + console.log(`Built ${destination}`); +} finally { + await rm(workDirectory, { recursive: true, force: true }); +} diff --git a/apps/desktop/scripts/build-sidecar.ts b/apps/desktop/scripts/build-sidecar.ts index d8f2836c..53c5f195 100644 --- a/apps/desktop/scripts/build-sidecar.ts +++ b/apps/desktop/scripts/build-sidecar.ts @@ -12,8 +12,6 @@ import { } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; -import { isSigningMutableRuntimePath } from "../src/main/runtime-integrity"; - const desktopRoot = resolve(import.meta.dir, ".."); const selfTuneRoot = resolve(desktopRoot, "../.."); const resourceRoot = join(desktopRoot, "resources/selftune"); @@ -52,15 +50,14 @@ async function listFiles(directory: string): Promise { 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 ( <> +
+

Manual fallback

+ + {settings.agent_skill.install_command} + +
+ + )} + +

+ 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;