diff --git a/.github/workflows/agent-framework-conformance.yml b/.github/workflows/agent-framework-conformance.yml index 0aa83d8c..69e6cbc9 100644 --- a/.github/workflows/agent-framework-conformance.yml +++ b/.github/workflows/agent-framework-conformance.yml @@ -8,6 +8,7 @@ on: - "packages/cli/src/proof-carrying-change.ts" - "packages/cli/src/utils/json-schema-contract.ts" - "packages/cli/src/utils/workspace-paths.ts" + - "packages/cli/src/__tests__/agent-framework-*.test.ts" - "packages/cli/src/__tests__/agent-framework-lifecycle.test.ts" - "packages/cli/src/__tests__/agent-framework-admission-candidate.test.ts" - "packages/cli/src/__tests__/agent-framework-registry.test.ts" @@ -26,31 +27,89 @@ on: - "packages/cli/scripts/promote-agent-framework-release-admission.ts" - "packages/cli/package.json" - "packages/cli/contracts/agent-framework-capabilities.v1.json" - - "packages/cli/contracts/workspace-intelligence/agent-framework-*.v1.json" + - "packages/cli/contracts/workspace-intelligence/agent-framework-*.v*.json" - "contracts/agent-framework-capabilities.v1.json" - - "contracts/workspace-intelligence/agent-framework-*.v1.json" + - "contracts/workspace-intelligence/agent-framework-*.v*.json" - ".github/workflows/agent-framework-conformance.yml" - ".github/workflows/agent-framework-version-discovery.yml" - ".github/agent-framework-version-update.md" - "package-lock.json" workflow_dispatch: + inputs: + qualification_mode: + description: "Fast Linux validation or complete release qualification" + required: true + default: full + type: choice + options: + - fast + - full permissions: contents: read + pull-requests: read concurrency: group: agent-framework-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + detect-changes: + name: Detect affected adapter families + runs-on: ubuntu-latest + outputs: + microsoft: ${{ steps.filter.outputs.microsoft }} + openai: ${{ steps.filter.outputs.openai }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 + id: filter + with: + filters: | + microsoft: + - 'packages/cli/src/agent-frameworks/**' + - '!packages/cli/src/agent-frameworks/adapters/openai-agents/**' + - 'packages/cli/src/__tests__/microsoft-agent-framework-adapter.test.ts' + - 'packages/cli/src/__tests__/agent-framework-*.test.ts' + - 'packages/cli/scripts/smoke-microsoft-agent-framework-adapter.ts' + - 'packages/cli/src/contracts/agent-framework-contract.ts' + - 'packages/cli/src/proof-carrying-change.ts' + - 'packages/cli/src/utils/json-schema-contract.ts' + - 'packages/cli/src/utils/workspace-paths.ts' + - 'packages/cli/scripts/verify-agent-framework-conformance.ts' + - 'packages/cli/scripts/promote-agent-framework-release-admission.ts' + - 'packages/cli/package.json' + - 'package-lock.json' + - '.github/workflows/agent-framework-conformance.yml' + openai: + - 'packages/cli/src/agent-frameworks/**' + - '!packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/**' + - 'packages/cli/src/__tests__/openai-agents-*.test.ts' + - 'packages/cli/src/__tests__/agent-framework-*.test.ts' + - 'packages/cli/scripts/smoke-openai-agents-adapter.ts' + - 'packages/cli/src/contracts/agent-framework-contract.ts' + - 'packages/cli/src/proof-carrying-change.ts' + - 'packages/cli/src/utils/json-schema-contract.ts' + - 'packages/cli/src/utils/workspace-paths.ts' + - 'packages/cli/scripts/verify-agent-framework-conformance.ts' + - 'packages/cli/scripts/promote-agent-framework-release-admission.ts' + - 'packages/cli/package.json' + - 'package-lock.json' + - '.github/workflows/agent-framework-conformance.yml' + microsoft-agent-framework: name: Microsoft Agent Framework · ${{ matrix.runtime }} · ${{ matrix.os }} + needs: detect-changes + if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.microsoft == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.qualification_mode == 'full' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest"]') }} runtime: [python, dotnet] steps: @@ -102,12 +161,14 @@ jobs: openai-agents: name: OpenAI Agents SDK · ${{ matrix.runtime }} · ${{ matrix.os }} + needs: detect-changes + if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.openai == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.qualification_mode == 'full' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest"]') }} runtime: [python, typescript] steps: @@ -152,7 +213,7 @@ jobs: admit-matrix: name: Admit complete adapter matrix - if: always() + if: always() && github.event_name == 'workflow_dispatch' && inputs.qualification_mode == 'full' && needs['microsoft-agent-framework'].result == 'success' && needs['openai-agents'].result == 'success' needs: [microsoft-agent-framework, openai-agents] runs-on: ubuntu-latest timeout-minutes: 10 @@ -227,8 +288,9 @@ jobs: run: >- npm exec tsx -- scripts/promote-agent-framework-release-admission.ts --candidate test-results/agent-framework-admission/agent-framework-admission-candidate.json - --output src/agent-frameworks/release-admissions.v1.json + --output src/agent-frameworks/release-admissions.v2.json --repository "${{ github.repository }}" + --source-commit "${{ github.sha }}" --workflow-run-id "${{ github.run_id }}" - name: Update the existing automation branch @@ -236,13 +298,13 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - if git diff --quiet -- packages/cli/src/agent-frameworks/release-admissions.v1.json; then + if git diff --quiet -- packages/cli/src/agent-frameworks/release-admissions.v2.json; then echo "Release admission already matches this verified matrix." exit 0 fi gh auth setup-git git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- packages/cli/src/agent-frameworks/release-admissions.v1.json + git add -- packages/cli/src/agent-frameworks/release-admissions.v2.json git commit --no-verify -m "chore(agent-frameworks): bind verified release admission" git push --no-verify origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/contracts/agent-framework-capabilities.v1.json b/contracts/agent-framework-capabilities.v1.json index 120e503b..5779bdef 100644 --- a/contracts/agent-framework-capabilities.v1.json +++ b/contracts/agent-framework-capabilities.v1.json @@ -425,10 +425,10 @@ }, "publication": { "manifestSchema": "contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json", - "conformanceSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v1.json", + "conformanceSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", "changePlanSchema": "contracts/workspace-intelligence/agent-framework-change-plan.v1.json", "ownershipReceiptSchema": "contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json", - "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json", + "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", "requiredChecks": [ "manifest-schema", "protocol-version", diff --git a/contracts/extension-cli-compatibility.v1.json b/contracts/extension-cli-compatibility.v1.json index dd376316..f2ab8fa2 100644 --- a/contracts/extension-cli-compatibility.v1.json +++ b/contracts/extension-cli-compatibility.v1.json @@ -100,10 +100,12 @@ "agentCustomizationPackCapabilities": "workspai-agent-customization-pack-capabilities.v1", "agentFrameworkCapabilities": "workspai.agent-framework-capabilities.v1", "agentFrameworkAdapterManifest": "workspai.agent-framework-adapter-manifest.v1", - "agentFrameworkConformanceReport": "workspai.agent-framework-conformance-report.v1", + "agentFrameworkConformanceReport": "workspai.agent-framework-conformance-report.v2", + "agentFrameworkConformanceReportLegacy": "workspai.agent-framework-conformance-report.v1", "agentFrameworkChangePlan": "workspai.agent-framework-change-plan.v1", "agentFrameworkOwnershipReceipt": "workspai.agent-framework-ownership-receipt.v1", - "agentFrameworkAdmissionCandidate": "workspai.agent-framework-admission-candidate.v1", + "agentFrameworkAdmissionCandidate": "workspai.agent-framework-admission-candidate.v2", + "agentFrameworkAdmissionCandidateLegacy": "workspai.agent-framework-admission-candidate.v1", "agentCustomizationPackReport": "rapidkit-agent-customization-pack.v1", "agentReportsIndex": "rapidkit-agent-reports-index.v1", "workspaceOperationalSkill": "workspace-operational-skill.v1", diff --git a/contracts/published-contract-catalog.v1.json b/contracts/published-contract-catalog.v1.json index 092800c2..bdb3e7bb 100644 --- a/contracts/published-contract-catalog.v1.json +++ b/contracts/published-contract-catalog.v1.json @@ -507,6 +507,11 @@ "publication": "json-schema" }, "agentFrameworkConformanceReport": { + "schemaVersion": "workspai.agent-framework-conformance-report.v2", + "contractPath": "contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", + "publication": "json-schema" + }, + "agentFrameworkConformanceReportLegacy": { "schemaVersion": "workspai.agent-framework-conformance-report.v1", "contractPath": "contracts/workspace-intelligence/agent-framework-conformance-report.v1.json", "publication": "json-schema" @@ -522,6 +527,11 @@ "publication": "json-schema" }, "agentFrameworkAdmissionCandidate": { + "schemaVersion": "workspai.agent-framework-admission-candidate.v2", + "contractPath": "contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", + "publication": "json-schema" + }, + "agentFrameworkAdmissionCandidateLegacy": { "schemaVersion": "workspai.agent-framework-admission-candidate.v1", "contractPath": "contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json", "publication": "json-schema" diff --git a/contracts/workspace-intelligence-architecture.v1.json b/contracts/workspace-intelligence-architecture.v1.json index 8be94c96..cacb07e3 100644 --- a/contracts/workspace-intelligence-architecture.v1.json +++ b/contracts/workspace-intelligence-architecture.v1.json @@ -568,10 +568,10 @@ "path": "contracts/agent-framework-capabilities.v1.json" }, "adapterManifestSchema": "contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json", - "conformanceReportSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v1.json", + "conformanceReportSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", "changePlanSchema": "contracts/workspace-intelligence/agent-framework-change-plan.v1.json", "ownershipReceiptSchema": "contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json", - "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json", + "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", "rules": [ "Create kits and existing-project attachment must consume the same framework adapter contract.", "A framework adapter owns runtime integration, never canonical Model, Graph, Goal, PCC, or verification truth.", diff --git a/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json b/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json new file mode 100644 index 00000000..f61a9870 --- /dev/null +++ b/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json @@ -0,0 +1,217 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://workspai.dev/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", + "title": "Workspai Agent Framework Admission Candidate v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocolVersion", + "generatedAt", + "sourceCommit", + "cliVersion", + "reviewStatus", + "adapters", + "verdict", + "blockers" + ], + "properties": { + "schemaVersion": { + "const": "workspai.agent-framework-admission-candidate.v2" + }, + "protocolVersion": { + "const": "workspai.agent-framework-adapter-protocol.v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "sourceCommit": { + "type": "string", + "pattern": "^[a-f0-9]{40}(?:[a-f0-9]{24})?$" + }, + "cliVersion": { + "type": "string", + "minLength": 1 + }, + "reviewStatus": { + "const": "pending" + }, + "adapters": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "manifestSha256", + "implementationSha256ByPlatform", + "framework", + "lanes" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "implementationSha256ByPlatform": { + "type": "object", + "additionalProperties": false, + "required": [ + "linux", + "darwin", + "win32" + ], + "properties": { + "linux": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "darwin": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "win32": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "framework": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + } + } + }, + "lanes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "runtime", + "runtimeVersion", + "frameworkVersion", + "report", + "evidence" + ], + "properties": { + "platform": { + "enum": [ + "linux", + "darwin", + "win32" + ] + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "runtimeVersion": { + "type": "string", + "minLength": 1 + }, + "frameworkVersion": { + "type": "string", + "minLength": 1 + }, + "report": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "^[A-Za-z]:[\\/]" + }, + { + "pattern": "(^|[\\/])\\.\\.([\\/]|$)" + } + ] + } + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "evidence": { + "type": "array", + "minItems": 18, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "^[A-Za-z]:[\\/]" + }, + { + "pattern": "(^|[\\/])\\.\\.([\\/]|$)" + } + ] + } + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + } + } + } + } + } + } + }, + "verdict": { + "const": "admitted" + }, + "blockers": { + "type": "array", + "maxItems": 0 + } + } +} diff --git a/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json b/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json new file mode 100644 index 00000000..a7a2b2ee --- /dev/null +++ b/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json @@ -0,0 +1,274 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://workspai.dev/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", + "title": "Workspai Agent Framework Conformance Report v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocolVersion", + "generatedAt", + "adapter", + "frameworkVersion", + "cliVersion", + "environment", + "checks", + "summary", + "verdict", + "blockers", + "limitations" + ], + "properties": { + "schemaVersion": { + "const": "workspai.agent-framework-conformance-report.v2" + }, + "protocolVersion": { + "const": "workspai.agent-framework-adapter-protocol.v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "adapter": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "manifestSha256", + "implementationSha256" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "implementationSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "frameworkVersion": { + "type": "string", + "minLength": 1 + }, + "cliVersion": { + "type": "string", + "minLength": 1 + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "architecture", + "runtime", + "runtimeVersion" + ], + "properties": { + "platform": { + "enum": [ + "linux", + "darwin", + "win32" + ] + }, + "architecture": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "runtimeVersion": { + "type": "string", + "minLength": 1 + } + } + }, + "checks": { + "type": "array", + "minItems": 18, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "status", + "required", + "summary", + "evidencePaths", + "durationMs" + ], + "properties": { + "id": { + "enum": [ + "manifest-schema", + "protocol-version", + "capability-truth", + "detection-positive", + "detection-negative", + "scaffold-plan-safety", + "attach-plan-safety", + "managed-file-ownership", + "user-file-preservation", + "path-containment", + "secret-non-persistence", + "context-generation-binding", + "mutation-gateway", + "verification-binding", + "failure-isolation", + "idempotency", + "offline-posture", + "cross-platform-paths" + ] + }, + "status": { + "enum": [ + "passed", + "failed", + "skipped" + ] + }, + "required": { + "type": "boolean" + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "evidencePaths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "^[A-Za-z]:[\\\\/]" + }, + { + "pattern": "(^|[\\\\/])\\.\\.([\\\\/]|$)" + } + ] + } + } + }, + "durationMs": { + "type": "number", + "minimum": 0 + } + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "passed", + "failed", + "skipped", + "required" + ], + "properties": { + "passed": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "integer", + "minimum": 18 + } + } + }, + "verdict": { + "enum": [ + "admitted", + "blocked" + ] + }, + "blockers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 0, + "uniqueItems": true + }, + "limitations": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 0, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "verdict": { + "const": "blocked" + } + }, + "required": [ + "verdict" + ] + }, + "then": { + "properties": { + "blockers": { + "type": "array", + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "verdict": { + "const": "admitted" + } + }, + "required": [ + "verdict" + ] + }, + "then": { + "properties": { + "blockers": { + "type": "array", + "maxItems": 0 + } + } + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 8c582347..1a262eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workspai", - "version": "0.75.2", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workspai", - "version": "0.75.2", + "version": "0.76.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -6600,7 +6600,7 @@ }, "packages/cli": { "name": "workspai", - "version": "0.75.2", + "version": "0.76.0", "license": "MIT", "dependencies": { "@clack/prompts": "^0.9.1", @@ -6809,10 +6809,10 @@ "license": "MIT" }, "packages/wspai": { - "version": "0.75.2", + "version": "0.76.0", "license": "MIT", "dependencies": { - "workspai": "0.75.2" + "workspai": "0.76.0" }, "bin": { "wspai": "bin/wspai.js" diff --git a/package.json b/package.json index a15b6b69..0358d847 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "workspai", "private": true, - "version": "0.75.2", + "version": "0.76.0", "description": "Workspai monorepo for Workspace Intelligence packages.", "author": "Chistiq", "license": "MIT", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 56332ce3..23a07d02 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,28 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.76.0] - 2026-09-20 + ### Added -- Implemented preview OpenAI Agents SDK adapters for Python `0.22.2` and - TypeScript `@openai/agents` `0.18.0` with independent framework/runtime - selection, credentialless conformance lanes, and Create kit ids - `agent.openai.python` / `agent.openai.typescript`. Reviewed release admission - from matrix run 35357989405 on `8eb1308` now opens Create and Attach for those - explicit kit ids and `--framework openai-agents`. Adapters remain `preview`. - `--runtime python` without `--framework` requires an explicit framework because - Microsoft and OpenAI are both admitted. +- Implemented OpenAI Agents SDK adapters for Python `0.22.2` and TypeScript + `@openai/agents` `0.18.0` with independent framework/runtime selection, + credentialless conformance lanes, and Create kit ids `agent.openai.python` / + `agent.openai.typescript`. Adapters are labeled `stable`. Create and Attach + stay fail-closed until the reviewed v2 inventory is promoted from Linux, + macOS, and Windows evidence for this release SHA. + `--runtime python` without `--framework` requires an explicit framework + because Microsoft and OpenAI are both published. Handoffs, MCP, sessions, and + voice stay unsupported. ### Changed +- Conformance reports and admission candidates now retain a per-platform + semantic implementation digest as audit provenance in addition to the + adapter manifest digest. Runtime admission remains bound to the adapter + version and manifest, framework baseline, runtime, and platform matrix; a + routine implementation edit no longer requires a manually refreshed digest. + Pull requests validate only affected families on Linux, while manual `full` + qualification retains the complete twelve-lane release gate. V1 evidence + cannot authorize Create or Attach. - Generated OpenAI and Microsoft starters inspect admitted Workspai context through allowlisted read-only tools instead of pasting the JSON into instructions. Live entrypoints stream stdout, accept a prompt from argv or stdin, and redact Azure-shaped secrets in addition to `sk-` values. Microsoft Python adds a credentialless LocalChatClient loop when the - framework is installed. Adapters remain `preview`. + framework is installed. Microsoft adapters remain `preview`. ### Fixed +- `agent framework list --json` includes each adapter's `stability`. - Microsoft .NET context loader assigns `FileAttributes` before `GetAttributes` so CS0165 does not fail the restore/build lane. - OpenAI Python tools pass `failure_error_function=None`, and OpenAI @@ -61,9 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `pre-push`. - Isolated the workspace-intelligence adversarial script the same way; it runs during `quality:push` and was still inheriting husky Git env. -- Kept published `agent framework list` preview OpenAI adapters visible before - release admission; the enterprise package smoke now requires the four - reviewed Microsoft and OpenAI adapters. +- Kept published `agent framework list` OpenAI adapters visible; the enterprise + package smoke requires OpenAI to stay labeled `stable` and requires the exact + admitted set after promotion. - Invoked npm through `npm_execpath` in the OpenAI TypeScript conformance smoke so Windows does not `spawn EINVAL` on `npm.cmd`. - Ran the OpenAI TypeScript conformance install/test inside the generated @@ -76,12 +88,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 require `project-context-agent.v1` JSON, and reject escaped symlinks without echoing file contents. Credentialless SDK checks now call the generated `buildAgent` / `build_agent` seam with official ScriptedModel - doubles. Python `pyproject.toml` is pip-editable. OpenAI adapters remain - preview after the reviewed admission; changing that label would change the - manifest digest and require a new matrix. Context-path containment + doubles. Python `pyproject.toml` is pip-editable. OpenAI adapters are labeled + `stable`; binding the new digest still requires the next green matrix. + Context-path containment is a bounded fd read after `O_NOFOLLOW` when available, not a TOCTOU-free walk. Generator coverage remains in the Vitest gate alongside the OpenAI adapter sources. +- Bound Agent Framework qualification to deterministic semantic implementation + digests in conformance-report v2, admission-candidate v2, and release-admission + v2. Create and Attach are admitted by the exact Linux/macOS/Windows candidate + from run `35483229302` on source commit `2b25305c`. +- Preflighted Python and `uv` before lifecycle mutation, restricted dependency + installation to the owned virtual environment, preserved scoped run evidence + timestamps, and rejected PCC goal binding after unreceipted filesystem drift. ## [0.75.2] - 2026-09-14 diff --git a/packages/cli/RELEASE_NOTES.md b/packages/cli/RELEASE_NOTES.md index d6aea46b..c05eee3c 100644 --- a/packages/cli/RELEASE_NOTES.md +++ b/packages/cli/RELEASE_NOTES.md @@ -5,7 +5,46 @@ > `rapidkit` commands and `.rapidkit` paths. Use the [CLI README](./README.md) and > [Command Reference](./docs/commands-reference.md) for current usage. -## Latest Release: v0.75.2 (September 14, 2026) +## Release Candidate: v0.76.0 (September 20, 2026) + +### Stable OpenAI Agents SDK Adapters + +Workspai 0.76.0 labels OpenAI Agents SDK starters stable for Python 0.22.2 and +TypeScript 0.18.0. Create and Attach are admitted by the reviewed v2 inventory +promoted from the Linux, macOS, and Windows release matrix. + +**What's New:** + +- Label `agent.openai.python` and `agent.openai.typescript` `stable`. +- Bind runtime authorization to the reviewed manifest, framework baseline, + runtime, and platform matrix; retain semantic implementation digests as + qualification provenance. +- Run affected-family Linux conformance on pull requests and reserve the full + twelve-lane matrix for explicit release qualification. +- Require `--framework` when `--runtime python` is shared; the CLI does not + guess. +- Keep handoffs, MCP, sessions, voice, sandbox, and approval loops + unsupported. + +**Compatibility:** Existing commands, schema versions, and fail-closed +framework admission remain supported. Microsoft adapters stay `preview`. +Independent Graph work is not this CLI release. + +**Publication status:** Qualified on September 20, 2026 by Agent Framework +Matrix run `35483229302`; npm publication follows the post-promotion CI gate. + +**Install:** + +```bash +npm install -g workspai@0.76.0 +workspai --version +``` + +[Full Release Notes](https://github.com/chistiq/workspai/blob/v0.76.0/packages/cli/releases/RELEASE_NOTES_v0.76.0.md) + +--- + +## v0.75.2 (September 14, 2026) ### Reviewed Framework Pins Without Automatic Promotion diff --git a/packages/cli/contracts/agent-framework-capabilities.v1.json b/packages/cli/contracts/agent-framework-capabilities.v1.json index 120e503b..5779bdef 100644 --- a/packages/cli/contracts/agent-framework-capabilities.v1.json +++ b/packages/cli/contracts/agent-framework-capabilities.v1.json @@ -425,10 +425,10 @@ }, "publication": { "manifestSchema": "contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json", - "conformanceSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v1.json", + "conformanceSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", "changePlanSchema": "contracts/workspace-intelligence/agent-framework-change-plan.v1.json", "ownershipReceiptSchema": "contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json", - "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json", + "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", "requiredChecks": [ "manifest-schema", "protocol-version", diff --git a/packages/cli/contracts/extension-cli-compatibility.v1.json b/packages/cli/contracts/extension-cli-compatibility.v1.json index dd376316..f2ab8fa2 100644 --- a/packages/cli/contracts/extension-cli-compatibility.v1.json +++ b/packages/cli/contracts/extension-cli-compatibility.v1.json @@ -100,10 +100,12 @@ "agentCustomizationPackCapabilities": "workspai-agent-customization-pack-capabilities.v1", "agentFrameworkCapabilities": "workspai.agent-framework-capabilities.v1", "agentFrameworkAdapterManifest": "workspai.agent-framework-adapter-manifest.v1", - "agentFrameworkConformanceReport": "workspai.agent-framework-conformance-report.v1", + "agentFrameworkConformanceReport": "workspai.agent-framework-conformance-report.v2", + "agentFrameworkConformanceReportLegacy": "workspai.agent-framework-conformance-report.v1", "agentFrameworkChangePlan": "workspai.agent-framework-change-plan.v1", "agentFrameworkOwnershipReceipt": "workspai.agent-framework-ownership-receipt.v1", - "agentFrameworkAdmissionCandidate": "workspai.agent-framework-admission-candidate.v1", + "agentFrameworkAdmissionCandidate": "workspai.agent-framework-admission-candidate.v2", + "agentFrameworkAdmissionCandidateLegacy": "workspai.agent-framework-admission-candidate.v1", "agentCustomizationPackReport": "rapidkit-agent-customization-pack.v1", "agentReportsIndex": "rapidkit-agent-reports-index.v1", "workspaceOperationalSkill": "workspace-operational-skill.v1", diff --git a/packages/cli/contracts/published-contract-catalog.v1.json b/packages/cli/contracts/published-contract-catalog.v1.json index 092800c2..bdb3e7bb 100644 --- a/packages/cli/contracts/published-contract-catalog.v1.json +++ b/packages/cli/contracts/published-contract-catalog.v1.json @@ -507,6 +507,11 @@ "publication": "json-schema" }, "agentFrameworkConformanceReport": { + "schemaVersion": "workspai.agent-framework-conformance-report.v2", + "contractPath": "contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", + "publication": "json-schema" + }, + "agentFrameworkConformanceReportLegacy": { "schemaVersion": "workspai.agent-framework-conformance-report.v1", "contractPath": "contracts/workspace-intelligence/agent-framework-conformance-report.v1.json", "publication": "json-schema" @@ -522,6 +527,11 @@ "publication": "json-schema" }, "agentFrameworkAdmissionCandidate": { + "schemaVersion": "workspai.agent-framework-admission-candidate.v2", + "contractPath": "contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", + "publication": "json-schema" + }, + "agentFrameworkAdmissionCandidateLegacy": { "schemaVersion": "workspai.agent-framework-admission-candidate.v1", "contractPath": "contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json", "publication": "json-schema" diff --git a/packages/cli/contracts/workspace-intelligence-architecture.v1.json b/packages/cli/contracts/workspace-intelligence-architecture.v1.json index 8be94c96..cacb07e3 100644 --- a/packages/cli/contracts/workspace-intelligence-architecture.v1.json +++ b/packages/cli/contracts/workspace-intelligence-architecture.v1.json @@ -568,10 +568,10 @@ "path": "contracts/agent-framework-capabilities.v1.json" }, "adapterManifestSchema": "contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json", - "conformanceReportSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v1.json", + "conformanceReportSchema": "contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", "changePlanSchema": "contracts/workspace-intelligence/agent-framework-change-plan.v1.json", "ownershipReceiptSchema": "contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json", - "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json", + "admissionCandidateSchema": "contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", "rules": [ "Create kits and existing-project attachment must consume the same framework adapter contract.", "A framework adapter owns runtime integration, never canonical Model, Graph, Goal, PCC, or verification truth.", diff --git a/packages/cli/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json b/packages/cli/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json new file mode 100644 index 00000000..f61a9870 --- /dev/null +++ b/packages/cli/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json @@ -0,0 +1,217 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://workspai.dev/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json", + "title": "Workspai Agent Framework Admission Candidate v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocolVersion", + "generatedAt", + "sourceCommit", + "cliVersion", + "reviewStatus", + "adapters", + "verdict", + "blockers" + ], + "properties": { + "schemaVersion": { + "const": "workspai.agent-framework-admission-candidate.v2" + }, + "protocolVersion": { + "const": "workspai.agent-framework-adapter-protocol.v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "sourceCommit": { + "type": "string", + "pattern": "^[a-f0-9]{40}(?:[a-f0-9]{24})?$" + }, + "cliVersion": { + "type": "string", + "minLength": 1 + }, + "reviewStatus": { + "const": "pending" + }, + "adapters": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "manifestSha256", + "implementationSha256ByPlatform", + "framework", + "lanes" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "implementationSha256ByPlatform": { + "type": "object", + "additionalProperties": false, + "required": [ + "linux", + "darwin", + "win32" + ], + "properties": { + "linux": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "darwin": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "win32": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "framework": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + } + } + }, + "lanes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "runtime", + "runtimeVersion", + "frameworkVersion", + "report", + "evidence" + ], + "properties": { + "platform": { + "enum": [ + "linux", + "darwin", + "win32" + ] + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "runtimeVersion": { + "type": "string", + "minLength": 1 + }, + "frameworkVersion": { + "type": "string", + "minLength": 1 + }, + "report": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "^[A-Za-z]:[\\/]" + }, + { + "pattern": "(^|[\\/])\\.\\.([\\/]|$)" + } + ] + } + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "evidence": { + "type": "array", + "minItems": 18, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "^[A-Za-z]:[\\/]" + }, + { + "pattern": "(^|[\\/])\\.\\.([\\/]|$)" + } + ] + } + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + } + } + } + } + } + } + }, + "verdict": { + "const": "admitted" + }, + "blockers": { + "type": "array", + "maxItems": 0 + } + } +} diff --git a/packages/cli/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json b/packages/cli/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json new file mode 100644 index 00000000..a7a2b2ee --- /dev/null +++ b/packages/cli/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json @@ -0,0 +1,274 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://workspai.dev/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json", + "title": "Workspai Agent Framework Conformance Report v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocolVersion", + "generatedAt", + "adapter", + "frameworkVersion", + "cliVersion", + "environment", + "checks", + "summary", + "verdict", + "blockers", + "limitations" + ], + "properties": { + "schemaVersion": { + "const": "workspai.agent-framework-conformance-report.v2" + }, + "protocolVersion": { + "const": "workspai.agent-framework-adapter-protocol.v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "adapter": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "manifestSha256", + "implementationSha256" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "implementationSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "frameworkVersion": { + "type": "string", + "minLength": 1 + }, + "cliVersion": { + "type": "string", + "minLength": 1 + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "architecture", + "runtime", + "runtimeVersion" + ], + "properties": { + "platform": { + "enum": [ + "linux", + "darwin", + "win32" + ] + }, + "architecture": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "runtimeVersion": { + "type": "string", + "minLength": 1 + } + } + }, + "checks": { + "type": "array", + "minItems": 18, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "status", + "required", + "summary", + "evidencePaths", + "durationMs" + ], + "properties": { + "id": { + "enum": [ + "manifest-schema", + "protocol-version", + "capability-truth", + "detection-positive", + "detection-negative", + "scaffold-plan-safety", + "attach-plan-safety", + "managed-file-ownership", + "user-file-preservation", + "path-containment", + "secret-non-persistence", + "context-generation-binding", + "mutation-gateway", + "verification-binding", + "failure-isolation", + "idempotency", + "offline-posture", + "cross-platform-paths" + ] + }, + "status": { + "enum": [ + "passed", + "failed", + "skipped" + ] + }, + "required": { + "type": "boolean" + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "evidencePaths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "^[A-Za-z]:[\\\\/]" + }, + { + "pattern": "(^|[\\\\/])\\.\\.([\\\\/]|$)" + } + ] + } + } + }, + "durationMs": { + "type": "number", + "minimum": 0 + } + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "passed", + "failed", + "skipped", + "required" + ], + "properties": { + "passed": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "integer", + "minimum": 18 + } + } + }, + "verdict": { + "enum": [ + "admitted", + "blocked" + ] + }, + "blockers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 0, + "uniqueItems": true + }, + "limitations": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 0, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "verdict": { + "const": "blocked" + } + }, + "required": [ + "verdict" + ] + }, + "then": { + "properties": { + "blockers": { + "type": "array", + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "verdict": { + "const": "admitted" + } + }, + "required": [ + "verdict" + ] + }, + "then": { + "properties": { + "blockers": { + "type": "array", + "maxItems": 0 + } + } + } + } + ] +} diff --git a/packages/cli/docs/agent-framework-adapters.md b/packages/cli/docs/agent-framework-adapters.md index 913b397c..6b276dc6 100644 --- a/packages/cli/docs/agent-framework-adapters.md +++ b/packages/cli/docs/agent-framework-adapters.md @@ -12,29 +12,31 @@ existing project ---/ | -> Workspai Context, Goal, PCC, and Verify ``` -Microsoft Agent Framework is the first release-admitted implementation of this +Microsoft Agent Framework is the first adapter implementation of this foundation. Its Python and .NET adapters are intentionally separate because their package graphs, runtime requirements, entrypoints, and verification -commands differ. Both are available for governed attachment after their exact -manifest digests pass the required Linux, macOS, and Windows conformance lanes -and are bound into the reviewed release-admission inventory. +commands differ. Both become available for governed attachment only after the +exact manifest and release baseline pass the required Linux, macOS, and Windows +conformance lanes and are bound into the reviewed release-admission inventory. +Per-platform semantic implementation digests accompany that evidence for +traceability, but do not act as runtime authorization locks. OpenAI Agents SDK adapters for Python and TypeScript are implemented in the -same registry and lifecycle. They are not listed as Create/Attach kits until -their complete cross-platform matrix is reviewed into that inventory. Public +same registry and lifecycle. They become Create/Attach kits only after their +complete cross-platform matrix is reviewed into that inventory. Public commands fail closed rather than silently substituting Microsoft, OpenAI, or another runtime. ## Published contracts -| Contract | Purpose | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| `contracts/agent-framework-capabilities.v1.json` | Normative ownership, capability, lifecycle, security, versioning, and admission rules | -| `contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json` | JSON Schema for one framework/version adapter declaration | -| `contracts/workspace-intelligence/agent-framework-conformance-report.v1.json` | JSON Schema for reproducible adapter admission evidence | -| `contracts/workspace-intelligence/agent-framework-change-plan.v1.json` | Portable, mutation-free scaffold or attach plan returned to the host | -| `contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json` | Hash-bound proof of the files Workspai may safely refresh | -| `contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json` | Review-pending, digest-bound index of the complete cross-platform evidence matrix | +| Contract | Purpose | +| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `contracts/agent-framework-capabilities.v1.json` | Normative ownership, capability, lifecycle, security, versioning, and admission rules | +| `contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json` | JSON Schema for one framework/version adapter declaration | +| `contracts/workspace-intelligence/agent-framework-conformance-report.v2.json` | JSON Schema binding reproducible adapter evidence to manifest and implementation digests | +| `contracts/workspace-intelligence/agent-framework-change-plan.v1.json` | Portable, mutation-free scaffold or attach plan returned to the host | +| `contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json` | Hash-bound proof of the files Workspai may safely refresh | +| `contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json` | Review-pending, digest-bound index of the complete cross-platform evidence matrix | The schemas are also discoverable through `contracts/published-contract-catalog.v1.json` and the extension compatibility @@ -227,22 +229,28 @@ workspace profile. A later adapter-manifest change must not hide a kit. Create and Attach still refuse a kit whose adapter is not release-admitted; visibility in the picker is not permission to write a blocked adapter. Attach still requires `--framework openai-agents` when the -runtime is shared. Adapters remain `preview`; handoffs, MCP, sessions, voice, -sandbox, and approval loops stay unsupported. Changing `preview` to `stable` -would change the manifest digest and require a new matrix. - -A path-filtered twelve-lane adapter matrix compiles the generated Microsoft -Python/.NET and OpenAI Python/TypeScript projects on Linux, macOS, and Windows. Every lane records all 18 mandatory -checks, the exact runtime and framework baseline, a digest of the adapter -manifest, and one bounded evidence file per check. Reports are retained as CI -artifacts for review. A final job validates every evidence path and admits the -matrix only when all three operating-system lanes pass for every built-in adapter. +runtime is shared. OpenAI adapters are labeled `stable`; release admission +remains blocked until the exact v2 cross-platform candidate is promoted. +Handoffs, MCP, sessions, voice, sandbox, and approval loops stay unsupported. +Microsoft adapters remain `preview`. + +A path-filtered PR gate compiles only the affected Microsoft or OpenAI adapter +family on Linux. Shared lifecycle, security, registry, admission, and contract +changes select both families; documentation-only edits do not run adapter +conformance. The complete twelve-lane matrix is an explicit release- +qualification operation: it compiles Microsoft Python/.NET and OpenAI +Python/TypeScript on Linux, macOS, and Windows. Every full-qualification lane +records all 18 mandatory checks, the exact runtime and framework baseline, +digests of the adapter manifest and semantic implementation, and one bounded +evidence file per check. Reports are retained as CI artifacts for review. A +final job validates every evidence path and emits an admission candidate only +when all three operating-system lanes pass for every built-in adapter. Python conformance is pinned to 3.10.11, the final Python 3.10 release with cross-platform binary installers; this provides one reproducible minimum-runtime baseline while the adapter continues to declare Python `>=3.10` support. After verification, CI emits one admission-candidate artifact. It binds the -source commit, CLI version, adapter-manifest digests, lane reports, and every +source commit, CLI version, adapter manifest and implementation digests, lane reports, and every evidence file by SHA-256. Its status is always `pending`: successful CI produces reviewable evidence, not release authority. Only the protected version-update branch may convert that candidate into the exact release-admission inventory, @@ -253,11 +261,16 @@ blocked when callers provide neither raw conformance reports nor explicit permission to use the bundled reviewed release inventory. User-facing commands enable that inventory deliberately and fail closed if an adapter version, manifest digest, framework baseline, runtime, or platform list has changed. +Semantic implementation digests remain in lane reports, candidates, and the +reviewed inventory as audit provenance. They are verified during full +qualification and promotion, but are deliberately not runtime authorization: +routine implementation changes are guarded by affected-family tests instead +of requiring a hand-edited admission digest for every source edit. ## Attach an agent runtime -Build current Workspace Intelligence first, then inspect the release-admitted -runtimes: +Build current Workspace Intelligence first, then inspect adapter admission +state: ```bash npx workspai workspace intelligence run --for-agent generic --strict --json diff --git a/packages/cli/docs/ci-workflows.md b/packages/cli/docs/ci-workflows.md index 7f6b6622..5644ecff 100644 --- a/packages/cli/docs/ci-workflows.md +++ b/packages/cli/docs/ci-workflows.md @@ -4,30 +4,37 @@ Map of GitHub Actions workflows in this repository. Use this when editing CI to ## Workflows -| Workflow | Path | Purpose | -| ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------- | -| Build / test matrix | `.github/workflows/ci.yml` | Path-aware docs or full matrix validation plus the required `CI Gate` | -| Workspace E2E matrix | `.github/workflows/workspace-e2e-matrix.yml` | Cross-OS workspace lifecycle smoke; setup `--warm-deps`; cache/mirror ops | -| Windows bridge E2E | `.github/workflows/windows-bridge-e2e.yml` | Native Windows bridge and lifecycle checks | -| E2E smoke | `.github/workflows/e2e-smoke.yml` | Focused bridge regression smoke | -| Official generator smoke | `.github/workflows/frontend-generator-smoke.yml` | Contract-driven official-generator drift gate | -| Agent Framework matrix | `.github/workflows/agent-framework-conformance.yml` | Twelve-lane Microsoft Python/.NET and OpenAI Python/TypeScript compile, credentialless lifecycle, admission | -| Agent Framework discovery| `.github/workflows/agent-framework-version-discovery.yml` | Weekly PyPI/NuGet candidate report; no commit, PR, or contract rewrite | -| Security | `.github/workflows/security.yml` | Path-aware scanning plus the always-resolved `Security Gate` | -| Manual npm release | `.github/workflows/release-npm-manual.yml` | Maintainer-only release gate and publish workflow | -| Discord announcement | `.github/workflows/discord-release-announcement.yml` | Preview and publish one idempotent product-aware release announcement | -| Contributor onboarding | `.github/workflows/contributor-onboarding.yml` | Accepted-contributor onboarding automation | -| Contributor Hub | `.github/workflows/contributor-hub.yml` | Daily live issue-route freshness | -| Welcome | `.github/workflows/welcome.yml` | First-issue and first-contribution messages | +| Workflow | Path | Purpose | +| ------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Build / test matrix | `.github/workflows/ci.yml` | Path-aware docs or full matrix validation plus the required `CI Gate` | +| Workspace E2E matrix | `.github/workflows/workspace-e2e-matrix.yml` | Cross-OS workspace lifecycle smoke; setup `--warm-deps`; cache/mirror ops | +| Windows bridge E2E | `.github/workflows/windows-bridge-e2e.yml` | Native Windows bridge and lifecycle checks | +| E2E smoke | `.github/workflows/e2e-smoke.yml` | Focused bridge regression smoke | +| Official generator smoke | `.github/workflows/frontend-generator-smoke.yml` | Contract-driven official-generator drift gate | +| Agent Framework matrix | `.github/workflows/agent-framework-conformance.yml` | Affected-family Linux PR gate plus manual twelve-lane release qualification and admission | +| Agent Framework discovery | `.github/workflows/agent-framework-version-discovery.yml` | Weekly PyPI/NuGet candidate report; no commit, PR, or contract rewrite | +| Security | `.github/workflows/security.yml` | Path-aware scanning plus the always-resolved `Security Gate` | +| Manual npm release | `.github/workflows/release-npm-manual.yml` | Maintainer-only release gate and publish workflow | +| Discord announcement | `.github/workflows/discord-release-announcement.yml` | Preview and publish one idempotent product-aware release announcement | +| Contributor onboarding | `.github/workflows/contributor-onboarding.yml` | Accepted-contributor onboarding automation | +| Contributor Hub | `.github/workflows/contributor-hub.yml` | Daily live issue-route freshness | +| Welcome | `.github/workflows/welcome.yml` | First-issue and first-contribution messages | ## Agent Framework workflows Weekly `agent-framework-version-discovery` checks PyPI, NuGet, and npm, then stops at a report and artifact. It does not commit, open a pull request, regenerate -Create contracts, or write `release-admissions.v1.json`. A human pin update -still has to pass `agent-framework-conformance` on Linux, macOS, and Windows -for every built-in adapter runtime. That matrix compiles the nested `agents/primary` runtime, -runs credentialless context-boundary tests, and records digest-bound evidence. +Create contracts, or write `release-admissions.v2.json`. A human pin update +still has to pass the manual `full` mode of `agent-framework-conformance` on +Linux, macOS, and Windows for every built-in adapter runtime. Pull requests run +the faster Linux gate only for the affected Microsoft or OpenAI family; shared +agent-framework surfaces select both, while documentation-only changes skip the +specialized workflow. The full matrix compiles the nested `agents/primary` +runtime, runs credentialless context-boundary tests, and records manifest-bound +admission data plus semantic implementation provenance. Promotion requires the +candidate source commit to equal the checked-out promotion commit. An +implementation digest is audit evidence, not a runtime lock and not a manual +maintenance requirement after every routine adapter edit. It never sets Foundry or OpenAI credentials. Only a reviewed admission on the protected version-update branch can promote a green candidate. @@ -118,7 +125,7 @@ Validate or preview the current CLI announcement locally: npm --workspace workspai run check:release-announcement npm --workspace workspai run release:announcement -- \ --product workspai-cli \ - --tag v0.75.2 \ + --tag v0.76.0 \ --markdown-output /tmp/workspai-discord-announcement.md ``` diff --git a/packages/cli/docs/commands-reference.md b/packages/cli/docs/commands-reference.md index fe33a5c0..efcc0a75 100644 --- a/packages/cli/docs/commands-reference.md +++ b/packages/cli/docs/commands-reference.md @@ -276,9 +276,13 @@ See [Canonical-first agent entry](./agent-entry.md). `agent framework` is the governed bridge between Workspai evidence and an agent runtime. `list` exposes every built-in adapter and its release-admission -state. In this CLI version Microsoft Python `1.18.0` and .NET `1.21.0` plus -OpenAI Agents SDK Python `0.22.2` and TypeScript `0.18.0` are release-admitted -and remain `preview`. `plan` creates or reuses a scoped Goal, begins a Proof-Carrying Change, and +state. In this CLI version Microsoft Python `1.18.0` and .NET `1.21.0` remain +`preview`. OpenAI Agents SDK Python `0.22.2` and TypeScript `0.18.0` are +labeled `stable`. Create and Attach require the reviewed v2 release inventory, +whose manifest, framework baseline, runtime, and platform claims were promoted +from the Linux, macOS, and Windows release matrix. Semantic implementation +digests remain audit provenance rather than runtime authorization. `plan` +creates or reuses a scoped Goal, begins a Proof-Carrying Change, and attaches a hash-bound file plan without writing project files. `--runtime` selects `python`, `dotnet`, or `node`. `--framework` selects the independent framework id when more than one admitted adapter shares that runtime. diff --git a/packages/cli/package.json b/packages/cli/package.json index cec31107..4a0ead8e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "workspai", - "version": "0.75.2", + "version": "0.76.0", "type": "module", "description": "Open-source workspace intelligence CLI for software systems: create, adopt, govern, verify, and align polyglot workspaces for humans, CI, IDEs, and AI agents.", "keywords": [ diff --git a/packages/cli/releases/RELEASE_NOTES_v0.76.0.md b/packages/cli/releases/RELEASE_NOTES_v0.76.0.md new file mode 100644 index 00000000..09dbcef6 --- /dev/null +++ b/packages/cli/releases/RELEASE_NOTES_v0.76.0.md @@ -0,0 +1,85 @@ + + +# Workspai CLI v0.76.0 + +Release date: September 20, 2026. + +**Publication status:** Qualified by the reviewed v2 cross-platform matrix. npm +publication follows the post-promotion CI run for the admission commit. + +## Stable OpenAI Agents SDK Adapters + +This minor release labels OpenAI Agents SDK starters `stable` and binds runtime +authorization to the matching manifest and release baseline from one Adapter +Matrix. Semantic implementation digests remain immutable audit provenance in +the qualification artifacts rather than a runtime lock that must be refreshed +after every routine source edit. It does not +add handoffs, MCP, sessions, voice, sandbox, or approval loops, or let weekly +discovery rewrite Create contracts. + +## Agent Framework Adapters + +- Python pins `openai-agents` `0.22.2`. TypeScript pins `@openai/agents` + `0.18.0` with `zod` `4.6.5`. +- Create kit ids are `agent.openai.python` and `agent.openai.typescript`. + Interactive Create lists every published agent kit; apply still refuses a + blocked adapter. +- `--framework openai-agents` selects the OpenAI runtime. `--runtime python` + without `--framework` requires an explicit choice because Microsoft Agent + Framework and OpenAI Agents SDK are both published. +- Release admission v2 binds the manifest, framework baseline, runtime, and + platform matrix from Adapter Matrix run `35483229302` for source commit + `2b25305c6806cd9f39fbda66b646ed9304af167f`. Per-platform semantic + implementation digests are retained as audit provenance. +- Generated starters resolve the owning `agents//` project, inspect + admitted JSON through allowlisted read-only tools, stream live stdout, accept + a prompt from argv or stdin, and fail closed on missing context. They do not + paste context into instructions or treat `boundedGraphSearch` as a shell. + +## Compatibility and Verification Boundaries + +Existing public commands and schema versions remain supported. Microsoft Agent +Framework adapters stay `preview`. Independent Graph work on `main` is not part +of this CLI publication and is not CLI production authority. This release does +not claim complete runtime validation for every repository or production-complete +OpenAI coverage. + +Conformance-report and admission-candidate contracts move to v2. Their v1 JSON +schemas remain published for consumers, but v1 evidence cannot authorize a v2 +release admission. + +## Install + +```bash +npm install -g workspai@0.76.0 +workspai --version +``` + +The optional npm alias `wspai@0.76.0` targets the same CLI version. + +See the [Changelog](../CHANGELOG.md), [Command Reference](../docs/commands-reference.md), +and [Agent Framework Adapter Contract](../docs/agent-framework-adapters.md). diff --git a/packages/cli/scripts/enterprise-package-smoke.mjs b/packages/cli/scripts/enterprise-package-smoke.mjs index 40161284..ee18e942 100644 --- a/packages/cli/scripts/enterprise-package-smoke.mjs +++ b/packages/cli/scripts/enterprise-package-smoke.mjs @@ -9,6 +9,7 @@ import process from 'node:process'; const repoRoot = process.cwd(); const cliPath = path.join(repoRoot, 'dist', 'index.js'); const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workspai-enterprise-home-')); +let releaseAdmittedAdapterIds = new Set(); process.on('exit', () => { fs.rmSync(isolatedHome, { recursive: true, force: true }); @@ -356,29 +357,34 @@ function assertCliContracts() { .filter((adapter) => adapter.status === 'admitted') .map((adapter) => adapter.id) .sort(); - const expectedAdmittedIds = [ + const expectedAdapterIds = [ 'microsoft-agent-framework-dotnet', 'microsoft-agent-framework-python', 'openai-agents-python', 'openai-agents-typescript', ]; - if (JSON.stringify(admittedIds) !== JSON.stringify(expectedAdmittedIds)) { + if ( + admittedIds.length !== 0 && + JSON.stringify(admittedIds) !== JSON.stringify(expectedAdapterIds) + ) { fail( `published CLI does not expose the exact release-admitted framework adapters (admitted: ${ admittedIds.join(', ') || 'none' })` ); } - for (const previewId of ['openai-agents-python', 'openai-agents-typescript']) { - const preview = adapters.find((adapter) => adapter.id === previewId); - if (!preview) { - fail(`published CLI is missing implemented preview adapter ${previewId}`); + releaseAdmittedAdapterIds = new Set(admittedIds); + for (const openaiId of ['openai-agents-python', 'openai-agents-typescript']) { + const openai = adapters.find((adapter) => adapter.id === openaiId); + if (!openai) { + fail(`published CLI is missing implemented OpenAI adapter ${openaiId}`); } - if (preview.status !== 'admitted') { - fail(`${previewId} must be release-admitted after the reviewed matrix`); - } - if (preview.stability && preview.stability !== 'preview') { - fail(`${previewId} must remain preview until a later digest-changing promotion`); + if (openai.stability !== 'stable') { + fail( + `${openaiId} must be labeled stable after the digest-changing promotion (observed: ${ + openai.stability ?? 'missing' + })` + ); } } @@ -416,6 +422,7 @@ function smokeCreateAgentFrameworkKits() { { kit: 'agent.microsoft.python', name: 'python-agent', + expectCreate: releaseAdmittedAdapterIds.has('microsoft-agent-framework-python'), expectedFiles: [ 'README.md', '.workspai/project.json', @@ -430,6 +437,7 @@ function smokeCreateAgentFrameworkKits() { { kit: 'agent.microsoft.dotnet', name: 'dotnet-agent', + expectCreate: releaseAdmittedAdapterIds.has('microsoft-agent-framework-dotnet'), expectedFiles: [ 'README.md', '.workspai/project.json', @@ -446,6 +454,7 @@ function smokeCreateAgentFrameworkKits() { { kit: 'agent.openai.python', name: 'openai-python-agent', + expectCreate: releaseAdmittedAdapterIds.has('openai-agents-python'), expectedFiles: [ 'README.md', '.workspai/project.json', @@ -462,6 +471,7 @@ function smokeCreateAgentFrameworkKits() { { kit: 'agent.openai.typescript', name: 'openai-typescript-agent', + expectCreate: releaseAdmittedAdapterIds.has('openai-agents-typescript'), expectedFiles: [ 'README.md', '.workspai/project.json', @@ -489,6 +499,22 @@ function smokeCreateAgentFrameworkKits() { stdio: ['ignore', 'pipe', 'pipe'], } ); + const combined = `${result.stdout}\n${result.stderr}`; + if (scenario.expectCreate === false) { + if (result.status === 0) { + fail(`${scenario.kit} create succeeded before the stable digest was release-admitted`); + } + if ( + !/not release-admitted|adapter (?:manifest|implementation) changed after release admission/i.test( + combined + ) + ) { + fail( + `${scenario.kit} failed closed without an admission blocker\n${result.stdout}\n${result.stderr}` + ); + } + continue; + } if (result.status !== 0) { fail( `${scenario.kit} governed create failed with exit ${result.status}\n${result.stdout}\n${result.stderr}` @@ -496,8 +522,16 @@ function smokeCreateAgentFrameworkKits() { } assertGeneratedProject(path.join(workspacePath, scenario.name), scenario.expectedFiles); } + const admittedCreates = scenarios.filter((scenario) => scenario.expectCreate); const ownershipRoot = path.join(workspacePath, '.workspai', 'agent-frameworks', 'ownership'); - if (!fs.existsSync(ownershipRoot)) fail('agent kit smoke did not record ownership receipts'); + const recordedOwnership = fs.existsSync(ownershipRoot); + if (admittedCreates.length === 0) { + if (recordedOwnership) { + fail('agent kit smoke recorded ownership receipts without a release-admitted create'); + } + } else if (!recordedOwnership) { + fail('agent kit smoke did not record ownership receipts'); + } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/packages/cli/scripts/generate-shared-contracts.mjs b/packages/cli/scripts/generate-shared-contracts.mjs index 548e6a0a..68e04c28 100644 --- a/packages/cli/scripts/generate-shared-contracts.mjs +++ b/packages/cli/scripts/generate-shared-contracts.mjs @@ -47,10 +47,10 @@ const GENERATED_FILES = [ 'workspace-repair-capabilities.v1.json', 'agent-framework-capabilities.v1.json', 'workspace-intelligence/agent-framework-adapter-manifest.v1.json', - 'workspace-intelligence/agent-framework-conformance-report.v1.json', + 'workspace-intelligence/agent-framework-conformance-report.v2.json', 'workspace-intelligence/agent-framework-change-plan.v1.json', 'workspace-intelligence/agent-framework-ownership-receipt.v1.json', - 'workspace-intelligence/agent-framework-admission-candidate.v1.json', + 'workspace-intelligence/agent-framework-admission-candidate.v2.json', ]; function runGenerator() { diff --git a/packages/cli/scripts/promote-agent-framework-release-admission.ts b/packages/cli/scripts/promote-agent-framework-release-admission.ts index d9c0aa4a..7617c8e2 100644 --- a/packages/cli/scripts/promote-agent-framework-release-admission.ts +++ b/packages/cli/scripts/promote-agent-framework-release-admission.ts @@ -3,7 +3,10 @@ import path from 'node:path'; import { BUILTIN_AGENT_FRAMEWORK_ADAPTERS, + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, + liveImplementationDigestBlockers, + requireCompleteImplementationDigestMap, } from '../src/agent-frameworks/index.js'; import { AGENT_FRAMEWORK_ADMISSION_CANDIDATE_CONTRACT_PATH, @@ -27,6 +30,7 @@ async function main(): Promise { const outputPath = path.resolve(requiredArgument('--output')); const repository = requiredArgument('--repository'); const workflowRunId = Number(requiredArgument('--workflow-run-id')); + const sourceCommit = requiredArgument('--source-commit'); if (repository !== 'chistiq/workspai') throw new Error('Unexpected admission repository.'); if (!Number.isSafeInteger(workflowRunId) || workflowRunId <= 0) { throw new Error('Workflow run id must be a positive safe integer.'); @@ -39,6 +43,9 @@ async function main(): Promise { AGENT_FRAMEWORK_ADMISSION_CANDIDATE_CONTRACT_PATH, 'Agent framework admission candidate' ); + if (candidate.sourceCommit !== sourceCommit) { + throw new Error('Candidate source commit does not match the checked-out promotion commit.'); + } if (candidate.verdict !== 'admitted' || candidate.blockers.length > 0) { throw new Error('Only an unblocked admitted candidate can be promoted.'); } @@ -46,11 +53,21 @@ async function main(): Promise { const admitted = candidate.adapters.find((entry) => entry.id === adapter.manifest.adapter.id); if (!admitted) throw new Error(`Candidate is missing ${adapter.manifest.adapter.id}.`); const manifestSha256 = digestBuiltinAgentFrameworkManifest(adapter); + const implementationSha256ByPlatform = requireCompleteImplementationDigestMap( + admitted.implementationSha256ByPlatform + ); + const liveBlockers = liveImplementationDigestBlockers({ + liveImplementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), + implementationSha256ByPlatform, + }); if ( admitted.version !== adapter.manifest.adapter.version || - admitted.manifestSha256 !== manifestSha256 + admitted.manifestSha256 !== manifestSha256 || + liveBlockers.length > 0 ) { - throw new Error(`Candidate does not bind the live ${adapter.manifest.adapter.id} manifest.`); + throw new Error( + `Candidate does not bind the live ${adapter.manifest.adapter.id} manifest and implementation.` + ); } const expectedPlatforms = [...adapter.manifest.implementation.platforms].sort(); const platforms = [...new Set(admitted.lanes.map((lane) => lane.platform))].sort(); @@ -68,6 +85,7 @@ async function main(): Promise { id: admitted.id, version: admitted.version, manifestSha256, + implementationSha256ByPlatform, frameworkVersion, runtime, platforms, @@ -77,7 +95,7 @@ async function main(): Promise { throw new Error('Candidate contains an unexpected adapter admission.'); } const document = { - schemaVersion: 'workspai.agent-framework-release-admissions.v1', + schemaVersion: 'workspai.agent-framework-release-admissions.v2', reviewedAt: candidate.generatedAt, source: { repository, diff --git a/packages/cli/scripts/run-generate-shared-contracts.ts b/packages/cli/scripts/run-generate-shared-contracts.ts index 646c6a30..22326c9b 100644 --- a/packages/cli/scripts/run-generate-shared-contracts.ts +++ b/packages/cli/scripts/run-generate-shared-contracts.ts @@ -91,7 +91,7 @@ writeJson( buildAgentFrameworkAdapterManifestSchema() ); writeJson( - 'workspace-intelligence/agent-framework-conformance-report.v1.json', + 'workspace-intelligence/agent-framework-conformance-report.v2.json', buildAgentFrameworkConformanceReportSchema() ); writeJson( @@ -103,6 +103,6 @@ writeJson( buildAgentFrameworkOwnershipReceiptSchema() ); writeJson( - 'workspace-intelligence/agent-framework-admission-candidate.v1.json', + 'workspace-intelligence/agent-framework-admission-candidate.v2.json', buildAgentFrameworkAdmissionCandidateSchema() ); diff --git a/packages/cli/scripts/smoke-microsoft-agent-framework-adapter.ts b/packages/cli/scripts/smoke-microsoft-agent-framework-adapter.ts index a9b2679d..5a9856fd 100644 --- a/packages/cli/scripts/smoke-microsoft-agent-framework-adapter.ts +++ b/packages/cli/scripts/smoke-microsoft-agent-framework-adapter.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, managedFile, MICROSOFT_AGENT_FRAMEWORK_DOTNET_BASELINE, @@ -920,6 +921,7 @@ async function main(): Promise { id: adapter.manifest.adapter.id, version: adapter.manifest.adapter.version, manifestSha256: digestBuiltinAgentFrameworkManifest(adapter), + implementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), }, frameworkVersion: adapter.manifest.framework.testedVersions[0], cliVersion: await cliVersion(), @@ -946,7 +948,12 @@ async function main(): Promise { process.stdout.write( `${status} ${adapter.manifest.adapter.id} ${report.frameworkVersion} on ${platform}; report: ${reportPath}\n` ); - if (report.verdict !== 'admitted') process.exitCode = 1; + if (report.verdict !== 'admitted') { + for (const blocker of report.blockers) { + process.stdout.write(`blocker: ${blocker}\n`); + } + process.exitCode = 1; + } } main().catch((error: unknown) => { diff --git a/packages/cli/scripts/smoke-openai-agents-adapter.ts b/packages/cli/scripts/smoke-openai-agents-adapter.ts index f2e52410..989cd9fe 100644 --- a/packages/cli/scripts/smoke-openai-agents-adapter.ts +++ b/packages/cli/scripts/smoke-openai-agents-adapter.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, managedFile, OPENAI_AGENTS_PYTHON_BASELINE, @@ -951,8 +952,10 @@ async function main(): Promise { assertCondition(generatedTests, 'Credentialless context tests were not rendered.'); assertCondition( generatedTests.content.includes('setUpClass') || - generatedTests.content.includes('restoreLiveContext'), - 'Generated context tests do not restore the operational context file.' + generatedTests.content.includes('restoreLiveContext') || + generatedTests.content.includes('bind_workspai_project_root_for_tests') || + generatedTests.content.includes('bindWorkspaiProjectRootForTests'), + 'Generated context tests neither isolate their fixture root nor restore operational context.' ); const agentSource = rendered.files.find( (file) => file.path.endsWith('/agent.py') || file.path.endsWith('/agent.ts') @@ -1400,6 +1403,7 @@ async function main(): Promise { id: adapter.manifest.adapter.id, version: adapter.manifest.adapter.version, manifestSha256: digestBuiltinAgentFrameworkManifest(adapter), + implementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), }, frameworkVersion: adapter.manifest.framework.testedVersions[0], cliVersion: await cliVersion(), @@ -1431,7 +1435,12 @@ async function main(): Promise { process.stdout.write( `${status} ${adapter.manifest.adapter.id} ${report.frameworkVersion} on ${platform}; report: ${reportPath}\n` ); - if (report.verdict !== 'admitted') process.exitCode = 1; + if (report.verdict !== 'admitted') { + for (const blocker of report.blockers) { + process.stdout.write(`blocker: ${blocker}\n`); + } + process.exitCode = 1; + } } main().catch((error: unknown) => { diff --git a/packages/cli/scripts/sync-shared-contracts.mjs b/packages/cli/scripts/sync-shared-contracts.mjs index ef35a184..f91c4dcd 100644 --- a/packages/cli/scripts/sync-shared-contracts.mjs +++ b/packages/cli/scripts/sync-shared-contracts.mjs @@ -71,10 +71,10 @@ const GENERATED_FILES = [ 'workspace-repair-capabilities.v1.json', 'agent-framework-capabilities.v1.json', 'workspace-intelligence/agent-framework-adapter-manifest.v1.json', - 'workspace-intelligence/agent-framework-conformance-report.v1.json', + 'workspace-intelligence/agent-framework-conformance-report.v2.json', 'workspace-intelligence/agent-framework-change-plan.v1.json', 'workspace-intelligence/agent-framework-ownership-receipt.v1.json', - 'workspace-intelligence/agent-framework-admission-candidate.v1.json', + 'workspace-intelligence/agent-framework-admission-candidate.v2.json', 'workspace-intelligence/workspace-repair-proposal.v1.json', 'workspace-intelligence/workspace-repair-transaction.v1.json', 'workspace-intelligence/project-agent-entry.v1.json', diff --git a/packages/cli/scripts/verify-agent-framework-conformance.ts b/packages/cli/scripts/verify-agent-framework-conformance.ts index a90c3c0d..03ce7a5a 100644 --- a/packages/cli/scripts/verify-agent-framework-conformance.ts +++ b/packages/cli/scripts/verify-agent-framework-conformance.ts @@ -4,7 +4,10 @@ import path from 'node:path'; import { assessAgentFrameworkAdmission, BUILTIN_AGENT_FRAMEWORK_ADAPTERS, + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, + implementationSha256ByPlatformFromReports, + liveImplementationDigestBlockers, loadAgentFrameworkConformanceReport, } from '../src/agent-frameworks/index.js'; import { buildAgentFrameworkAdmissionCandidate } from '../src/agent-frameworks/admission-candidate.js'; @@ -140,9 +143,18 @@ async function main(): Promise { const adapterReports = reports.filter( (report) => report.adapter.id === adapter.manifest.adapter.id ); + const implementationSha256ByPlatform = + implementationSha256ByPlatformFromReports(adapterReports); + blockers.push( + ...liveImplementationDigestBlockers({ + liveImplementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), + implementationSha256ByPlatform, + }).map((blocker) => `${adapter.manifest.adapter.id}: ${blocker}`) + ); const assessment = assessAgentFrameworkAdmission({ manifest: adapter.manifest, manifestSha256: digestBuiltinAgentFrameworkManifest(adapter), + implementationSha256ByPlatform, reports: adapterReports, }); if (assessment.status !== 'admitted') blockers.push(...assessment.blockers); diff --git a/packages/cli/src/__tests__/agent-framework-admission-candidate.test.ts b/packages/cli/src/__tests__/agent-framework-admission-candidate.test.ts index 642eb927..af08ee43 100644 --- a/packages/cli/src/__tests__/agent-framework-admission-candidate.test.ts +++ b/packages/cli/src/__tests__/agent-framework-admission-candidate.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { buildAgentFrameworkAdmissionCandidate } from '../agent-frameworks/admission-candidate.js'; import { digestBuiltinAgentFrameworkManifest, + digestBuiltinAgentFrameworkImplementation, microsoftAgentFrameworkPythonAdapter, } from '../agent-frameworks/index.js'; import { @@ -37,6 +38,7 @@ function report(platform: 'linux' | 'darwin' | 'win32'): AgentFrameworkConforman id: adapter.manifest.adapter.id, version: adapter.manifest.adapter.version, manifestSha256: digestBuiltinAgentFrameworkManifest(adapter), + implementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), }, frameworkVersion: adapter.manifest.framework.testedVersions[0], cliVersion: '0.74.0', @@ -103,8 +105,42 @@ describe('agent framework admission candidate', () => { expect(candidate.schemaVersion).toBe(AGENT_FRAMEWORK_ADMISSION_CANDIDATE_SCHEMA_VERSION); expect(candidate.reviewStatus).toBe('pending'); expect(candidate.adapters[0].lanes).toHaveLength(3); + expect(candidate.adapters[0].implementationSha256ByPlatform).toEqual({ + linux: expect.stringMatching(/^[a-f0-9]{64}$/), + darwin: expect.stringMatching(/^[a-f0-9]{64}$/), + win32: expect.stringMatching(/^[a-f0-9]{64}$/), + }); expect(candidate.adapters[0].lanes.every((lane) => lane.evidence.length === 18)).toBe(true); expect(candidate.adapters[0].lanes[0].report.sha256).toMatch(/^[a-f0-9]{64}$/); + expect( + candidate.adapters[0].implementationSha256ByPlatform[ + process.platform as 'linux' | 'darwin' | 'win32' + ] + ).toBe(digestBuiltinAgentFrameworkImplementation(microsoftAgentFrameworkPythonAdapter)); + }); + + it('fails closed when live implementation does not match the current-platform report', async () => { + const input = await fixture(); + const host = process.platform as 'linux' | 'darwin' | 'win32'; + const mismatched = input.reports.map((lane) => + lane.environment.platform === host + ? { + ...lane, + adapter: { ...lane.adapter, implementationSha256: 'b'.repeat(64) }, + } + : lane + ); + + await expect( + buildAgentFrameworkAdmissionCandidate({ + evidenceRoot: input.root, + reportPaths: input.reportPaths, + reports: mismatched, + sourceCommit: 'a'.repeat(40), + cliVersion: '0.74.0', + adapters: [microsoftAgentFrameworkPythonAdapter], + }) + ).rejects.toThrow(`live implementation digest does not match ${host} evidence`); }); it('rejects a report changed after conformance validation', async () => { diff --git a/packages/cli/src/__tests__/agent-framework-lifecycle.test.ts b/packages/cli/src/__tests__/agent-framework-lifecycle.test.ts index 768baf76..878c760e 100644 --- a/packages/cli/src/__tests__/agent-framework-lifecycle.test.ts +++ b/packages/cli/src/__tests__/agent-framework-lifecycle.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createBuiltinAgentFrameworkRegistry, + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, microsoftAgentFrameworkPythonAdapter, } from '../agent-frameworks/index.js'; @@ -32,6 +33,7 @@ const roots: string[] = []; function admittedRegistry() { const adapter = microsoftAgentFrameworkPythonAdapter; const manifestSha256 = digestBuiltinAgentFrameworkManifest(adapter); + const implementationSha256 = digestBuiltinAgentFrameworkImplementation(adapter); const reports = adapter.manifest.implementation.platforms.map((platform) => { const checks = AGENT_FRAMEWORK_CONFORMANCE_CHECK_IDS.map((id) => ({ id, @@ -49,6 +51,7 @@ function admittedRegistry() { id: adapter.manifest.adapter.id, version: adapter.manifest.adapter.version, manifestSha256, + implementationSha256, }, frameworkVersion: adapter.manifest.framework.testedVersions[0], cliVersion: '0.74.0', @@ -166,7 +169,7 @@ describe('agent framework proof-carrying lifecycle', () => { expect(prepared.status).toBe('planned'); expect(prepared.plan.target).toEqual({ project: 'api', artifactPrefix }); - expect(prepared.plan.files).toHaveLength(8); + expect(prepared.plan.files).toHaveLength(9); expect(prepared.planArtifact).toContain( `/plans/agent-framework-change-plan-${prepared.planDigest}.json` ); @@ -206,7 +209,7 @@ describe('agent framework proof-carrying lifecycle', () => { }); expect(applied.status).toBe('applied'); - expect(applied.files).toHaveLength(8); + expect(applied.files).toHaveLength(9); expect( await fsExtra.readFile( path.join(projectPath, 'agents', 'release-reviewer', 'main.py'), @@ -219,7 +222,7 @@ describe('agent framework proof-carrying lifecycle', () => { changeId, target: { workspace: 'platform', project: 'api', instanceName: 'release-reviewer' }, }); - expect(ownership.files).toHaveLength(8); + expect(ownership.files).toHaveLength(9); const transaction = await readDecisionTransaction(workspacePath, changeId); expect(transaction.transaction.effects).toContainEqual( expect.objectContaining({ diff --git a/packages/cli/src/__tests__/agent-framework-project-kits.test.ts b/packages/cli/src/__tests__/agent-framework-project-kits.test.ts index a90b9303..dda1b058 100644 --- a/packages/cli/src/__tests__/agent-framework-project-kits.test.ts +++ b/packages/cli/src/__tests__/agent-framework-project-kits.test.ts @@ -12,15 +12,17 @@ import { resolveAgentFrameworkProjectKit, } from '../agent-frameworks/project-kits.js'; import { readProjectMetadata } from '../utils/project-metadata.js'; +import { listBundledAgentFrameworkReleaseAdmissions } from '../agent-frameworks/release-admission.js'; const roots: string[] = []; +const releaseAdmitted = listBundledAgentFrameworkReleaseAdmissions().length === 4; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); }); describe('agent framework project kits', () => { - it('publishes the exact release-admitted Microsoft and OpenAI kits', () => { + it('publishes every Microsoft and OpenAI kit without treating visibility as admission', () => { expect(describeAgentFrameworkProjectKits().map((kit) => kit.id)).toEqual([ 'agent.microsoft.python', 'agent.microsoft.dotnet', @@ -42,7 +44,11 @@ describe('agent framework project kits', () => { expect(resolveAgentFrameworkProjectKit('agent.openai.typescript')?.adapterId).toBe( 'openai-agents-typescript' ); - expect(kits.every((kit) => isAdmittedAgentFrameworkProjectKit(kit))).toBe(true); + expect(isAdmittedAgentFrameworkProjectKit('agent.openai.python')).toBe(releaseAdmitted); + expect(isAdmittedAgentFrameworkProjectKit('agent.openai.typescript')).toBe(releaseAdmitted); + expect(kits.every((kit) => isAdmittedAgentFrameworkProjectKit(kit) === releaseAdmitted)).toBe( + true + ); }); it('resolves stable aliases without exposing mutable registry state', () => { diff --git a/packages/cli/src/__tests__/agent-framework-registry.test.ts b/packages/cli/src/__tests__/agent-framework-registry.test.ts index d1ee3af5..80d0d6e3 100644 --- a/packages/cli/src/__tests__/agent-framework-registry.test.ts +++ b/packages/cli/src/__tests__/agent-framework-registry.test.ts @@ -152,6 +152,7 @@ function reportFor( id: adapterManifest.adapter.id, version: adapterManifest.adapter.version, manifestSha256: digest, + implementationSha256: digest, }, frameworkVersion: '1.0.0', cliVersion: '0.74.0', @@ -187,6 +188,7 @@ describe('agent framework detection and registry', () => { const registry = new AgentFrameworkRegistry().register({ manifest: fixtureManifest, manifestSha256: digest, + implementationSha256: digest, source: 'builtin', conformanceReports: [], }); @@ -338,6 +340,7 @@ describe('agent framework detection and registry', () => { const incomplete = assessAgentFrameworkAdmission({ manifest: adapter, manifestSha256: digest, + implementationSha256: digest, reports: [nodeReport], }); expect(incomplete.status).toBe('blocked'); @@ -350,6 +353,7 @@ describe('agent framework detection and registry', () => { assessAgentFrameworkAdmission({ manifest: adapter, manifestSha256: digest, + implementationSha256: digest, reports: [nodeReport, pythonReport], }).status ).toBe('admitted'); @@ -360,10 +364,66 @@ describe('agent framework detection and registry', () => { assessAgentFrameworkAdmission({ manifest: adapter, manifestSha256: digest, + implementationSha256: digest, reports: [nodeReport, wrongDigest], }).blockers ).toContain('conformance linux/python/1.0.0: manifest digest does not match'); + const wrongImplementation = structuredClone(pythonReport); + wrongImplementation.adapter.implementationSha256 = 'b'.repeat(64); + expect( + assessAgentFrameworkAdmission({ + manifest: adapter, + manifestSha256: digest, + implementationSha256: digest, + reports: [nodeReport, wrongImplementation], + }).blockers + ).toContain('conformance linux/python/1.0.0: implementation digest does not match'); + + const darwinPython = structuredClone(pythonReport); + darwinPython.environment = { ...pythonReport.environment, platform: 'darwin' }; + darwinPython.adapter = { ...pythonReport.adapter, implementationSha256: 'c'.repeat(64) }; + const win32Python = structuredClone(pythonReport); + win32Python.environment = { ...pythonReport.environment, platform: 'win32' }; + win32Python.adapter = { ...pythonReport.adapter, implementationSha256: 'd'.repeat(64) }; + const darwinNode = structuredClone(nodeReport); + darwinNode.environment = { ...nodeReport.environment, platform: 'darwin' }; + darwinNode.adapter = { ...nodeReport.adapter, implementationSha256: 'c'.repeat(64) }; + const win32Node = structuredClone(nodeReport); + win32Node.environment = { ...nodeReport.environment, platform: 'win32' }; + win32Node.adapter = { ...nodeReport.adapter, implementationSha256: 'd'.repeat(64) }; + const threeOsAdapter = { + ...adapter, + implementation: { ...adapter.implementation, platforms: ['linux', 'darwin', 'win32'] }, + }; + expect( + assessAgentFrameworkAdmission({ + manifest: threeOsAdapter, + manifestSha256: digest, + implementationSha256: digest, + reports: [nodeReport, pythonReport, darwinNode, darwinPython, win32Node, win32Python], + }).blockers + ).toEqual( + expect.arrayContaining([ + 'conformance darwin/node/1.0.0: implementation digest does not match', + 'conformance darwin/python/1.0.0: implementation digest does not match', + 'conformance win32/node/1.0.0: implementation digest does not match', + 'conformance win32/python/1.0.0: implementation digest does not match', + ]) + ); + expect( + assessAgentFrameworkAdmission({ + manifest: threeOsAdapter, + manifestSha256: digest, + implementationSha256ByPlatform: { + linux: digest, + darwin: 'c'.repeat(64), + win32: 'd'.repeat(64), + }, + reports: [nodeReport, pythonReport, darwinNode, darwinPython, win32Node, win32Python], + }).status + ).toBe('admitted'); + const blockedReport = structuredClone(pythonReport); blockedReport.checks[0].status = 'failed'; blockedReport.summary.passed -= 1; @@ -374,6 +434,7 @@ describe('agent framework detection and registry', () => { assessAgentFrameworkAdmission({ manifest: adapter, manifestSha256: digest, + implementationSha256: digest, reports: [nodeReport, blockedReport], }).blockers ).toContain('conformance linux/python/1.0.0: manifest-schema: fixture failure'); @@ -465,12 +526,14 @@ describe('agent framework detection and registry', () => { .register({ manifest: first, manifestSha256: digest, + implementationSha256: digest, source: 'package', conformanceReports: [reportFor(first)], }) .register({ manifest: second, manifestSha256: digest, + implementationSha256: digest, source: 'package', conformanceReports: [reportFor(second)], }); @@ -481,6 +544,7 @@ describe('agent framework detection and registry', () => { const blocked = new AgentFrameworkRegistry().register({ manifest: first, manifestSha256: digest, + implementationSha256: digest, source: 'package', conformanceReports: [], }); @@ -495,6 +559,7 @@ describe('agent framework detection and registry', () => { blocked.register({ manifest: first, manifestSha256: digest, + implementationSha256: digest, source: 'workspace', conformanceReports: [], }) @@ -512,6 +577,7 @@ describe('agent framework detection and registry', () => { const result = assessAgentFrameworkAdmission({ manifest: adapter, manifestSha256: digest, + implementationSha256: digest, reports: [report, structuredClone(report)], }); diff --git a/packages/cli/src/__tests__/agent-framework-release-admission.test.ts b/packages/cli/src/__tests__/agent-framework-release-admission.test.ts index 8559d2d3..61f59fd9 100644 --- a/packages/cli/src/__tests__/agent-framework-release-admission.test.ts +++ b/packages/cli/src/__tests__/agent-framework-release-admission.test.ts @@ -4,51 +4,66 @@ import { assessBundledAgentFrameworkRelease, BUILTIN_AGENT_FRAMEWORK_ADAPTERS, createBuiltinAgentFrameworkRegistry, + digestBuiltinAgentFrameworkImplementation, listBundledAgentFrameworkReleaseAdmissions, } from '../agent-frameworks/index.js'; describe('agent framework release admission', () => { - it('admits only the exact reviewed built-in manifests and complete platform matrices', () => { + it('admits exactly the four adapters promoted from the reviewed v2 matrix', () => { const admissions = listBundledAgentFrameworkReleaseAdmissions(); - const admittedIds = new Set(admissions.map((admission) => admission.id)); - expect(admissions).toHaveLength(4); + expect(admissions.map((admission) => admission.id).sort()).toEqual( + BUILTIN_AGENT_FRAMEWORK_ADAPTERS.map((adapter) => adapter.manifest.adapter.id).sort() + ); for (const adapter of BUILTIN_AGENT_FRAMEWORK_ADAPTERS) { const resolution = assessBundledAgentFrameworkRelease(adapter); - if (admittedIds.has(adapter.manifest.adapter.id)) { - expect(resolution).toMatchObject({ status: 'admitted', blockers: [] }); - expect(resolution.admission?.platforms).toEqual(['linux', 'darwin', 'win32']); - } else { - expect(resolution.status).toBe('blocked'); - expect(resolution.blockers).toEqual( - expect.arrayContaining([expect.stringContaining('No reviewed release admission exists')]) - ); - } + expect(resolution.status).toBe('admitted'); + expect(resolution.blockers).toEqual([]); } }); - it('fails closed after any admitted manifest change', () => { + it('records deterministic implementation provenance without making it runtime authority', () => { const adapter = BUILTIN_AGENT_FRAMEWORK_ADAPTERS[0]; const changed = { + ...adapter, + render: (input: Parameters[0]) => ({ + ...adapter.render(input), + files: adapter + .render(input) + .files.map((file, index) => + index === 0 ? { ...file, content: `${file.content}\n# changed` } : file + ), + }), + }; + expect(digestBuiltinAgentFrameworkImplementation(adapter)).toMatch(/^[a-f0-9]{64}$/); + expect(digestBuiltinAgentFrameworkImplementation(adapter)).not.toBe( + digestBuiltinAgentFrameworkImplementation(changed) + ); + expect(assessBundledAgentFrameworkRelease(changed)).toMatchObject({ + status: 'admitted', + blockers: [], + }); + + const changedManifest = { ...adapter, manifest: { ...adapter.manifest, - framework: { - ...adapter.manifest.framework, - testedVersions: ['1.17.1'], + adapter: { + ...adapter.manifest.adapter, + version: '999.0.0', }, }, }; - expect(assessBundledAgentFrameworkRelease(changed)).toMatchObject({ + expect(assessBundledAgentFrameworkRelease(changedManifest)).toMatchObject({ status: 'blocked', blockers: expect.arrayContaining([ + 'adapter version changed after release admission', 'adapter manifest changed after release admission', - 'framework baseline changed after release admission', ]), }); }); - it('keeps raw registries blocked and enables release trust only when explicitly requested', () => { + it('keeps raw registries blocked and admits reviewed adapters only when explicitly trusted', () => { const adapterId = BUILTIN_AGENT_FRAMEWORK_ADAPTERS[0].manifest.adapter.id; expect(createBuiltinAgentFrameworkRegistry().resolveAdapter(adapterId).status).toBe('blocked'); expect( @@ -64,7 +79,7 @@ describe('agent framework release admission', () => { ).not.toHaveProperty('releaseAdapter'); }); - it('admits reviewed OpenAI adapters while keeping default registries blocked', () => { + it('keeps stable-labeled OpenAI adapters visible and admitted after promotion', () => { const registry = createBuiltinAgentFrameworkRegistry( {}, { trustReviewedReleaseAdmissions: true } @@ -74,21 +89,11 @@ describe('agent framework release admission', () => { status: registry.resolveAdapter(entry.manifest.adapter.id).status, stability: entry.manifest.adapter.stability, })); - expect( - adapters - .filter((adapter) => adapter.status === 'admitted') - .map((adapter) => adapter.id) - .sort() - ).toEqual([ - 'microsoft-agent-framework-dotnet', - 'microsoft-agent-framework-python', - 'openai-agents-python', - 'openai-agents-typescript', - ]); + expect(adapters.filter((adapter) => adapter.status === 'admitted')).toHaveLength(4); expect( adapters .filter((adapter) => adapter.id.startsWith('openai-agents-')) - .every((adapter) => adapter.status === 'admitted' && adapter.stability === 'preview') + .every((adapter) => adapter.status === 'admitted' && adapter.stability === 'stable') ).toBe(true); expect( createBuiltinAgentFrameworkRegistry().resolveAdapter('openai-agents-python').status diff --git a/packages/cli/src/__tests__/agent-framework-selection.test.ts b/packages/cli/src/__tests__/agent-framework-selection.test.ts index 5570b377..3eec2826 100644 --- a/packages/cli/src/__tests__/agent-framework-selection.test.ts +++ b/packages/cli/src/__tests__/agent-framework-selection.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'; import { createBuiltinAgentFrameworkRegistry, + listBundledAgentFrameworkReleaseAdmissions, parseAgentFrameworkRuntime, resolveAgentFrameworkSelection, } from '../agent-frameworks/index.js'; +const releaseAdmitted = listBundledAgentFrameworkReleaseAdmissions().length === 4; + describe('agent framework selection', () => { it('refuses to guess Python when Microsoft and OpenAI are both published', () => { const registry = createBuiltinAgentFrameworkRegistry( @@ -24,7 +27,7 @@ describe('agent framework selection', () => { ).toMatchObject({ adapterId: 'microsoft-agent-framework-python', frameworkId: 'microsoft-agent-framework', - admitted: true, + admitted: releaseAdmitted, }); }); @@ -73,7 +76,7 @@ describe('agent framework selection', () => { ).toMatchObject({ adapterId: 'openai-agents-python', frameworkId: 'openai-agents', - admitted: true, + admitted: releaseAdmitted, }); expect( resolveAgentFrameworkSelection({ @@ -84,7 +87,7 @@ describe('agent framework selection', () => { ).toMatchObject({ adapterId: 'openai-agents-typescript', frameworkId: 'openai-agents', - admitted: true, + admitted: releaseAdmitted, }); }); }); diff --git a/packages/cli/src/__tests__/agent-framework-user-flow.test.ts b/packages/cli/src/__tests__/agent-framework-user-flow.test.ts index 0e48598c..47f85b2e 100644 --- a/packages/cli/src/__tests__/agent-framework-user-flow.test.ts +++ b/packages/cli/src/__tests__/agent-framework-user-flow.test.ts @@ -8,6 +8,7 @@ import { applyPreparedAgentFrameworkAttachment, prepareAgentFrameworkAttachment, } from '../agent-frameworks/user-flow.js'; +import { listBundledAgentFrameworkReleaseAdmissions } from '../agent-frameworks/release-admission.js'; import { readDecisionTransaction } from '../decisions/decision-store.js'; import { inspectGoalLifecycle } from '../goal-lifecycle.js'; import { verifyProofCarryingChange } from '../proof-carrying-change.js'; @@ -15,14 +16,21 @@ import { buildWorkspaceModel, writeWorkspaceModel } from '../workspace-model.js' import { runWorkspaceIntelligenceChain } from '../workspace-intelligence-runner.js'; const roots: string[] = []; +const releaseAdmitted = listBundledAgentFrameworkReleaseAdmissions().length === 4; -async function fixture(options: { secondProject?: boolean } = {}): Promise<{ +async function fixture( + options: { secondProject?: boolean; extraProjects?: string[] } = {} +): Promise<{ workspacePath: string; projectPath: string; }> { const workspacePath = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'workspai-agent-user-flow-')); roots.push(workspacePath); const projectPath = path.join(workspacePath, 'api'); + const extraProjects = [ + ...(options.secondProject ? ['worker'] : []), + ...(options.extraProjects ?? []), + ]; await fsExtra.outputJson(path.join(workspacePath, '.workspai-workspace'), { name: 'platform', profile: 'polyglot', @@ -50,36 +58,32 @@ async function fixture(options: { secondProject?: boolean } = {}): Promise<{ env: [], }, }, - ...(options.secondProject - ? [ - { - slug: 'worker', - relativePath: 'worker', - runtime: 'node', - framework: 'node', - kit: 'node', - modules: [], - ports: [], - contracts: { - owns: [], - apis: [], - publishes: [], - consumes: [], - dependsOn: [], - env: [], - }, - }, - ] - : []), + ...extraProjects.map((slug) => ({ + slug, + relativePath: slug, + runtime: 'node', + framework: 'node', + kit: 'node', + modules: [], + ports: [], + contracts: { + owns: [], + apis: [], + publishes: [], + consumes: [], + dependsOn: [], + env: [], + }, + })), ], }); await fsExtra.outputJson(path.join(projectPath, 'package.json'), { name: '@platform/api', version: '1.0.0', }); - if (options.secondProject) { - await fsExtra.outputJson(path.join(workspacePath, 'worker', 'package.json'), { - name: '@platform/worker', + for (const slug of extraProjects) { + await fsExtra.outputJson(path.join(workspacePath, slug, 'package.json'), { + name: `@platform/${slug}`, version: '1.0.0', }); } @@ -96,7 +100,22 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); }); -describe('agent framework user flow', () => { +describe.skipIf(releaseAdmitted)('agent framework user flow fail-closed', () => { + it('refuses prepare when v2 release admission is empty', async () => { + const { workspacePath } = await fixture(); + await expect( + prepareAgentFrameworkAttachment({ + workspacePath, + project: 'api', + runtime: 'python', + framework: 'openai-agents', + instanceName: 'primary', + }) + ).rejects.toThrow(/not release-admitted/i); + }); +}); + +describe.skipIf(!releaseAdmitted)('agent framework user flow', () => { it('turns one attach request into a Goal, hash-bound plan, authorization, and owned files', async () => { const { workspacePath, projectPath } = await fixture(); const prepared = await prepareAgentFrameworkAttachment({ @@ -117,7 +136,7 @@ describe('agent framework user flow', () => { adapterId: 'microsoft-agent-framework-python', instanceName: 'release-reviewer', }); - expect(prepared.files).toHaveLength(8); + expect(prepared.files).toHaveLength(9); expect(await fsExtra.pathExists(path.join(projectPath, 'agents', 'release-reviewer'))).toBe( false ); @@ -148,6 +167,39 @@ describe('agent framework user flow', () => { ) ).toContain('Generated and managed by Workspai'); expect(await fsExtra.pathExists(path.join(workspacePath, applied.ownershipReceipt))).toBe(true); + + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + await expect( + inspectGoalLifecycle({ workspacePath, goalId: prepared.goalId, validateBindings: true }) + ).resolves.toMatchObject({ + active: expect.objectContaining({ id: prepared.goalId }), + }); + }); + + it('does not sanction an unrelated architecture mutation with an older apply receipt', async () => { + const { workspacePath, projectPath } = await fixture(); + const prepared = await prepareAgentFrameworkAttachment({ + workspacePath, + project: 'api', + runtime: 'python', + framework: 'microsoft-agent-framework', + instanceName: 'primary', + }); + await applyPreparedAgentFrameworkAttachment({ prepared, grantedBy: 'test-maintainer' }); + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + await expect( + inspectGoalLifecycle({ workspacePath, goalId: prepared.goalId, validateBindings: true }) + ).resolves.toBeDefined(); + + await fsExtra.outputFile( + path.join(projectPath, 'src', 'unreceipted.ts'), + 'export const unreceipted = true;\n' + ); + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + + await expect( + inspectGoalLifecycle({ workspacePath, goalId: prepared.goalId, validateBindings: true }) + ).rejects.toThrow('changed outside a closed Goal repair transaction'); }); it('closes its generated Goal and Change when an idempotent plan is a no-op', async () => { @@ -254,6 +306,40 @@ describe('agent framework user flow', () => { ).toBe(false); }); + it('keeps the first kit Goal current after a later kit is attached in the same workspace', async () => { + const { workspacePath } = await fixture({ secondProject: true }); + const first = await prepareAgentFrameworkAttachment({ + workspacePath, + project: 'api', + runtime: 'python', + framework: 'microsoft-agent-framework', + instanceName: 'primary', + }); + await applyPreparedAgentFrameworkAttachment({ prepared: first, grantedBy: 'test-maintainer' }); + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + + const second = await prepareAgentFrameworkAttachment({ + workspacePath, + project: 'worker', + runtime: 'python', + framework: 'openai-agents', + instanceName: 'primary', + }); + await applyPreparedAgentFrameworkAttachment({ prepared: second, grantedBy: 'test-maintainer' }); + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + + await expect( + inspectGoalLifecycle({ workspacePath, goalId: first.goalId, validateBindings: true }) + ).resolves.toMatchObject({ + active: expect.objectContaining({ id: first.goalId }), + }); + await expect( + inspectGoalLifecycle({ workspacePath, goalId: second.goalId, validateBindings: true }) + ).resolves.toMatchObject({ + active: expect.objectContaining({ id: second.goalId }), + }); + }); + it('fails closed when Python attach does not name one of the published frameworks', async () => { const { workspacePath } = await fixture(); await expect( @@ -284,4 +370,95 @@ describe('agent framework user flow', () => { }); expect(await fsExtra.pathExists(path.join(projectPath, 'agents', 'primary'))).toBe(false); }); + + it('keeps Create verification pending until init/test/build evidence exists', async () => { + const { workspacePath } = await fixture(); + const prepared = await prepareAgentFrameworkAttachment({ + workspacePath, + project: 'api', + runtime: 'python', + framework: 'microsoft-agent-framework', + instanceName: 'primary', + }); + await applyPreparedAgentFrameworkAttachment({ prepared, grantedBy: 'test-maintainer' }); + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + + const verified = await verifyProofCarryingChange({ + workspacePath, + changeId: prepared.changeId, + strict: false, + }); + expect(verified.state).toBe('executing'); + expect(verified.capsule.status).toBe('open'); + expect( + verified.capsule.assurances.find((assurance) => assurance.id === 'independently-verified') + ?.status + ).toBe('pending'); + const surpriseArtifact = verified.capsule.surpriseReport?.artifact; + expect(surpriseArtifact).toBeTruthy(); + const surprise = await fsExtra.readJson(path.join(workspacePath, surpriseArtifact!)); + expect(surprise.summary.unpredicted, JSON.stringify(surprise.unpredicted, null, 2)).toBe(0); + await expect( + inspectGoalLifecycle({ workspacePath, goalId: prepared.goalId, validateBindings: true }) + ).resolves.toMatchObject({ + active: expect.objectContaining({ id: prepared.goalId }), + }); + }); + + it('creates all four kits sequentially without invalidating earlier Goals', async () => { + const { workspacePath } = await fixture({ + extraProjects: ['worker', 'web', 'backend'], + }); + const kits = [ + { project: 'api', runtime: 'python' as const, framework: 'microsoft-agent-framework' }, + { project: 'worker', runtime: 'python' as const, framework: 'openai-agents' }, + { project: 'web', runtime: 'node' as const, framework: 'openai-agents' }, + { project: 'backend', runtime: 'dotnet' as const, framework: 'microsoft-agent-framework' }, + ]; + const attached = []; + for (const kit of kits) { + const prepared = await prepareAgentFrameworkAttachment({ + workspacePath, + project: kit.project, + runtime: kit.runtime, + framework: kit.framework, + instanceName: 'primary', + }); + await applyPreparedAgentFrameworkAttachment({ prepared, grantedBy: 'test-maintainer' }); + await runWorkspaceIntelligenceChain({ workspacePath, strict: false, agent: 'generic' }); + attached.push(prepared); + } + + for (const prepared of attached) { + await expect( + inspectGoalLifecycle({ workspacePath, goalId: prepared.goalId, validateBindings: true }) + ).resolves.toMatchObject({ + active: expect.objectContaining({ id: prepared.goalId }), + }); + } + + const firstVerified = await verifyProofCarryingChange({ + workspacePath, + changeId: attached[0]!.changeId, + strict: false, + }); + expect( + firstVerified.capsule.assurances.find( + (assurance) => assurance.id === 'independently-verified' + )?.status + ).toBe('pending'); + expect(firstVerified.capsule.status).toBe('open'); + const surpriseArtifact = firstVerified.capsule.surpriseReport?.artifact; + expect(surpriseArtifact).toBeTruthy(); + const surprise = await fsExtra.readJson(path.join(workspacePath, surpriseArtifact!)); + expect( + surprise.unpredicted.some( + (operation: { targetKind: string; targetId: string }) => + operation.targetKind === 'artifact' && + (operation.targetId.startsWith('worker/') || + operation.targetId.startsWith('web/') || + operation.targetId.startsWith('backend/')) + ) + ).toBe(false); + }, 60_000); }); diff --git a/packages/cli/src/__tests__/agent-framework-version-automation.test.ts b/packages/cli/src/__tests__/agent-framework-version-automation.test.ts index 2d359bbc..3d447025 100644 --- a/packages/cli/src/__tests__/agent-framework-version-automation.test.ts +++ b/packages/cli/src/__tests__/agent-framework-version-automation.test.ts @@ -52,7 +52,26 @@ describe('agent framework version automation', () => { } } const conformance = workflows[1]!; - expect(conformance).toContain('os: [ubuntu-latest, macos-latest, windows-latest]'); + expect(conformance).toContain('qualification_mode:'); + expect(conformance).toContain("'packages/cli/src/__tests__/agent-framework-*.test.ts'"); + expect(conformance).toContain( + 'inputs.qualification_mode == \'full\' && \'["ubuntu-latest","macos-latest","windows-latest"]\' || \'["ubuntu-latest"]\'' + ); + expect(conformance).toContain( + "github.event_name == 'workflow_dispatch' && inputs.qualification_mode == 'full'" + ); + expect(conformance).toContain('dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d'); + expect(YAML.parse(conformance).permissions).toEqual({ + contents: 'read', + 'pull-requests': 'read', + }); + expect(conformance).toContain("- 'packages/cli/src/agent-frameworks/**'"); + expect(conformance).toContain( + "- '!packages/cli/src/agent-frameworks/adapters/openai-agents/**'" + ); + expect(conformance).toContain( + "- '!packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/**'" + ); expect(conformance).toContain('runtime: [python, dotnet]'); expect(conformance).toContain('runtime: [python, typescript]'); expect(conformance).toContain('smoke-openai-agents-adapter.ts'); @@ -97,7 +116,16 @@ describe('agent framework version automation', () => { const promotion = read('packages/cli/scripts/promote-agent-framework-release-admission.ts'); expect(promotion).toContain('AGENT_FRAMEWORK_ADMISSION_CANDIDATE_CONTRACT_PATH'); expect(promotion).toContain('digestBuiltinAgentFrameworkManifest'); + expect(promotion).toContain('digestBuiltinAgentFrameworkImplementation'); + expect(promotion).toContain('candidate.sourceCommit !== sourceCommit'); + expect(conformance).toContain('release-admissions.v2.json'); expect(promotion).toContain("repository !== 'chistiq/workspai'"); + const verify = read('packages/cli/scripts/verify-agent-framework-conformance.ts'); + expect(verify).toContain('implementationSha256ByPlatformFromReports'); + expect(verify).toContain('liveImplementationDigestBlockers'); + expect(verify).not.toContain( + 'implementationSha256: digestBuiltinAgentFrameworkImplementation(adapter)' + ); }); it('keeps generator coverage and OpenAI adapter coverage in the same Vitest gate', () => { diff --git a/packages/cli/src/__tests__/autopilot-release.test.ts b/packages/cli/src/__tests__/autopilot-release.test.ts index a51cb59d..d0e756a4 100644 --- a/packages/cli/src/__tests__/autopilot-release.test.ts +++ b/packages/cli/src/__tests__/autopilot-release.test.ts @@ -77,6 +77,7 @@ function makeWorkspaceRunReport( passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [], @@ -160,6 +161,7 @@ describe('autopilot-release', () => { passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [], @@ -201,6 +203,7 @@ describe('autopilot-release', () => { passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [], @@ -291,6 +294,7 @@ describe('autopilot-release', () => { passed: 0, failed: 0, skipped: 1, + blocked: 1, exitCode: 1, }, projects: [ @@ -350,6 +354,7 @@ describe('autopilot-release', () => { passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [], @@ -406,6 +411,7 @@ describe('autopilot-release', () => { passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [], diff --git a/packages/cli/src/__tests__/bridge-kit-metadata.test.ts b/packages/cli/src/__tests__/bridge-kit-metadata.test.ts new file mode 100644 index 00000000..fe50b6ca --- /dev/null +++ b/packages/cli/src/__tests__/bridge-kit-metadata.test.ts @@ -0,0 +1,120 @@ +import os from 'node:os'; +import path from 'node:path'; + +import fsExtra from 'fs-extra'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { enrichBridgeBackedProjectMetadata } from '../utils/bridge-kit-metadata.js'; +import { getVersion } from '../update-checker.js'; + +describe('bridge-backed project metadata enrichment', () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fsExtra.remove(root))); + }); + + async function makeProjectRoot(prefix: string): Promise { + const root = await fsExtra.mkdtemp(path.join(os.tmpdir(), prefix)); + roots.push(root); + return root; + } + + it('merges Workspai identity into Core FastAPI markers without dropping Core provenance', async () => { + const root = await makeProjectRoot('workspai-bridge-fastapi-'); + await fsExtra.outputFile( + path.join(root, 'pyproject.toml'), + '[tool.poetry]\nname = "quantum-api"\nversion = "0.1.0"\n' + ); + await fsExtra.outputJson(path.join(root, '.workspai', 'project.json'), { + kit_name: 'fastapi.standard', + profile: 'fastapi/standard', + created_at: '2026-09-19T23:52:14.240509+00:00', + rapidkit_version: '0.6.1', + }); + await fsExtra.outputJson(path.join(root, '.workspai', 'context.json'), { engine: 'pip' }); + + expect(await enrichBridgeBackedProjectMetadata(root)).toBe(true); + + expect(await fsExtra.readJson(path.join(root, '.workspai', 'project.json'))).toMatchObject({ + schema_version: '1.0', + name: path.basename(root), + slug: path.basename(root), + kind: 'backend', + runtime: 'python', + framework: 'fastapi', + kit_name: 'fastapi.standard', + profile: 'fastapi/standard', + created_at: '2026-09-19T23:52:14.240509+00:00', + engine: 'poetry', + rapidkit_version: '0.6.1', + rapidkit_core_version: '0.6.1', + workspai_version: getVersion(), + generated_by: 'workspai', + generator: { id: 'fastapi.standard', source: 'rapidkit-core-bridge', official: false }, + }); + expect(await fsExtra.readJson(path.join(root, '.workspai', 'context.json'))).toMatchObject({ + engine: 'poetry', + runtime: 'python', + framework: 'fastapi', + kind: 'backend', + source: 'core-bridge', + }); + }); + + it('keeps NestJS Python control-plane engine in context.json and records node runtime separately', async () => { + const workspace = await makeProjectRoot('workspai-bridge-ws-'); + const root = path.join(workspace, 'vault-api'); + await fsExtra.ensureDir(root); + await fsExtra.outputJson(path.join(workspace, '.workspai-workspace'), { + signature: 'RAPIDKIT_WORKSPACE', + createdBy: 'workspai-cli', + version: '0.76.0', + createdAt: '2026-09-19T23:47:22.872Z', + name: 'my-workspace', + metadata: { npm: { installMethod: 'venv' } }, + }); + await fsExtra.outputJson(path.join(root, '.workspai', 'project.json'), { + kit_name: 'nestjs.standard', + profile: 'nestjs/standard', + created_at: '2026-09-20T00:20:34.749399+00:00', + rapidkit_version: '0.6.1', + }); + await fsExtra.outputJson(path.join(root, '.workspai', 'context.json'), { engine: 'pip' }); + + expect(await enrichBridgeBackedProjectMetadata(root)).toBe(true); + + expect(await fsExtra.readJson(path.join(root, '.workspai', 'project.json'))).toMatchObject({ + schema_version: '1.0', + runtime: 'node', + framework: 'nestjs', + kit_name: 'nestjs.standard', + profile: 'nestjs/standard', + engine: 'npm', + rapidkit_core_version: '0.6.1', + generated_by: 'workspai', + }); + expect(await fsExtra.readJson(path.join(root, '.workspai', 'context.json'))).toMatchObject({ + engine: 'venv', + runtime: 'node', + framework: 'nestjs', + source: 'core-bridge', + }); + }); + + it('does not rewrite official or npm-owned kit metadata', async () => { + const root = await makeProjectRoot('workspai-bridge-official-'); + const projectJson = { + schema_version: '1.0', + kit_name: 'desktop.electron', + runtime: 'node', + generated_by: 'workspai', + }; + await fsExtra.outputJson(path.join(root, '.workspai', 'project.json'), projectJson); + + expect(await enrichBridgeBackedProjectMetadata(root)).toBe(false); + expect(await fsExtra.readJson(path.join(root, '.workspai', 'project.json'))).toEqual( + projectJson + ); + }); +}); diff --git a/packages/cli/src/__tests__/contracts/agent-framework-contract.test.ts b/packages/cli/src/__tests__/contracts/agent-framework-contract.test.ts index 442a98a1..0d3a9098 100644 --- a/packages/cli/src/__tests__/contracts/agent-framework-contract.test.ts +++ b/packages/cli/src/__tests__/contracts/agent-framework-contract.test.ts @@ -155,6 +155,7 @@ function validReport(): AgentFrameworkConformanceReport { id: 'fixture-agent', version: '1.0.0', manifestSha256: 'a'.repeat(64), + implementationSha256: 'b'.repeat(64), }, frameworkVersion: '1.2.3', cliVersion: '0.74.0', @@ -324,6 +325,11 @@ describe('agent framework contracts', () => { id: 'fixture-agent', version: '1.0.0', manifestSha256: 'b'.repeat(64), + implementationSha256ByPlatform: { + linux: 'c'.repeat(64), + darwin: 'd'.repeat(64), + win32: 'e'.repeat(64), + }, framework: { id: 'fixture' }, lanes: [ { diff --git a/packages/cli/src/__tests__/contracts/generated-contracts.test.ts b/packages/cli/src/__tests__/contracts/generated-contracts.test.ts index 5a11332c..18327881 100644 --- a/packages/cli/src/__tests__/contracts/generated-contracts.test.ts +++ b/packages/cli/src/__tests__/contracts/generated-contracts.test.ts @@ -136,7 +136,7 @@ describe('generated shared contracts (Wave B + C)', () => { readJsonContract('workspace-intelligence/agent-framework-adapter-manifest.v1.json') ).toEqual(buildAgentFrameworkAdapterManifestSchema()); expect( - readJsonContract('workspace-intelligence/agent-framework-conformance-report.v1.json') + readJsonContract('workspace-intelligence/agent-framework-conformance-report.v2.json') ).toEqual(buildAgentFrameworkConformanceReportSchema()); expect(readJsonContract('workspace-intelligence/agent-framework-change-plan.v1.json')).toEqual( buildAgentFrameworkChangePlanSchema() diff --git a/packages/cli/src/__tests__/contracts/npm-contracts-parity.test.ts b/packages/cli/src/__tests__/contracts/npm-contracts-parity.test.ts index ab770fc3..940cec2a 100644 --- a/packages/cli/src/__tests__/contracts/npm-contracts-parity.test.ts +++ b/packages/cli/src/__tests__/contracts/npm-contracts-parity.test.ts @@ -40,6 +40,7 @@ const CLI_EXTENSION_CONTRACT_FILES = [ 'workspace-intelligence/workspace-repair-transaction.v1.json', 'workspace-intelligence/agent-framework-adapter-manifest.v1.json', 'workspace-intelligence/agent-framework-conformance-report.v1.json', + 'workspace-intelligence/agent-framework-conformance-report.v2.json', 'workspace-intelligence/agent-framework-change-plan.v1.json', 'workspace-intelligence/agent-framework-ownership-receipt.v1.json', 'analyze-last-run.v1.json', diff --git a/packages/cli/src/__tests__/doctor-surface-probes.test.ts b/packages/cli/src/__tests__/doctor-surface-probes.test.ts index 077dbdf5..69e6c18d 100644 --- a/packages/cli/src/__tests__/doctor-surface-probes.test.ts +++ b/packages/cli/src/__tests__/doctor-surface-probes.test.ts @@ -1027,6 +1027,10 @@ describe('doctor enterprise surface probes', () => { expect(security?.repairCapability?.strategy?.some((stage) => stage.kind === 'safe-fix')).toBe( false ); + expect(security?.repairCapability?.files).toEqual([ + path.join(projectPath, 'package.json'), + path.join(projectPath, 'package-lock.json'), + ]); }); it('binds agent dependency and environment repairs to the nested runtime boundary', async () => { diff --git a/packages/cli/src/__tests__/handle-create-flags.test.ts b/packages/cli/src/__tests__/handle-create-flags.test.ts index 31bd3e8b..52994969 100644 --- a/packages/cli/src/__tests__/handle-create-flags.test.ts +++ b/packages/cli/src/__tests__/handle-create-flags.test.ts @@ -9,6 +9,9 @@ import path from 'path'; import * as cliPrompts from '../cli-ui/prompts.js'; import * as frontendProject from '../frontend-project.js'; import * as officialProject from '../official-project.js'; +import { listBundledAgentFrameworkReleaseAdmissions } from '../agent-frameworks/release-admission.js'; + +const releaseAdmitted = listBundledAgentFrameworkReleaseAdmissions().length === 4; describe('handleCreateOrFallback - wrapper flags handling', () => { let tmpDir: string; @@ -112,116 +115,156 @@ describe('handleCreateOrFallback - wrapper flags handling', () => { expect(output).not.toContain('rm -rf'); }); - it('creates a governed agent project through the admitted scaffold lifecycle', async () => { - await create.createProject('agent-workspace', { - parentDirectory: tmpDir, - profile: 'minimal', - skipPythonEngine: true, - skipGit: true, - yes: true, - }); - const workspacePath = path.join(tmpDir, 'agent-workspace'); - process.chdir(workspacePath); + it.skipIf(releaseAdmitted)( + 'refuses governed agent kits until v2 implementation admission is promoted', + async () => { + await create.createProject('agent-workspace', { + parentDirectory: tmpDir, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(tmpDir, 'agent-workspace'); + process.chdir(workspacePath); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - const code = await index.handleCreateOrFallback([ - 'create', - 'project', - 'agent.microsoft.python', - 'support-agent', - '--agent-name', - 'triage', - '--skip-git', - '--yes', - ]); + const code = await index.handleCreateOrFallback([ + 'create', + 'project', + 'agent.openai.python', + 'openai-python-agent', + '--skip-git', + '--yes', + ]); - expect(code).toBe(0); - const projectPath = path.join(workspacePath, 'support-agent'); - expect(await fsExtra.pathExists(path.join(projectPath, 'agents', 'triage', 'main.py'))).toBe( - true - ); - expect( - await fsExtra.pathExists( - path.join(workspacePath, '.workspai', 'agent-frameworks', 'ownership') - ) - ).toBe(true); - expect(await fsExtra.pathExists(path.join(projectPath, 'pyproject.toml'))).toBe(false); - const contract = await fsExtra.readJson( - path.join(workspacePath, '.workspai', 'workspace.contract.json') - ); - expect(contract.projects).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - slug: 'support-agent', - runtime: 'python', - kit: 'agent.microsoft.python', - ports: [], - contracts: expect.objectContaining({ - env: expect.arrayContaining(['FOUNDRY_PROJECT_ENDPOINT', 'FOUNDRY_MODEL']), + expect(code).toBe(1); + expect(await fsExtra.pathExists(path.join(workspacePath, 'openai-python-agent'))).toBe(false); + expect(stderrSpy.mock.calls.map((call) => String(call[0])).join('')).toMatch( + /not release-admitted/i + ); + }, + 60_000 + ); + + it.skipIf(!releaseAdmitted)( + 'creates a governed agent project through the admitted scaffold lifecycle', + async () => { + await create.createProject('agent-workspace', { + parentDirectory: tmpDir, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(tmpDir, 'agent-workspace'); + process.chdir(workspacePath); + + const code = await index.handleCreateOrFallback([ + 'create', + 'project', + 'agent.microsoft.python', + 'support-agent', + '--agent-name', + 'triage', + '--skip-git', + '--yes', + ]); + + expect(code).toBe(0); + const projectPath = path.join(workspacePath, 'support-agent'); + expect(await fsExtra.pathExists(path.join(projectPath, 'agents', 'triage', 'main.py'))).toBe( + true + ); + expect( + await fsExtra.pathExists( + path.join(workspacePath, '.workspai', 'agent-frameworks', 'ownership') + ) + ).toBe(true); + expect(await fsExtra.pathExists(path.join(projectPath, 'pyproject.toml'))).toBe(false); + const contract = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'workspace.contract.json') + ); + expect(contract.projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + slug: 'support-agent', + runtime: 'python', + kit: 'agent.microsoft.python', + ports: [], + contracts: expect.objectContaining({ + env: expect.arrayContaining(['FOUNDRY_PROJECT_ENDPOINT', 'FOUNDRY_MODEL']), + }), }), - }), - ]) - ); - const model = await fsExtra.readJson( - path.join(workspacePath, '.workspai', 'reports', 'workspace-model.json') - ); - const modeled = model.projects.find( - (project: { name?: string }) => project.name === 'support-agent' - ); - expect(modeled).toMatchObject({ - kind: 'agent', - framework: 'microsoft-agent-framework', - kit: 'agent.microsoft.python', - path: 'support-agent', - }); - expect(modeled.commands.fleetStages).toEqual( - expect.arrayContaining(['init', 'test', 'build', 'start']) - ); - }, 90_000); + ]) + ); + const model = await fsExtra.readJson( + path.join(workspacePath, '.workspai', 'reports', 'workspace-model.json') + ); + const modeled = model.projects.find( + (project: { name?: string }) => project.name === 'support-agent' + ); + expect(modeled).toMatchObject({ + kind: 'agent', + framework: 'microsoft-agent-framework', + kit: 'agent.microsoft.python', + path: 'support-agent', + }); + expect(modeled.commands.fleetStages).toEqual( + expect.arrayContaining(['init', 'test', 'build', 'start']) + ); + }, + 90_000 + ); - it('creates governed OpenAI agent projects after reviewed release admission', async () => { - await create.createProject('agent-workspace', { - parentDirectory: tmpDir, - profile: 'minimal', - skipPythonEngine: true, - skipGit: true, - yes: true, - }); - const workspacePath = path.join(tmpDir, 'agent-workspace'); - process.chdir(workspacePath); + it.skipIf(!releaseAdmitted)( + 'creates governed OpenAI agent projects after reviewed release admission', + async () => { + await create.createProject('agent-workspace', { + parentDirectory: tmpDir, + profile: 'minimal', + skipPythonEngine: true, + skipGit: true, + yes: true, + }); + const workspacePath = path.join(tmpDir, 'agent-workspace'); + process.chdir(workspacePath); - const pythonCode = await index.handleCreateOrFallback([ - 'create', - 'project', - 'agent.openai.python', - 'openai-python-agent', - '--skip-git', - '--yes', - ]); - const typescriptCode = await index.handleCreateOrFallback([ - 'create', - 'project', - 'agent.openai.typescript', - 'openai-typescript-agent', - '--skip-git', - '--yes', - ]); + const pythonCode = await index.handleCreateOrFallback([ + 'create', + 'project', + 'agent.openai.python', + 'openai-python-agent', + '--skip-git', + '--yes', + ]); + const typescriptCode = await index.handleCreateOrFallback([ + 'create', + 'project', + 'agent.openai.typescript', + 'openai-typescript-agent', + '--skip-git', + '--yes', + ]); - expect(pythonCode).toBe(0); - expect(typescriptCode).toBe(0); - expect( - await fsExtra.pathExists( - path.join(workspacePath, 'openai-python-agent', 'agents', 'primary', 'main.py') - ) - ).toBe(true); - expect( - await fsExtra.pathExists( - path.join(workspacePath, 'openai-typescript-agent', 'agents', 'primary', 'src', 'main.ts') - ) - ).toBe(true); - expect( - await fsExtra.pathExists(path.join(workspacePath, 'openai-python-agent', 'pyproject.toml')) - ).toBe(false); - }, 90_000); + expect(pythonCode).toBe(0); + expect(typescriptCode).toBe(0); + expect( + await fsExtra.pathExists( + path.join(workspacePath, 'openai-python-agent', 'agents', 'primary', 'main.py') + ) + ).toBe(true); + expect( + await fsExtra.pathExists( + path.join(workspacePath, 'openai-typescript-agent', 'agents', 'primary', 'src', 'main.ts') + ) + ).toBe(true); + expect( + await fsExtra.pathExists(path.join(workspacePath, 'openai-python-agent', 'pyproject.toml')) + ).toBe(false); + }, + 90_000 + ); it('rolls back project registration when the governed scaffold cannot be planned', async () => { await create.createProject('agent-workspace', { diff --git a/packages/cli/src/__tests__/index-workspace-command-coverage.test.ts b/packages/cli/src/__tests__/index-workspace-command-coverage.test.ts index ce6ee853..b9f1f783 100644 --- a/packages/cli/src/__tests__/index-workspace-command-coverage.test.ts +++ b/packages/cli/src/__tests__/index-workspace-command-coverage.test.ts @@ -633,14 +633,11 @@ describe.sequential('in-process workspace Commander coverage', () => { '--dry-run', '--json', ]); - await runWorkspaceCommand(root, [ - 'run', - 'build', - '--workspace', + await runWorkspaceCommandExpectExit( root, - '--json', - '--continue-on-error', - ]); + ['run', 'build', '--workspace', root, '--json', '--continue-on-error'], + 1 + ); await runWorkspaceCommandExpectExit(root, ['run', 'dev', '--workspace', root], 2); await runWorkspaceCommandExpectExit(root, ['feedback', 'unknown', '--workspace', root], 1); diff --git a/packages/cli/src/__tests__/microsoft-agent-framework-adapter.test.ts b/packages/cli/src/__tests__/microsoft-agent-framework-adapter.test.ts index b4f4b8c3..25fd7d32 100644 --- a/packages/cli/src/__tests__/microsoft-agent-framework-adapter.test.ts +++ b/packages/cli/src/__tests__/microsoft-agent-framework-adapter.test.ts @@ -98,6 +98,7 @@ describe('Microsoft Agent Framework adapters', () => { 'agents/release-reviewer/main.py', 'agents/release-reviewer/pyproject.toml', 'agents/release-reviewer/tests/test_context.py', + 'agents/release-reviewer/tests/test_framework.py', 'agents/release-reviewer/.env.example', 'agents/release-reviewer/README.md', '.workspai/agent-frameworks/microsoft-agent-framework-python/release-reviewer.json', @@ -133,10 +134,16 @@ describe('Microsoft Agent Framework adapters', () => { expect(contextFile).not.toContain('Path.cwd'); const generatedTests = first.files.find((file) => file.path.endsWith('/tests/test_context.py'))?.content ?? ''; - expect(generatedTests).toContain('setUpClass'); - expect(generatedTests).toContain('_restore_live_context'); + const generatedFrameworkTests = + first.files.find((file) => file.path.endsWith('/tests/test_framework.py'))?.content ?? ''; + expect(generatedTests).toContain('bind_workspai_project_root_for_tests'); + expect(generatedTests).not.toContain('_restore_live_context'); expect(generatedTests).toContain('test_allowlisted_views_omit_non_admitted_keys'); - expect(generatedTests).toContain('test_local_chat_client_loop_stays_offline'); + expect(generatedFrameworkTests).toContain( + 'agent-framework is required for this release-admitted kit' + ); + expect(generatedFrameworkTests).not.toContain('skipTest("agent-framework is not installed")'); + expect(generatedTests).not.toContain('RequiredFrameworkLoopTests'); expect(generatedTests).not.toContain('main.Path.cwd'); expect(dependencies).toContain('[build-system]'); expect(dependencies).toContain('setuptools'); @@ -187,9 +194,15 @@ describe('Microsoft Agent Framework adapters', () => { expect(contextLoader).toContain('ProjectSummaryAsync'); expect(contextLoader).toContain('SupportedCommandsAsync'); expect(contextLoader).toContain('RedactSecretShapedValues'); + expect(contextLoader).toContain( + 'new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)' + ); + expect(contextLoader).toContain('if (!stream.CanSeek)'); + expect(contextLoader).toContain('FileAttributes.Device'); const generatedTests = rendered.files.find((file) => file.path.endsWith('/WorkspaiContextTests.cs'))?.content ?? ''; expect(generatedTests).toContain('AllowlistedViewsOmitNonAdmittedKeys'); + expect(generatedTests).toContain('RejectsMalformedUtf8WithoutDisclosingContents'); expect(project).toContain( `Microsoft.Agents.AI.Foundry" Version="${packageVersion(MICROSOFT_AGENT_FRAMEWORK_DOTNET_BASELINE, 'Microsoft.Agents.AI.Foundry')}"` ); @@ -331,7 +344,7 @@ describe('Microsoft Agent Framework adapters', () => { expect(result.stdout).toBe(admitted); const generatedSuite = spawnSync( 'python3', - ['-m', 'unittest', 'discover', '-s', 'tests', '-v'], + ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_context.py', '-v'], { cwd: agentRoot, encoding: 'utf8', diff --git a/packages/cli/src/__tests__/openai-agents-adapter.test.ts b/packages/cli/src/__tests__/openai-agents-adapter.test.ts index 40026fc6..b5d63907 100644 --- a/packages/cli/src/__tests__/openai-agents-adapter.test.ts +++ b/packages/cli/src/__tests__/openai-agents-adapter.test.ts @@ -48,7 +48,7 @@ function assertCompleteManifest(manifest: AgentFrameworkAdapterManifest) { expect(validateAgentFrameworkAdapterManifest(manifest)).toEqual([]); expect(Object.values(manifest.operations).every((operation) => operation.supported)).toBe(true); expect(manifest.framework.id).toBe('openai-agents'); - expect(manifest.adapter.stability).toBe('preview'); + expect(manifest.adapter.stability).toBe('stable'); expect(manifest.security.secrets).toBe('references-only'); expect(manifest.ownership.mutationAdmission).toBe('workspai-pcc'); expect(manifest.capabilities['single-agent']?.support).toBe('native'); @@ -148,6 +148,7 @@ describe('OpenAI Agents SDK adapters', () => { 'agents/release-reviewer/main.py', 'agents/release-reviewer/pyproject.toml', 'agents/release-reviewer/tests/test_context.py', + 'agents/release-reviewer/tests/test_framework.py', 'agents/release-reviewer/.env.example', 'agents/release-reviewer/README.md', '.workspai/agent-frameworks/openai-agents-python/release-reviewer.json', @@ -160,18 +161,27 @@ describe('OpenAI Agents SDK adapters', () => { const entrypoint = first.files.find((file) => file.path.endsWith('/main.py'))?.content ?? ''; const generatedTests = first.files.find((file) => file.path.endsWith('/tests/test_context.py'))?.content ?? ''; + const generatedFrameworkTests = + first.files.find((file) => file.path.endsWith('/tests/test_framework.py'))?.content ?? ''; const dependencies = first.files.find((file) => file.path.endsWith('/pyproject.toml'))?.content ?? ''; expect(agent).toContain('from agents import Agent, ModelSettings, function_tool'); expect(agent).toContain('ModelSettings(timeout=MODEL_TIMEOUT_SECONDS)'); expect(agent).toContain('if model is not None:'); - expect(generatedTests).toContain('setUpClass'); - expect(generatedTests).toContain('_restore_live_context'); - expect(generatedTests).toContain('test_scripted_model_tool_call_stays_offline'); - expect(generatedTests).toContain('ScriptedModel'); + expect(generatedTests).toContain('bind_workspai_project_root_for_tests'); + expect(generatedTests).not.toContain('_restore_live_context'); + expect(generatedFrameworkTests).toContain( + 'openai-agents is required for this release-admitted kit' + ); + expect(generatedFrameworkTests).not.toContain('skipTest("openai-agents is not installed")'); + expect(generatedFrameworkTests).toContain('ScriptedModel'); + expect(generatedTests).not.toContain('ScriptedModel'); expect(generatedTests).toContain('test_allowlisted_views_omit_non_admitted_keys'); expect(agent).toContain('@function_tool(failure_error_function=None)'); expect(agent).toContain('describe_workspai_context'); + expect(agent).toContain('async def describe_workspai_context()'); + expect(agent).toContain('async def read_workspai_project_summary()'); + expect(agent).toContain('async def list_workspai_supported_commands()'); expect(agent).toContain('read_workspai_project_summary'); expect(agent).toContain('list_workspai_supported_commands'); expect(agent).toContain('OPENAI_API_KEY is not set'); @@ -235,8 +245,10 @@ describe('OpenAI Agents SDK adapters', () => { expect(contextFile).toContain('listWorkspaiSupportedCommands'); expect(contextFile).toContain('redactSecretShapedValues'); expect(contextFile).not.toContain('process.cwd()'); - expect(generatedTests).toContain('restoreLiveContext'); - expect(generatedTests).toContain('before(isolateLiveContext)'); + expect(generatedTests).toContain('createTemporaryProjectFixture'); + expect(generatedTests).toContain('bindWorkspaiProjectRootForTests'); + expect(generatedTests).toContain('before(createTemporaryProjectFixture)'); + expect(generatedTests).not.toContain('isolateLiveContext'); expect(generatedTests).toContain('scripted model tool call stays offline'); expect(generatedTests).toContain('ScriptedModel'); expect(generatedTests).toContain('allowlisted views omit non-admitted keys'); diff --git a/packages/cli/src/__tests__/openai-agents-context-loader.test.ts b/packages/cli/src/__tests__/openai-agents-context-loader.test.ts index f6cfd089..e857b42c 100644 --- a/packages/cli/src/__tests__/openai-agents-context-loader.test.ts +++ b/packages/cli/src/__tests__/openai-agents-context-loader.test.ts @@ -361,7 +361,7 @@ assert loaded['marker'] == 'nested' expect(result.status, diagnostic(result)).toBe(0); }); - it('Python generated context tests restore the operational context file', async () => { + it('Python generated context tests never mutate the operational context file', async () => { const root = await temporaryProject('workspai-oai-py-restore-'); const rendered = openaiAgentsPythonAdapter.render({ projectRoot: root, @@ -371,11 +371,15 @@ assert loaded['marker'] == 'nested' const marker = `live-marker-${process.pid}`; const contextPath = await writeContext(root, admittedContext({ marker })); const before = await readFile(contextPath, 'utf8'); - const result = spawnSync('python3', ['-m', 'unittest', 'discover', '-s', 'tests'], { - cwd: path.join(root, 'agents', 'release-reviewer'), - encoding: 'utf8', - env: { ...process.env, OPENAI_AGENTS_DISABLE_TRACING: '1' }, - }); + const result = spawnSync( + 'python3', + ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_context.py'], + { + cwd: path.join(root, 'agents', 'release-reviewer'), + encoding: 'utf8', + env: { ...process.env, OPENAI_AGENTS_DISABLE_TRACING: '1' }, + } + ); expect(result.status, diagnostic(result)).toBe(0); expect(await readFile(contextPath, 'utf8')).toBe(before); }); diff --git a/packages/cli/src/__tests__/openai-agents-lifecycle.test.ts b/packages/cli/src/__tests__/openai-agents-lifecycle.test.ts index 74d94aa2..10ae38f7 100644 --- a/packages/cli/src/__tests__/openai-agents-lifecycle.test.ts +++ b/packages/cli/src/__tests__/openai-agents-lifecycle.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createBuiltinAgentFrameworkRegistry, + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, openaiAgentsPythonAdapter, openaiAgentsTypeScriptAdapter, @@ -33,6 +34,7 @@ function admittedRegistry( adapter: typeof openaiAgentsPythonAdapter | typeof openaiAgentsTypeScriptAdapter ) { const manifestSha256 = digestBuiltinAgentFrameworkManifest(adapter); + const implementationSha256 = digestBuiltinAgentFrameworkImplementation(adapter); const reports = adapter.manifest.implementation.platforms.map((platform) => { const checks = AGENT_FRAMEWORK_CONFORMANCE_CHECK_IDS.map((id) => ({ id, @@ -50,9 +52,10 @@ function admittedRegistry( id: adapter.manifest.adapter.id, version: adapter.manifest.adapter.version, manifestSha256, + implementationSha256, }, frameworkVersion: adapter.manifest.framework.testedVersions[0], - cliVersion: '0.75.2', + cliVersion: '0.76.0', environment: { platform, architecture: 'x64', diff --git a/packages/cli/src/__tests__/package-publish-contract.test.ts b/packages/cli/src/__tests__/package-publish-contract.test.ts index 4a3b4d30..76a35886 100644 --- a/packages/cli/src/__tests__/package-publish-contract.test.ts +++ b/packages/cli/src/__tests__/package-publish-contract.test.ts @@ -172,7 +172,17 @@ describe('npm publish contract', () => { expect(smoke).toContain("adapter.status === 'admitted'"); expect(smoke).toContain('microsoft-agent-framework-python'); expect(smoke).toContain('openai-agents-typescript'); + expect(smoke).toContain("openai.stability !== 'stable'"); + expect(smoke).toContain('openai-agents-python'); + expect(smoke).toContain('agent kit smoke did not record ownership receipts'); + expect(smoke).toContain('recorded ownership receipts without a release-admitted create'); expect(smoke).not.toContain('adapters?.length !== 2'); + + const listCommand = fs.readFileSync( + path.join(process.cwd(), 'src/commands/agent-framework.ts'), + 'utf8' + ); + expect(listCommand).toContain('stability: entry.manifest.adapter.stability'); }); it('keeps npm-only contributor enforcement out of consumer install lifecycles', () => { diff --git a/packages/cli/src/__tests__/polyglot-lifecycle-plan.test.ts b/packages/cli/src/__tests__/polyglot-lifecycle-plan.test.ts index c365e704..72b11e90 100644 --- a/packages/cli/src/__tests__/polyglot-lifecycle-plan.test.ts +++ b/packages/cli/src/__tests__/polyglot-lifecycle-plan.test.ts @@ -7,7 +7,10 @@ import { buildPolyglotLifecyclePlan } from '../polyglot-lifecycle-plan.js'; describe('polyglot lifecycle plan', () => { const tempDirs: string[] = []; - const python = process.platform === 'win32' ? 'python' : 'python3'; + const nestedAgentPython = + process.platform === 'win32' ? '../../.venv/Scripts/python.exe' : '../../.venv/bin/python'; + const siblingPython = + process.platform === 'win32' ? '../.venv/Scripts/python.exe' : '../.venv/bin/python'; afterEach(async () => { await Promise.all(tempDirs.splice(0).map((directory) => fs.remove(directory))); @@ -333,10 +336,21 @@ describe('polyglot lifecycle plan', () => { expect(buildPolyglotLifecyclePlan(root).units[0]?.stages).toEqual( expect.arrayContaining([ - expect.objectContaining({ stage: 'build', command: `${python} -m compileall .` }), - expect.objectContaining({ stage: 'start', command: `${python} main.py` }), + expect.objectContaining({ + stage: 'init', + command: expect.stringContaining('-m venv'), + }), + expect.objectContaining({ + stage: 'build', + command: `${nestedAgentPython} -m compileall .`, + }), + expect.objectContaining({ stage: 'start', command: `${nestedAgentPython} main.py` }), ]) ); + expect( + buildPolyglotLifecyclePlan(root).units[0]?.stages.find((stage) => stage.stage === 'init') + ?.command + ).not.toMatch(/(?:^|&& )(?:python3|python) -m pip install -e \./); }); it('compiles a Python agent with a build-system table instead of invoking python -m build', async () => { @@ -350,12 +364,17 @@ describe('polyglot lifecycle plan', () => { expect(buildPolyglotLifecyclePlan(root).units[0]?.stages).toEqual( expect.arrayContaining([ - expect.objectContaining({ stage: 'build', command: `${python} -m compileall .` }), - expect.objectContaining({ stage: 'start', command: `${python} main.py` }), + expect.objectContaining({ + stage: 'build', + command: `${nestedAgentPython} -m compileall .`, + }), + expect.objectContaining({ stage: 'start', command: `${nestedAgentPython} main.py` }), ]) ); expect(buildPolyglotLifecyclePlan(root).units[0]?.stages).not.toEqual( - expect.arrayContaining([expect.objectContaining({ command: `${python} -m build` })]) + expect.arrayContaining([ + expect.objectContaining({ command: `${nestedAgentPython} -m build` }), + ]) ); }); @@ -369,7 +388,7 @@ describe('polyglot lifecycle plan', () => { expect(buildPolyglotLifecyclePlan(root).units[0]?.stages).toEqual( expect.arrayContaining([ - expect.objectContaining({ stage: 'build', command: `${python} -m build` }), + expect.objectContaining({ stage: 'build', command: `${siblingPython} -m build` }), ]) ); }); @@ -397,12 +416,12 @@ describe('polyglot lifecycle plan', () => { plan.units .find((unit) => unit.root === 'stdlib') ?.stages.find((stage) => stage.stage === 'test') - ).toMatchObject({ command: `${python} -m unittest discover -s tests` }); + ).toMatchObject({ command: `${siblingPython} -m unittest discover -s tests` }); expect( plan.units .find((unit) => unit.root === 'pytest-owned') ?.stages.find((stage) => stage.stage === 'test') - ).toMatchObject({ command: `${python} -m pytest` }); + ).toMatchObject({ command: `${siblingPython} -m pytest` }); }); it('treats a direct .NET test project as the only test execution boundary', async () => { diff --git a/packages/cli/src/__tests__/python-lifecycle-interpreter.test.ts b/packages/cli/src/__tests__/python-lifecycle-interpreter.test.ts new file mode 100644 index 00000000..a0881d21 --- /dev/null +++ b/packages/cli/src/__tests__/python-lifecycle-interpreter.test.ts @@ -0,0 +1,179 @@ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + findOwningWorkspaiProjectRoot, + getVenvPythonPath, + resolvePythonLifecycleInterpreter, +} from '../utils/platform-capabilities.js'; + +const tempDirs: string[] = []; + +async function tempDir(prefix: string): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(directory); + return directory; +} + +async function writeFakePython(filePath: string): Promise { + await fs.ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, '#!/usr/bin/env python3\n'); + try { + await fs.chmod(filePath, 0o755); + } catch { + // Windows fixtures only need the path to exist as a file. + } +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((directory) => fs.remove(directory))); +}); + +describe('python lifecycle interpreter', () => { + it('prefers the owning Workspai project venv on POSIX', async () => { + const project = await tempDir('workspai-python-project-venv-'); + const unit = path.join(project, 'agents', 'primary'); + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { name: 'agent' }); + await fs.ensureDir(unit); + const interpreter = getVenvPythonPath(path.join(project, '.venv'), 'linux'); + await writeFakePython(interpreter); + + const resolved = resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: {}, + platform: 'linux', + }); + expect(findOwningWorkspaiProjectRoot(unit)).toBe(path.resolve(project)); + expect(resolved.source).toBe('project-venv'); + expect(resolved.interpreter).toBe(interpreter); + expect(resolved.usedForDependencyInstall).toBe(true); + }); + + it('resolves Windows Scripts/python.exe', async () => { + const project = await tempDir('workspai-python-windows-venv-'); + const unit = path.join(project, 'agents', 'primary'); + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { name: 'agent' }); + await fs.ensureDir(unit); + const interpreter = getVenvPythonPath(path.join(project, '.venv'), 'win32'); + await writeFakePython(interpreter); + + const resolved = resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: {}, + platform: 'win32', + }); + expect(resolved.source).toBe('project-venv'); + expect(resolved.interpreter).toBe(interpreter); + expect(path.basename(resolved.interpreter)).toBe('python.exe'); + }); + + it('accepts an active venv only when it belongs to the project', async () => { + const project = await tempDir('workspai-python-active-venv-'); + const unit = path.join(project, 'agents', 'primary'); + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { name: 'agent' }); + await fs.ensureDir(unit); + const owned = path.join(project, '.venv'); + await writeFakePython(getVenvPythonPath(owned, 'linux')); + + const unrelated = await tempDir('workspai-python-unrelated-venv-'); + await writeFakePython(getVenvPythonPath(unrelated, 'linux')); + + expect( + resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: { VIRTUAL_ENV: owned }, + platform: 'linux', + }).source + ).toBe('project-venv'); + + const ignored = resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: { VIRTUAL_ENV: unrelated }, + platform: 'linux', + }); + expect(ignored.source).toBe('project-venv'); + }); + + it('ignores an unrelated active venv when the project has no venv', async () => { + const project = await tempDir('workspai-python-unrelated-active-'); + const unit = path.join(project, 'agents', 'primary'); + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { name: 'agent' }); + await fs.ensureDir(unit); + const unrelated = await tempDir('workspai-python-foreign-venv-'); + await writeFakePython(getVenvPythonPath(unrelated, 'linux')); + + const resolved = resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: { VIRTUAL_ENV: unrelated }, + platform: 'linux', + }); + expect(resolved.source).toBe('system'); + expect(resolved.usedForDependencyInstall).toBe(false); + expect(resolved.interpreter).toBe('python3'); + }); + + it('keeps two projects isolated by their own venvs', async () => { + const workspace = await tempDir('workspai-python-multi-'); + const first = path.join(workspace, 'alpha with spaces'); + const second = path.join(workspace, 'beta'); + for (const project of [first, second]) { + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { + name: path.basename(project), + }); + await fs.ensureDir(path.join(project, 'agents', 'primary')); + await writeFakePython(getVenvPythonPath(path.join(project, '.venv'), 'linux')); + } + + const alpha = resolvePythonLifecycleInterpreter({ + unitRoot: path.join(first, 'agents', 'primary'), + platform: 'linux', + env: {}, + }); + const beta = resolvePythonLifecycleInterpreter({ + unitRoot: path.join(second, 'agents', 'primary'), + platform: 'linux', + env: {}, + }); + expect(alpha.interpreter).toBe(getVenvPythonPath(path.join(first, '.venv'), 'linux')); + expect(beta.interpreter).toBe(getVenvPythonPath(path.join(second, '.venv'), 'linux')); + expect(alpha.interpreter).not.toBe(beta.interpreter); + }); + + it('does not install dependencies through an external WORKSPAI_PYTHON interpreter', async () => { + const project = await tempDir('workspai-python-configured-'); + const unit = path.join(project, 'agents', 'primary'); + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { name: 'agent' }); + await fs.ensureDir(unit); + const configured = path.join(project, 'custom', 'python'); + await writeFakePython(configured); + + const resolved = resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: { WORKSPAI_PYTHON: configured }, + platform: 'linux', + }); + expect(resolved.source).toBe('system'); + expect(resolved.interpreter).toBe('python3'); + expect(resolved.usedForDependencyInstall).toBe(false); + }); + + it('uses WORKSPAI_PYTHON when it belongs to the project venv', async () => { + const project = await tempDir('workspai-python-configured-owned-'); + const unit = path.join(project, 'agents', 'primary'); + await fs.outputJson(path.join(project, '.workspai', 'project.json'), { name: 'agent' }); + await fs.ensureDir(unit); + const configured = getVenvPythonPath(path.join(project, '.venv'), 'linux'); + await writeFakePython(configured); + + const resolved = resolvePythonLifecycleInterpreter({ + unitRoot: unit, + env: { WORKSPAI_PYTHON: configured }, + platform: 'linux', + }); + expect(resolved.source).toBe('configured'); + expect(resolved.interpreter).toBe(configured); + expect(resolved.usedForDependencyInstall).toBe(true); + }); +}); diff --git a/packages/cli/src/__tests__/runtime-adapters.test.ts b/packages/cli/src/__tests__/runtime-adapters.test.ts index 2ae5724f..e9baa8a8 100644 --- a/packages/cli/src/__tests__/runtime-adapters.test.ts +++ b/packages/cli/src/__tests__/runtime-adapters.test.ts @@ -12,6 +12,7 @@ import { areRuntimeAdaptersEnabled, getRuntimeAdapter } from '../runtime-adapter const normalizePath = (value: string | undefined): string => (value || '').replace(/\\/g, '/'); const ORIGINAL_JAVA_HOME = process.env.JAVA_HOME; +const ORIGINAL_BUN_INSTALL_CACHE_DIR = process.env.BUN_INSTALL_CACHE_DIR; function mockNodePackageScripts( projectPath: string, @@ -60,6 +61,11 @@ describe('Runtime Adapters', () => { } else { process.env.JAVA_HOME = ORIGINAL_JAVA_HOME; } + if (typeof ORIGINAL_BUN_INSTALL_CACHE_DIR === 'undefined') { + delete process.env.BUN_INSTALL_CACHE_DIR; + } else { + process.env.BUN_INSTALL_CACHE_DIR = ORIGINAL_BUN_INSTALL_CACHE_DIR; + } }); describe('GoRuntimeAdapter', () => { @@ -1434,6 +1440,7 @@ describe('Runtime Adapters', () => { }); it('uses Bun for a Bun-locked package script and restores its cache environment', async () => { + delete process.env.BUN_INSTALL_CACHE_DIR; process.env.RAPIDKIT_DEP_SHARING_MODE = 'shared-runtime-caches'; process.env.RAPIDKIT_WORKSPACE_PATH = '/tmp/workspace'; let seenCache = ''; diff --git a/packages/cli/src/__tests__/workspace-contract.test.ts b/packages/cli/src/__tests__/workspace-contract.test.ts index d417af4e..0d017f2e 100644 --- a/packages/cli/src/__tests__/workspace-contract.test.ts +++ b/packages/cli/src/__tests__/workspace-contract.test.ts @@ -299,6 +299,53 @@ describe('workspace contract registry', () => { expect(contract.projects[0].ports).toEqual([{ name: 'http', port: 3000, protocol: 'http' }]); }); + it('infers runtime for Core-shaped FastAPI markers that omit runtime', async () => { + const workspacePath = await makeTempDir('rk-contract-core-fastapi-runtime-'); + await fsExtra.outputJson(path.join(workspacePath, 'quantum-api', '.workspai', 'project.json'), { + kit_name: 'fastapi.standard', + profile: 'fastapi/standard', + rapidkit_version: '0.6.1', + }); + + const contract = await buildWorkspaceContract({ workspacePath }); + expect(contract.projects[0]).toMatchObject({ + slug: 'quantum-api', + runtime: 'python', + framework: 'fastapi', + kit: 'fastapi.standard', + }); + }); + + it('does not invent HTTP ports for Electron or VS Code extension kits', async () => { + const workspacePath = await makeTempDir('rk-contract-nonservice-ports-'); + await fsExtra.outputJson( + path.join(workspacePath, 'atlas-desktop', '.workspai', 'project.json'), + { + schema_version: '1.0', + runtime: 'node', + kind: 'desktop', + kit_name: 'desktop.electron', + } + ); + await fsExtra.outputJson( + path.join(workspacePath, 'zenith-extension', '.workspai', 'project.json'), + { + schema_version: '1.0', + runtime: 'node', + kind: 'extension', + kit_name: 'extension.vscode', + } + ); + + const { contract } = await writeWorkspaceContract({ workspacePath }); + expect(contract.projects.find((project) => project.slug === 'atlas-desktop')?.ports).toEqual( + [] + ); + expect(contract.projects.find((project) => project.slug === 'zenith-extension')?.ports).toEqual( + [] + ); + }); + it('does not rewrite canonical contract artifacts when sync has no semantic changes', async () => { const workspacePath = await makeTempDir('rk-contract-idempotent-'); await fsExtra.outputJson(path.join(workspacePath, 'api', '.workspai', 'project.json'), { diff --git a/packages/cli/src/__tests__/workspace-run-evidence.test.ts b/packages/cli/src/__tests__/workspace-run-evidence.test.ts index 34830cb6..ff432d18 100644 --- a/packages/cli/src/__tests__/workspace-run-evidence.test.ts +++ b/packages/cli/src/__tests__/workspace-run-evidence.test.ts @@ -54,6 +54,7 @@ function makeStageReport( passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [], @@ -131,4 +132,81 @@ describe('workspace run evidence', () => { ).rejects.toThrow('refusing to overwrite'); expect(await fsExtra.readFile(reportPath, 'utf-8')).toBe('{invalid json'); }); + + it('keeps timestamped project history without presenting old rows as the latest run', async () => { + const workspace = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'rk-workspace-run-merge-')); + createdPaths.push(workspace); + const base = makeStageReport(workspace, 'test'); + const first: WorkspaceRunReport = { + ...base, + generatedAt: '2026-06-16T12:00:00.000Z', + projects: [ + { + path: path.join(workspace, 'alpha'), + relativePath: 'alpha', + projectName: 'alpha', + selected: true, + affected: true, + status: 'passed', + exitCode: 0, + durationMs: 10, + executionCommand: 'python -m unittest', + }, + { + path: path.join(workspace, 'beta'), + relativePath: 'beta', + projectName: 'beta', + selected: false, + affected: false, + status: 'skipped', + exitCode: null, + durationMs: 0, + reason: 'outside scope', + }, + ], + summary: { ...base.summary, selectedCount: 1, passed: 1, skipped: 1 }, + }; + const second: WorkspaceRunReport = { + ...base, + generatedAt: '2026-06-16T12:05:00.000Z', + projects: [ + { + path: path.join(workspace, 'alpha'), + relativePath: 'alpha', + projectName: 'alpha', + selected: false, + affected: false, + status: 'skipped', + exitCode: null, + durationMs: 0, + reason: 'outside scope', + }, + { + path: path.join(workspace, 'beta'), + relativePath: 'beta', + projectName: 'beta', + selected: true, + affected: true, + status: 'passed', + exitCode: 0, + durationMs: 12, + executionCommand: 'python -m unittest', + }, + ], + summary: { ...base.summary, selectedCount: 1, passed: 1, skipped: 1 }, + }; + + await publishWorkspaceRunStageReport(workspace, first); + await publishWorkspaceRunStageReport(workspace, second); + const evidence = await readWorkspaceRunEvidence(workspace); + const testStage = evidence?.stages.test; + expect(testStage?.projects.find((project) => project.relativePath === 'alpha')?.status).toBe( + 'skipped' + ); + expect(testStage?.projects.find((project) => project.relativePath === 'beta')?.status).toBe( + 'passed' + ); + expect(evidence?.projectStages?.alpha?.test?.status).toBe('passed'); + expect(evidence?.projectStages?.beta?.test?.status).toBe('passed'); + }); }); diff --git a/packages/cli/src/__tests__/workspace-run.test.ts b/packages/cli/src/__tests__/workspace-run.test.ts index bc016fd9..a671921a 100644 --- a/packages/cli/src/__tests__/workspace-run.test.ts +++ b/packages/cli/src/__tests__/workspace-run.test.ts @@ -442,12 +442,101 @@ describe('workspace-run', { timeout: 30_000 }, () => { expect(report.projects[0]?.runtimeExecutions).toEqual([ expect.objectContaining({ root: 'agents/primary', - command: `${process.platform === 'win32' ? 'python' : 'python3'} -m compileall .`, + command: `${process.platform === 'win32' ? '../../.venv/Scripts/python.exe' : '../../.venv/bin/python'} -m compileall .`, }), ]); await fsExtra.remove(workspacePath); }); + it('fails a Python unittest run when every required test was skipped', async () => { + const workspacePath = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'rk-python-skip-fail-')); + const projectPath = path.join(workspacePath, 'agent-app'); + await fsExtra.outputJson(path.join(projectPath, '.workspai', 'project.json'), { + name: 'agent-app', + runtime: 'python', + framework: 'openai-agents', + }); + await fsExtra.outputFile( + path.join(projectPath, 'agents', 'primary', 'pyproject.toml'), + '# Generated and managed by Workspai\n\n[project]\nname = "primary"\nversion = "0.1.0"\n' + ); + await fsExtra.outputFile( + path.join(projectPath, 'agents', 'primary', 'tests', 'test_context.py'), + 'import unittest\n' + ); + const projectPython = + process.platform === 'win32' + ? path.join(projectPath, '.venv', 'Scripts', 'python.exe') + : path.join(projectPath, '.venv', 'bin', 'python'); + await fsExtra.outputFile(projectPython, '#!/usr/bin/env python3\n', { mode: 0o755 }); + + const execaMock = execa as unknown as ReturnType; + execaMock.mockImplementation(async () => { + return { + exitCode: 0, + stdout: 'Ran 1 test in 0.001s\n\nOK (skipped=1)\n', + stderr: '', + }; + }); + + const report = await runWorkspaceStage({ + workspacePath, + stage: 'test', + enforceGates: false, + json: true, + }); + + expect(report.summary.failed).toBe(1); + expect(report.summary.exitCode).toBe(1); + expect(report.projects[0]?.status).toBe('failed'); + expect(report.projects[0]?.runtimeExecutions?.[0]?.testCounts).toEqual({ + ran: 1, + skipped: 1, + failed: 0, + }); + + await fsExtra.remove(workspacePath); + }); + + it('preflights uv before creating a managed Python environment', async () => { + const workspacePath = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'rk-python-uv-preflight-')); + const projectPath = path.join(workspacePath, 'agent-app'); + await fsExtra.outputJson(path.join(projectPath, '.workspai', 'project.json'), { + name: 'agent-app', + runtime: 'python', + framework: 'openai-agents', + }); + await fsExtra.outputFile( + path.join(projectPath, 'agents', 'primary', 'pyproject.toml'), + '# Generated and managed by Workspai\n\n[project]\nname = "primary"\nversion = "0.1.0"\n\n[tool.workspai]\nlifecycle-lock-tool = "uv"\n' + ); + + const execaMock = execa as unknown as ReturnType; + execaMock.mockImplementation(async (command: string, args: string[]) => ({ + exitCode: + command === (process.platform === 'win32' ? 'where' : 'which') && args[0] === 'uv' ? 1 : 0, + stdout: '', + stderr: '', + })); + + const report = await runWorkspaceStage({ + workspacePath, + stage: 'init', + enforceGates: false, + json: true, + }); + + expect(report.summary.exitCode).toBe(1); + expect(report.projects[0]?.reason).toContain('Missing admitted lock tool `uv`'); + expect(await fsExtra.pathExists(path.join(projectPath, '.venv'))).toBe(false); + expect( + execaMock.mock.calls.some( + ([command, args]) => command === 'python3' && Array.isArray(args) && args.includes('venv') + ) + ).toBe(false); + await fsExtra.remove(workspacePath); + }); + it('propagates a runtime-unit timeout category to the project result', async () => { const workspacePath = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'rk-polyglot-timeout-')); const projectPath = path.join(workspacePath, 'sdk'); @@ -704,6 +793,43 @@ describe('workspace-run', { timeout: 30_000 }, () => { expect(report.gates.blocked).toBe(true); expect(report.summary.passed).toBe(0); expect(report.summary.failed).toBe(0); + expect(report.summary.blocked).toBe(1); + expect(report.projects[0]?.status).toBe('blocked'); + expect(report.summary.exitCode).toBe(1); + + await fsExtra.remove(workspacePath); + }); + + it('returns a non-zero process exit when a blocking gate skips selected projects without --strict', async () => { + const workspacePath = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'rk-workspace-run-')); + await createProject(workspacePath, 'services/api-a'); + + const execaMock = execa as unknown as ReturnType; + execaMock.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('doctor')) { + return { exitCode: 0, stdout: JSON.stringify({ healthScore: { errors: 0 } }), stderr: '' }; + } + if (args.includes('readiness')) { + return { exitCode: 0, stdout: JSON.stringify({ overallStatus: 'fail' }), stderr: '' }; + } + if (args.includes('build')) { + throw new Error('build should not execute when gate fails'); + } + return { exitCode: 0, stdout: '{}', stderr: '' }; + }); + + const report = await runWorkspaceStage({ + workspacePath, + stage: 'build', + json: true, + strict: false, + }); + + expect(report.gates.blocked).toBe(true); + expect(report.summary.passed).toBe(0); + expect(report.summary.failed).toBe(0); + expect(report.summary.blocked).toBe(1); + expect(report.projects[0]?.status).toBe('blocked'); expect(report.summary.exitCode).toBe(1); await fsExtra.remove(workspacePath); @@ -1268,6 +1394,7 @@ describe('workspace-run', { timeout: 30_000 }, () => { expect(typeof report.summary.passed).toBe('number'); expect(typeof report.summary.failed).toBe('number'); expect(typeof report.summary.skipped).toBe('number'); + expect(typeof report.summary.blocked).toBe('number'); expect(typeof report.summary.exitCode).toBe('number'); // projects array diff --git a/packages/cli/src/__tests__/workspace-verify.test.ts b/packages/cli/src/__tests__/workspace-verify.test.ts index 0fbbb8ff..fef4624a 100644 --- a/packages/cli/src/__tests__/workspace-verify.test.ts +++ b/packages/cli/src/__tests__/workspace-verify.test.ts @@ -454,6 +454,7 @@ describe('workspace verify', () => { passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [ @@ -468,7 +469,6 @@ describe('workspace verify', () => { }, ], } satisfies WorkspaceRunReport); - const verify = await buildWorkspaceVerify({ workspacePath, fromImpactPath: impactPath, @@ -553,6 +553,7 @@ describe('workspace verify', () => { passed: 1, failed: 0, skipped: 0, + blocked: 0, exitCode: 0, }, projects: [ @@ -568,6 +569,67 @@ describe('workspace verify', () => { ], } satisfies WorkspaceRunReport); + // A newer scoped run must not make the older web receipt fresh merely by + // copying it into the latest stage report. + await publishWorkspaceRunStageReport(workspacePath, { + schemaVersion: '1.0', + workspacePath, + stage: 'test', + generatedAt: '2026-06-15T00:03:00.000Z', + durationMs: 10, + options: { + affected: false, + blastRadius: false, + since: null, + parallel: false, + maxWorkers: 1, + continueOnError: false, + strict: false, + enforceGates: false, + scope: 'project:api', + }, + selection: { + mode: 'all', + since: null, + scope: 'project:api', + graphStatus: 'not-applicable', + expansionDepth: 0, + }, + gates: { enforced: false, results: [], blocked: false }, + summary: { + projectCount: 2, + selectedCount: 1, + passed: 1, + failed: 0, + skipped: 1, + blocked: 0, + exitCode: 0, + }, + projects: [ + { + path: path.join(workspacePath, 'api'), + relativePath: 'api', + projectName: 'api', + selected: true, + affected: false, + status: 'passed', + exitCode: 0, + durationMs: 10, + }, + { + path: path.join(workspacePath, 'web'), + relativePath: 'web', + projectName: 'web', + selected: false, + affected: false, + status: 'skipped', + exitCode: null, + durationMs: 0, + reason: 'outside scope', + }, + ], + } satisfies WorkspaceRunReport); + const verify = await buildWorkspaceVerify({ workspacePath, fromImpactPath: impactPath, diff --git a/packages/cli/src/agent-frameworks/adapter-digest.ts b/packages/cli/src/agent-frameworks/adapter-digest.ts new file mode 100644 index 00000000..27ffc03a --- /dev/null +++ b/packages/cli/src/agent-frameworks/adapter-digest.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; + +import type { AgentFrameworkAdapter } from './adapter.js'; + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map((entry) => stableValue(entry)); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableValue(entry)]) + ); + } + return value; +} + +export function digestAgentFrameworkManifest(adapter: AgentFrameworkAdapter): string { + return createHash('sha256') + .update(`${JSON.stringify(adapter.manifest)}\n`) + .digest('hex'); +} + +/** + * Binds release evidence to generated templates and every synchronous adapter + * operation. The probe is semantic so the digest remains identical in source, + * bundled CLI, and installed-package execution. + */ +export function digestAgentFrameworkImplementation(adapter: AgentFrameworkAdapter): string { + const input = { + projectRoot: '/workspai/qualification/project', + instanceName: 'qualification-probe', + target: { project: 'qualification-project', artifactPrefix: '.' }, + } as const; + const probe = { + algorithm: 'workspai.agent-framework-implementation.semantic.v1', + adapterId: adapter.manifest.adapter.id, + render: adapter.render(input), + plans: { + scaffold: adapter.plan('scaffold', input), + attach: adapter.plan('attach', input), + }, + context: adapter.context(input), + validation: adapter.validate(input), + runtimeResolution: { + unavailable: adapter.resolveRuntime([]), + resolved: adapter.resolveRuntime([adapter.manifest.implementation.runtimes[0]]), + ambiguous: adapter.resolveRuntime( + adapter.manifest.implementation.runtimes.map((runtime) => `${runtime}@qualification`) + ), + }, + }; + return createHash('sha256') + .update(JSON.stringify(stableValue(probe))) + .digest('hex'); +} diff --git a/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/dotnet.ts b/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/dotnet.ts index bcdb18f3..2a62b4f3 100644 --- a/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/dotnet.ts +++ b/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/dotnet.ts @@ -268,7 +268,8 @@ public static class WorkspaiContext } if (last) { - if ((targetAttributes & FileAttributes.Directory) != 0) + if ((targetAttributes & FileAttributes.Directory) != 0 + || (targetAttributes & FileAttributes.Device) != 0) { ThrowUnsafe(); } @@ -285,7 +286,8 @@ public static class WorkspaiContext } if (last) { - if ((attributes & FileAttributes.Directory) != 0) + if ((attributes & FileAttributes.Directory) != 0 + || (attributes & FileAttributes.Device) != 0) { ThrowUnsafe(); } @@ -317,12 +319,28 @@ public static class WorkspaiContext 4096, FileOptions.SequentialScan)) { + if (!stream.CanSeek) + { + ThrowUnsafe(); + } if (stream.Length > ContextLimit) { throw new InvalidOperationException("Workspai agent context exceeds the admitted 128 KiB boundary."); } - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false); - decoded = await reader.ReadToEndAsync(); + using var reader = new StreamReader( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: false); + try + { + decoded = await reader.ReadToEndAsync(); + } + catch (DecoderFallbackException) + { + throw new InvalidOperationException("Workspai agent context is not valid UTF-8."); + } } if (Encoding.UTF8.GetByteCount(decoded) > ContextLimit) { @@ -734,6 +752,25 @@ public sealed class WorkspaiContextTests File.WriteAllText(context, contents); return root; } + + [Fact] + public async Task RejectsMalformedUtf8WithoutDisclosingContents() + { + var root = Path.Combine(Path.GetTempPath(), "workspai-context-" + Guid.NewGuid().ToString("N")); + var context = Path.Combine(root, "${PROJECT_CONTEXT_AGENT_REPORT_RELATIVE_PATH}".Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(context)!); + File.WriteAllBytes(context, new byte[] { 0x7b, 0xff, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74 }); + try + { + var error = await Assert.ThrowsAsync(() => WorkspaiContext.LoadAsync(root)); + Assert.Contains("UTF-8", error.Message); + Assert.DoesNotContain("secret", error.Message); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } ` ), diff --git a/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/python.ts b/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/python.ts index 65652d21..af201837 100644 --- a/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/python.ts +++ b/packages/cli/src/agent-frameworks/adapters/microsoft-agent-framework/python.ts @@ -69,6 +69,7 @@ function pathsFor(instanceName: string) { agent: `agents/${slug}/agent.py`, dependencyManifest: `agents/${slug}/pyproject.toml`, test: `agents/${slug}/tests/test_context.py`, + frameworkTest: `agents/${slug}/tests/test_framework.py`, environmentExample: `agents/${slug}/.env.example`, readme: `agents/${slug}/README.md`, state: `.workspai/agent-frameworks/microsoft-agent-framework-python/${slug}.json`, @@ -226,6 +227,9 @@ dependencies = [ [tool.setuptools] py-modules = ["agent", "main", "workspai_context"] + +[tool.workspai] +lifecycle-lock-tool = "uv" ` ), managedFile( @@ -246,69 +250,38 @@ from workspai_context import ( CONTEXT_LIMIT, CONTEXT_PATH, CONTEXT_SCHEMA_VERSION, + GENERATED_NOTICE, + bind_workspai_project_root_for_tests, describe_workspai_context_view, list_workspai_supported_commands, load_workspai_context, read_workspai_project_summary, redact_secret_shaped_values, - resolve_workspai_project_root, ) -class WorkspaiContextTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls._live_context = resolve_workspai_project_root() / CONTEXT_PATH - cls._backup_dir = Path(tempfile.mkdtemp(prefix="workspai-context-backup-")) - cls._backup = cls._backup_dir / "project-context-agent.json" - cls._restored_kind = "none" - cls._isolated = False - cls.addClassCleanup(cls._restore_live_context) - live = cls._live_context - if live.is_symlink(): - cls._backup.symlink_to(os.readlink(live)) - cls._restored_kind = "symlink" - live.unlink() - cls._isolated = True - return - if live.is_file(): - shutil.copy2(live, cls._backup) - cls._restored_kind = "file" - live.unlink() - cls._isolated = True - return - if live.exists(): - raise RuntimeError("Workspai agent context path is not a contained regular file") - cls._isolated = True - - @classmethod - def _restore_live_context(cls) -> None: - try: - if not getattr(cls, "_isolated", False): - return - live = cls._live_context - if live.is_symlink() or live.exists(): - live.unlink() - if cls._restored_kind == "symlink": - live.parent.mkdir(parents=True, exist_ok=True) - live.symlink_to(os.readlink(cls._backup)) - elif cls._restored_kind == "file": - live.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(cls._backup, live) - finally: - backup_dir = getattr(cls, "_backup_dir", None) - if backup_dir is not None: - shutil.rmtree(backup_dir, ignore_errors=True) +class _TemporaryProjectFixture(unittest.TestCase): + def setUp(self) -> None: + self._fixture = Path(tempfile.mkdtemp(prefix="workspai-context-fixture-")) + self.addCleanup(shutil.rmtree, self._fixture, True) + agent = self._fixture / "agents" / "primary" + agent.mkdir(parents=True) + (agent / "pyproject.toml").write_text( + f"# {GENERATED_NOTICE}\\n\\n[project]\\nname = \\"primary\\"\\n", + encoding="utf-8", + ) + bind_workspai_project_root_for_tests(self._fixture) + self.addCleanup(bind_workspai_project_root_for_tests, None) def _context_path(self) -> Path: - path = resolve_workspai_project_root() / CONTEXT_PATH + path = self._fixture / CONTEXT_PATH path.parent.mkdir(parents=True, exist_ok=True) - if path.is_symlink(): + if path.is_symlink() or path.exists(): path.unlink() - elif path.exists() and not path.is_file(): - raise RuntimeError("Workspai agent context path is not a contained regular file") return path + +class WorkspaiContextTests(_TemporaryProjectFixture): def test_reads_bounded_context_from_the_owning_project_not_cwd(self) -> None: payload = json.dumps({"schemaVersion": CONTEXT_SCHEMA_VERSION}) self._context_path().write_text(payload, encoding="utf-8") @@ -334,6 +307,12 @@ class WorkspaiContextTests(unittest.TestCase): load_workspai_context() self.assertNotIn("do-not-leak", str(raised.exception)) + def test_rejects_malformed_utf8_without_disclosing_contents(self) -> None: + self._context_path().write_bytes(b"{\\xffsecret") + with self.assertRaisesRegex(RuntimeError, "UTF-8") as raised: + load_workspai_context() + self.assertNotIn("secret", str(raised.exception)) + def test_rejects_an_external_symlink_without_disclosing_the_target(self) -> None: context = self._context_path() if context.exists() or context.is_symlink(): @@ -395,11 +374,60 @@ class WorkspaiContextTests(unittest.TestCase): self.assertNotIn("SECRETKEYVALUE", redacted) self.assertIn("[redacted]", redacted) + +if __name__ == "__main__": + unittest.main() +` + ), + managedFile( + target.frameworkTest, + `# Generated and managed by Workspai. This test performs no network calls. + +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from workspai_context import ( + CONTEXT_PATH, + CONTEXT_SCHEMA_VERSION, + GENERATED_NOTICE, + bind_workspai_project_root_for_tests, + describe_workspai_context_view, + list_workspai_supported_commands, + read_workspai_project_summary, +) + + +class RequiredFrameworkLoopTests(unittest.TestCase): + def setUp(self) -> None: + self._fixture = Path(tempfile.mkdtemp(prefix="workspai-framework-fixture-")) + self.addCleanup(shutil.rmtree, self._fixture, True) + agent = self._fixture / "agents" / "primary" + agent.mkdir(parents=True) + (agent / "pyproject.toml").write_text( + f"# {GENERATED_NOTICE}\\n\\n[project]\\nname = \\"primary\\"\\n", + encoding="utf-8", + ) + bind_workspai_project_root_for_tests(self._fixture) + self.addCleanup(bind_workspai_project_root_for_tests, None) + + def _context_path(self) -> Path: + path = self._fixture / CONTEXT_PATH + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink() or path.exists(): + path.unlink() + return path + def test_local_chat_client_loop_stays_offline(self) -> None: try: from agent_framework import Agent, ChatResponse, Message - except ImportError: - self.skipTest("agent-framework is not installed") + except ImportError as error: + self.fail(f"agent-framework is required for this release-admitted kit: {error}") class LocalChatClient: def __init__(self) -> None: @@ -473,13 +501,17 @@ Activate the environment, then run: CI may use \`uv sync --project ${target.root}\` against the same \`pyproject.toml\`. \`uv\` success is not evidence that pip install succeeded. +\`wspai workspace run init\` requires [uv](https://docs.astral.sh/uv/), validates it before changing the project, creates the project \`.venv\`, installs this package into that environment, and writes \`uv.lock\`. It fails closed rather than producing an unlocked environment. + ## Verify \`${python} -m compileall ${target.root}\` \`cd ${target.root} && ${python} -m unittest discover -s tests\` -Credentialless tests cover the Workspai context boundary, allowlisted views, and an optional LocalChatClient loop when \`agent-framework\` is installed. They isolate the operational context file for the suite and restore it afterward. They do not call a model provider. +Credentialless tests cover the Workspai context boundary, allowlisted views, and a required LocalChatClient loop. They construct a temporary project fixture and never mutate the operational context file. A missing \`agent-framework\` install fails the required framework test; it is not skipped. They do not call a model provider. + +\`wspai workspace run init\` creates the project \`.venv\` and installs this package into that environment. \`wspai workspace run test\` and \`wspai workspace run build\` use that same interpreter. A blocking Doctor or Readiness gate fails the process even without \`--strict\`. ## Run diff --git a/packages/cli/src/agent-frameworks/adapters/openai-agents/common.ts b/packages/cli/src/agent-frameworks/adapters/openai-agents/common.ts index 8bd06388..fcde36e7 100644 --- a/packages/cli/src/agent-frameworks/adapters/openai-agents/common.ts +++ b/packages/cli/src/agent-frameworks/adapters/openai-agents/common.ts @@ -108,7 +108,7 @@ export function openaiAgentsManifest( id: adapterId, package: '@workspai/cli', version: '0.1.0', - stability: 'preview', + stability: 'stable', }, framework: { id: 'openai-agents', diff --git a/packages/cli/src/agent-frameworks/adapters/openai-agents/python-context-source.ts b/packages/cli/src/agent-frameworks/adapters/openai-agents/python-context-source.ts index bc8db8ff..f9be6f34 100644 --- a/packages/cli/src/agent-frameworks/adapters/openai-agents/python-context-source.ts +++ b/packages/cli/src/agent-frameworks/adapters/openai-agents/python-context-source.ts @@ -12,6 +12,7 @@ import os import re import stat import sys +from contextvars import ContextVar from pathlib import Path CONTEXT_LIMIT = 131_072 @@ -22,6 +23,7 @@ GENERATED_NOTICE = "Generated and managed by Workspai" CONTEXT_SEGMENTS = tuple(part for part in CONTEXT_PATH.split("/") if part) MAX_PACKAGE_WALK = 5 MAX_MANIFEST_BYTES = 16_384 +_TEST_PROJECT_ROOT: ContextVar[Path | None] = ContextVar("workspai_test_project_root", default=None) def _fail(message: str) -> None: @@ -79,7 +81,15 @@ def _is_generated_python_manifest(path: Path) -> bool: return text.startswith(f"# {GENERATED_NOTICE}") and "[project]" in text +def bind_workspai_project_root_for_tests(project_root: Path | None) -> None: + """Test-only. Production entrypoints must not call this.""" + _TEST_PROJECT_ROOT.set(_canonical(project_root) if project_root is not None else None) + + def resolve_workspai_project_root() -> Path: + overridden = _TEST_PROJECT_ROOT.get() + if overridden is not None: + return overridden cursor = Path(os.path.abspath(__file__)).parent for _ in range(MAX_PACKAGE_WALK): manifest = cursor / "pyproject.toml" @@ -152,9 +162,9 @@ def _open_contained_regular_file(project_root: Path) -> int: raise -def load_workspai_context() -> str: - project_root = resolve_workspai_project_root() - fd = _open_contained_regular_file(project_root) +def load_workspai_context(project_root: Path | None = None) -> str: + root = _canonical(Path(project_root)) if project_root is not None else resolve_workspai_project_root() + fd = _open_contained_regular_file(root) try: st = os.fstat(fd) if not _is_regular_file(st): diff --git a/packages/cli/src/agent-frameworks/adapters/openai-agents/python.ts b/packages/cli/src/agent-frameworks/adapters/openai-agents/python.ts index 626250c2..bf9ebaf2 100644 --- a/packages/cli/src/agent-frameworks/adapters/openai-agents/python.ts +++ b/packages/cli/src/agent-frameworks/adapters/openai-agents/python.ts @@ -58,6 +58,7 @@ function pathsFor(instanceName: string) { agent: `agents/${slug}/agent.py`, dependencyManifest: `agents/${slug}/pyproject.toml`, test: `agents/${slug}/tests/test_context.py`, + frameworkTest: `agents/${slug}/tests/test_framework.py`, environmentExample: `agents/${slug}/.env.example`, readme: `agents/${slug}/README.md`, state: `.workspai/agent-frameworks/openai-agents-python/${slug}.json`, @@ -119,19 +120,19 @@ def tracing_disabled() -> bool: @function_tool(failure_error_function=None) -def describe_workspai_context() -> str: +async def describe_workspai_context() -> str: """Return the admitted Workspai context size and schemaVersion. This tool does not mutate files or run a shell.""" return describe_workspai_context_view() @function_tool(failure_error_function=None) -def read_workspai_project_summary() -> str: +async def read_workspai_project_summary() -> str: """Return allowlisted Workspai workspace and project identity fields. This tool does not mutate files or run a shell.""" return read_project_summary_view() @function_tool(failure_error_function=None) -def list_workspai_supported_commands() -> str: +async def list_workspai_supported_commands() -> str: """Return the admitted project command surface. This tool does not mutate files or run a shell.""" return list_supported_commands_view() @@ -248,13 +249,15 @@ dependencies = [ [tool.setuptools] py-modules = ["agent", "main", "workspai_context"] + +[tool.workspai] +lifecycle-lock-tool = "uv" ` ), managedFile( target.test, `# Generated and managed by Workspai. This test performs no network calls. -import asyncio import json import os import shutil @@ -269,69 +272,39 @@ from workspai_context import ( CONTEXT_LIMIT, CONTEXT_PATH, CONTEXT_SCHEMA_VERSION, + GENERATED_NOTICE, + bind_workspai_project_root_for_tests, describe_workspai_context_view, list_workspai_supported_commands, load_workspai_context, read_workspai_project_summary, redact_secret_shaped_values, - resolve_workspai_project_root, ) -class WorkspaiContextTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls._live_context = resolve_workspai_project_root() / CONTEXT_PATH - cls._backup_dir = Path(tempfile.mkdtemp(prefix="workspai-context-backup-")) - cls._backup = cls._backup_dir / "project-context-agent.json" - cls._restored_kind = "none" - cls._isolated = False - cls.addClassCleanup(cls._restore_live_context) - live = cls._live_context - if live.is_symlink(): - cls._backup.symlink_to(os.readlink(live)) - cls._restored_kind = "symlink" - live.unlink() - cls._isolated = True - return - if live.is_file(): - shutil.copy2(live, cls._backup) - cls._restored_kind = "file" - live.unlink() - cls._isolated = True - return - if live.exists(): - raise RuntimeError("Workspai agent context path is not a contained regular file") - cls._isolated = True - - @classmethod - def _restore_live_context(cls) -> None: - try: - if not getattr(cls, "_isolated", False): - return - live = cls._live_context - if live.is_symlink() or live.exists(): - live.unlink() - if cls._restored_kind == "symlink": - live.parent.mkdir(parents=True, exist_ok=True) - live.symlink_to(os.readlink(cls._backup)) - elif cls._restored_kind == "file": - live.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(cls._backup, live) - finally: - backup_dir = getattr(cls, "_backup_dir", None) - if backup_dir is not None: - shutil.rmtree(backup_dir, ignore_errors=True) +class _TemporaryProjectFixture(unittest.TestCase): + def setUp(self) -> None: + self._fixture = Path(tempfile.mkdtemp(prefix="workspai-context-fixture-")) + self.addCleanup(shutil.rmtree, self._fixture, True) + agent = self._fixture / "agents" / "primary" + agent.mkdir(parents=True) + (agent / "pyproject.toml").write_text( + f"# {GENERATED_NOTICE}\\n\\n[project]\\nname = \\"primary\\"\\n", + encoding="utf-8", + ) + bind_workspai_project_root_for_tests(self._fixture) + self.addCleanup(bind_workspai_project_root_for_tests, None) def _context_path(self) -> Path: - path = resolve_workspai_project_root() / CONTEXT_PATH + path = self._fixture / CONTEXT_PATH path.parent.mkdir(parents=True, exist_ok=True) - if path.is_symlink(): + if path.is_symlink() or path.exists(): path.unlink() - elif path.exists() and not path.is_file(): - raise RuntimeError("Workspai agent context path is not a contained regular file") return path + +class WorkspaiContextTests(_TemporaryProjectFixture): + def test_reads_bounded_context_from_the_owning_project_not_cwd(self) -> None: payload = json.dumps({"schemaVersion": CONTEXT_SCHEMA_VERSION}) self._context_path().write_text(payload, encoding="utf-8") @@ -357,6 +330,12 @@ class WorkspaiContextTests(unittest.TestCase): load_workspai_context() self.assertNotIn("do-not-leak", str(raised.exception)) + def test_rejects_malformed_utf8_without_disclosing_contents(self) -> None: + self._context_path().write_bytes(b"{\\xffsecret") + with self.assertRaisesRegex(RuntimeError, "UTF-8") as raised: + load_workspai_context() + self.assertNotIn("secret", str(raised.exception)) + def test_rejects_an_external_symlink_without_disclosing_the_target(self) -> None: context = self._context_path() if context.exists() or context.is_symlink(): @@ -375,28 +354,6 @@ class WorkspaiContextTests(unittest.TestCase): load_workspai_context() self.assertNotIn("do-not-leak", str(raised.exception)) - def test_scripted_model_tool_call_stays_offline(self) -> None: - try: - from agents.testing import ScriptedModel, assistant_message, function_call - from main import run_admitted_agent - except ImportError: - self.skipTest("openai-agents is not installed") - payload = json.dumps({"schemaVersion": CONTEXT_SCHEMA_VERSION, "secret": "do-not-leak"}) - self._context_path().write_text(payload, encoding="utf-8") - os.environ["OPENAI_AGENTS_DISABLE_TRACING"] = "1" - model = ScriptedModel( - steps=[ - [function_call("describe_workspai_context", {}, call_id="call_context")], - [assistant_message("OFFLINE_OK")], - ] - ) - output = asyncio.run(run_admitted_agent("Check admitted context", model=model)) - self.assertEqual(output, "OFFLINE_OK") - self.assertEqual(len(model.calls), 2) - self.assertIn("admitted-context-bytes:", str(model.calls[1].input)) - self.assertIsNone(getattr(getattr(model.calls[0], "model_settings", None), "timeout", None)) - self.assertNotIn("do-not-leak", str(getattr(model.calls[0], "system_instructions", ""))) - def test_allowlisted_views_omit_non_admitted_keys(self) -> None: payload = { "schemaVersion": CONTEXT_SCHEMA_VERSION, @@ -441,6 +398,76 @@ class WorkspaiContextTests(unittest.TestCase): self.assertIn("[redacted]", redacted) +if __name__ == "__main__": + unittest.main() +` + ), + managedFile( + target.frameworkTest, + `# Generated and managed by Workspai. This test performs no network calls. + +import asyncio +import json +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from workspai_context import ( + CONTEXT_PATH, + CONTEXT_SCHEMA_VERSION, + GENERATED_NOTICE, + bind_workspai_project_root_for_tests, +) + + +class RequiredFrameworkLoopTests(unittest.TestCase): + def setUp(self) -> None: + self._fixture = Path(tempfile.mkdtemp(prefix="workspai-framework-fixture-")) + self.addCleanup(shutil.rmtree, self._fixture, True) + agent = self._fixture / "agents" / "primary" + agent.mkdir(parents=True) + (agent / "pyproject.toml").write_text( + f"# {GENERATED_NOTICE}\\n\\n[project]\\nname = \\"primary\\"\\n", + encoding="utf-8", + ) + bind_workspai_project_root_for_tests(self._fixture) + self.addCleanup(bind_workspai_project_root_for_tests, None) + + def _context_path(self) -> Path: + path = self._fixture / CONTEXT_PATH + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink() or path.exists(): + path.unlink() + return path + + def test_scripted_model_tool_call_stays_offline(self) -> None: + try: + from agents.testing import ScriptedModel, assistant_message, function_call + from main import run_admitted_agent + except ImportError as error: + self.fail(f"openai-agents is required for this release-admitted kit: {error}") + payload = json.dumps({"schemaVersion": CONTEXT_SCHEMA_VERSION, "secret": "do-not-leak"}) + self._context_path().write_text(payload, encoding="utf-8") + os.environ["OPENAI_AGENTS_DISABLE_TRACING"] = "1" + model = ScriptedModel( + steps=[ + [function_call("describe_workspai_context", {}, call_id="call_context")], + [assistant_message("OFFLINE_OK")], + ] + ) + output = asyncio.run(run_admitted_agent("Check admitted context", model=model)) + self.assertEqual(output, "OFFLINE_OK") + self.assertEqual(len(model.calls), 2) + self.assertIn("admitted-context-bytes:", str(model.calls[1].input)) + self.assertIsNone(getattr(getattr(model.calls[0], "model_settings", None), "timeout", None)) + self.assertNotIn("do-not-leak", str(getattr(model.calls[0], "system_instructions", ""))) + + if __name__ == "__main__": unittest.main() ` @@ -478,7 +505,9 @@ CI may use \`uv sync --project ${target.root}\` against the same \`pyproject.tom \`cd ${target.root} && ${python} -m unittest discover -s tests\` -Credentialless tests cover the Workspai context boundary, allowlisted views, Azure-shaped redaction, and an official ScriptedModel tool-call when the SDK is installed. They isolate the operational context file for the suite and restore it afterward. They do not call a model provider. +Credentialless tests cover the Workspai context boundary, allowlisted views, Azure-shaped redaction, and an official ScriptedModel tool-call. They construct a temporary project fixture and never mutate the operational context file. A missing \`openai-agents\` install fails the required framework test; it is not skipped. They do not call a model provider. + +\`wspai workspace run init\` requires [uv](https://docs.astral.sh/uv/), validates it before changing the project, creates the project \`.venv\`, installs this package into that environment, and writes \`uv.lock\`. It fails closed rather than producing an unlocked environment. \`wspai workspace run test\` and \`wspai workspace run build\` use that same interpreter. A blocking Doctor or Readiness gate fails the process even without \`--strict\`. ## Run diff --git a/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript-context-source.ts b/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript-context-source.ts index 408ddab4..275d5410 100644 --- a/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript-context-source.ts +++ b/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript-context-source.ts @@ -28,6 +28,13 @@ export const WORKSPAI_CONTEXT_SCHEMA_VERSION = '${WORKSPAI_CONTEXT_SCHEMA_VERSIO export const WORKSPAI_AGENT_LAYOUT_PARENT = 'agents'; export const WORKSPAI_GENERATED_NOTICE = 'Generated and managed by Workspai'; +let testProjectRoot: string | undefined; + +/** Test-only. Production entrypoints must not call this. */ +export function bindWorkspaiProjectRootForTests(projectRoot: string | null): void { + testProjectRoot = projectRoot ?? undefined; +} + const CONTEXT_SEGMENTS = WORKSPAI_CONTEXT_PATH.split('/').filter(Boolean); const MAX_PACKAGE_WALK = 5; const MAX_MANIFEST_BYTES = 16_384; @@ -108,6 +115,9 @@ function isGeneratedAgentManifest(directory: string): boolean { } export function resolveWorkspaiProjectRoot(moduleUrl = import.meta.url): string { + if (testProjectRoot) { + return testProjectRoot; + } let cursor = dirname(fileURLToPath(moduleUrl)); for (let depth = 0; depth < MAX_PACKAGE_WALK; depth += 1) { if (isGeneratedAgentManifest(cursor) && basename(dirname(cursor)) === WORKSPAI_AGENT_LAYOUT_PARENT) { @@ -185,8 +195,7 @@ function parseObjectOrFail(decoded: string): Record { return parsed as Record; } -export function loadWorkspaiContext(): string { - const projectRoot = resolveWorkspaiProjectRoot(); +export function loadWorkspaiContext(projectRoot = resolveWorkspaiProjectRoot()): string { const fd = openContainedRegularFile(projectRoot); try { const st = fstatSync(fd); diff --git a/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript.ts b/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript.ts index 8304c131..a35420b5 100644 --- a/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript.ts +++ b/packages/cli/src/agent-frameworks/adapters/openai-agents/typescript.ts @@ -308,7 +308,7 @@ main().catch((error: unknown) => { `// Generated and managed by Workspai. This test performs no network calls. import assert from 'node:assert/strict'; -import { copyFile, lstat, mkdir, mkdtemp, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { after, before, test } from 'node:test'; @@ -318,6 +318,7 @@ import { ScriptedModel, assistantMessage, functionCall } from '@openai/agents/te import { runAdmittedAgent } from '../src/agent.js'; import { + bindWorkspaiProjectRootForTests, describeWorkspaiContextView, listWorkspaiSupportedCommands, loadWorkspaiContext, @@ -327,79 +328,36 @@ import { WORKSPAI_CONTEXT_LIMIT, WORKSPAI_CONTEXT_PATH, WORKSPAI_CONTEXT_SCHEMA_VERSION, + WORKSPAI_GENERATED_NOTICE, } from '../src/workspai-context.js'; function admittedContext(): string { return JSON.stringify({ schemaVersion: WORKSPAI_CONTEXT_SCHEMA_VERSION }); } -let liveContextPath = ''; -let contextBackupPath = ''; -let restoredLiveKind = 'none'; -let isolatedLiveContext = false; +let fixtureRoot = ''; -async function isolateLiveContext() { - const projectRoot = resolveWorkspaiProjectRoot(); - liveContextPath = join(projectRoot, WORKSPAI_CONTEXT_PATH); - const backupRoot = await mkdtemp(join(tmpdir(), 'workspai-context-backup-')); - contextBackupPath = join(backupRoot, 'project-context-agent.json'); - restoredLiveKind = 'none'; - isolatedLiveContext = false; - try { - const st = await lstat(liveContextPath); - if (st.isSymbolicLink()) { - await symlink(await readlink(liveContextPath), contextBackupPath); - restoredLiveKind = 'symlink'; - await rm(liveContextPath, { force: true }); - isolatedLiveContext = true; - return; - } - if (st.isFile()) { - try { - await rename(liveContextPath, contextBackupPath); - } catch { - await copyFile(liveContextPath, contextBackupPath); - await rm(liveContextPath, { force: true }); - } - restoredLiveKind = 'file'; - isolatedLiveContext = true; - return; - } - throw new Error('Workspai agent context path is not a contained regular file'); - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - isolatedLiveContext = true; - return; - } - throw error; - } +async function createTemporaryProjectFixture() { + fixtureRoot = await mkdtemp(join(tmpdir(), 'workspai-context-fixture-')); + const agent = join(fixtureRoot, 'agents', 'primary'); + await mkdir(agent, { recursive: true }); + await writeFile( + join(agent, 'package.json'), + JSON.stringify({ notice: WORKSPAI_GENERATED_NOTICE, name: 'primary' }), + 'utf8' + ); + bindWorkspaiProjectRootForTests(fixtureRoot); } -async function restoreLiveContext() { - try { - if (isolatedLiveContext && liveContextPath) { - await rm(liveContextPath, { force: true }); - if (restoredLiveKind === 'file') { - await mkdir(dirname(liveContextPath), { recursive: true }); - try { - await rename(contextBackupPath, liveContextPath); - } catch { - await copyFile(contextBackupPath, liveContextPath); - } - } else if (restoredLiveKind === 'symlink') { - await mkdir(dirname(liveContextPath), { recursive: true }); - await symlink(await readlink(contextBackupPath), liveContextPath); - } - } - } finally { - if (contextBackupPath) { - await rm(dirname(contextBackupPath), { recursive: true, force: true }); - } +async function removeTemporaryProjectFixture() { + bindWorkspaiProjectRootForTests(null); + if (fixtureRoot) { + await rm(fixtureRoot, { recursive: true, force: true }); } } -before(isolateLiveContext); -after(restoreLiveContext); +before(createTemporaryProjectFixture); +after(removeTemporaryProjectFixture); test('reads bounded context from the owning project, not process cwd', async () => { const projectRoot = resolveWorkspaiProjectRoot(); @@ -424,6 +382,14 @@ test('rejects context larger than the admitted boundary', async () => { assert.throws(() => loadWorkspaiContext(), /128 KiB/); }); +test('rejects malformed UTF-8 without disclosing contents', async () => { + const projectRoot = resolveWorkspaiProjectRoot(); + const contextPath = join(projectRoot, WORKSPAI_CONTEXT_PATH); + await mkdir(dirname(contextPath), { recursive: true }); + await writeFile(contextPath, Buffer.from([0x7b, 0xff, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74])); + assert.throws(() => loadWorkspaiContext(), /UTF-8|valid/); +}); + test('rejects an external symlink without disclosing the target', async () => { const projectRoot = resolveWorkspaiProjectRoot(); const contextPath = join(projectRoot, WORKSPAI_CONTEXT_PATH); @@ -544,7 +510,7 @@ Context bytes are admitted only after canonical containment, a regular-file open \`npm --prefix ${target.root} test\` -Credentialless tests cover the Workspai context boundary, allowlisted views, Azure-shaped redaction, and an official ScriptedModel tool-call. They isolate the operational context file for the suite and restore it afterward. They do not call a model provider and must keep \`OPENAI_AGENTS_DISABLE_TRACING=1\`. +Credentialless tests cover the Workspai context boundary, allowlisted views, Azure-shaped redaction, and an official ScriptedModel tool-call. They construct a temporary project fixture and never mutate the operational context file. They do not call a model provider and must keep \`OPENAI_AGENTS_DISABLE_TRACING=1\`. A blocking Doctor or Readiness gate fails the process even without \`--strict\`. ## Run diff --git a/packages/cli/src/agent-frameworks/admission-candidate.ts b/packages/cli/src/agent-frameworks/admission-candidate.ts index b1698c5d..97317d6d 100644 --- a/packages/cli/src/agent-frameworks/admission-candidate.ts +++ b/packages/cli/src/agent-frameworks/admission-candidate.ts @@ -14,9 +14,15 @@ import { hashCanonicalJson } from '../workspace-model-hash.js'; import type { AgentFrameworkAdapter } from './adapter.js'; import { BUILTIN_AGENT_FRAMEWORK_ADAPTERS, + digestBuiltinAgentFrameworkImplementation, digestBuiltinAgentFrameworkManifest, } from './builtins.js'; -import { assessAgentFrameworkAdmission } from './conformance.js'; +import { + assessAgentFrameworkAdmission, + implementationSha256ByPlatformFromReports, + liveImplementationDigestBlockers, + requireCompleteImplementationDigestMap, +} from './conformance.js'; const MAX_CANDIDATE_INPUT_BYTES = 2 * 1024 * 1024; @@ -95,9 +101,22 @@ export async function buildAgentFrameworkAdmissionCandidate(input: { .filter((report) => report.adapter.id === adapter.manifest.adapter.id) .sort((left, right) => laneKey(left).localeCompare(laneKey(right))); const manifestSha256 = digestBuiltinAgentFrameworkManifest(adapter); + const implementationSha256ByPlatform = requireCompleteImplementationDigestMap( + implementationSha256ByPlatformFromReports(reports) + ); + const liveBlockers = liveImplementationDigestBlockers({ + liveImplementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), + implementationSha256ByPlatform, + }); + if (liveBlockers.length > 0) { + throw new Error( + `Cannot build admission candidate for ${adapter.manifest.adapter.id}: ${liveBlockers.join('; ')}` + ); + } const assessment = assessAgentFrameworkAdmission({ manifest: adapter.manifest, manifestSha256, + implementationSha256ByPlatform, reports, }); if (assessment.status !== 'admitted') { @@ -109,6 +128,7 @@ export async function buildAgentFrameworkAdmissionCandidate(input: { id: adapter.manifest.adapter.id, version: adapter.manifest.adapter.version, manifestSha256, + implementationSha256ByPlatform, framework: { id: adapter.manifest.framework.id }, lanes: await Promise.all( reports.map(async (report) => { diff --git a/packages/cli/src/agent-frameworks/builtins.ts b/packages/cli/src/agent-frameworks/builtins.ts index 5f75c504..10634d6a 100644 --- a/packages/cli/src/agent-frameworks/builtins.ts +++ b/packages/cli/src/agent-frameworks/builtins.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; - import type { AgentFrameworkConformanceReport } from '../contracts/agent-framework-contract.js'; import type { AgentFrameworkAdapter } from './adapter.js'; import { @@ -11,6 +9,10 @@ import { openaiAgentsTypeScriptAdapter, } from './adapters/openai-agents/index.js'; import { AgentFrameworkRegistry } from './registry.js'; +import { + digestAgentFrameworkImplementation, + digestAgentFrameworkManifest, +} from './adapter-digest.js'; export const BUILTIN_AGENT_FRAMEWORK_ADAPTERS: readonly AgentFrameworkAdapter[] = Object.freeze([ microsoftAgentFrameworkPythonAdapter, @@ -20,9 +22,16 @@ export const BUILTIN_AGENT_FRAMEWORK_ADAPTERS: readonly AgentFrameworkAdapter[] ]); export function digestBuiltinAgentFrameworkManifest(adapter: AgentFrameworkAdapter): string { - return createHash('sha256') - .update(`${JSON.stringify(adapter.manifest)}\n`) - .digest('hex'); + return digestAgentFrameworkManifest(adapter); +} + +/** + * Binds release evidence to generated templates and every synchronous adapter + * operation. The probe is semantic so the digest remains identical in source, + * bundled CLI, and installed-package execution. + */ +export function digestBuiltinAgentFrameworkImplementation(adapter: AgentFrameworkAdapter): string { + return digestAgentFrameworkImplementation(adapter); } export function createBuiltinAgentFrameworkRegistry( @@ -34,6 +43,7 @@ export function createBuiltinAgentFrameworkRegistry( registry.register({ manifest: adapter.manifest, manifestSha256: digestBuiltinAgentFrameworkManifest(adapter), + implementationSha256: digestBuiltinAgentFrameworkImplementation(adapter), source: 'builtin', conformanceReports: structuredClone(conformanceReports[adapter.manifest.adapter.id] ?? []), ...(options.trustReviewedReleaseAdmissions ? { releaseAdapter: adapter } : {}), diff --git a/packages/cli/src/agent-frameworks/conformance.ts b/packages/cli/src/agent-frameworks/conformance.ts index 9ba987c1..703e1223 100644 --- a/packages/cli/src/agent-frameworks/conformance.ts +++ b/packages/cli/src/agent-frameworks/conformance.ts @@ -8,12 +8,68 @@ import { } from '../contracts/agent-framework-contract.js'; import { assertJsonSchemaContract } from '../utils/json-schema-contract.js'; +export const AGENT_FRAMEWORK_ADMISSION_PLATFORMS = ['linux', 'darwin', 'win32'] as const; + +export type AgentFrameworkAdmissionPlatform = (typeof AGENT_FRAMEWORK_ADMISSION_PLATFORMS)[number]; + export type AgentFrameworkConformanceLane = { - platform: AgentFrameworkAdapterManifest['implementation']['platforms'][number]; + platform: AgentFrameworkAdmissionPlatform; runtime: string; frameworkVersion: string; }; +function isAdmissionPlatform(value: string): value is AgentFrameworkAdmissionPlatform { + return (AGENT_FRAMEWORK_ADMISSION_PLATFORMS as readonly string[]).includes(value); +} + +export function implementationSha256ByPlatformFromReports( + reports: readonly AgentFrameworkConformanceReport[] +): Partial> { + const map: Partial> = {}; + for (const report of reports) { + const platform = report.environment.platform; + const digest = report.adapter.implementationSha256; + if (!isAdmissionPlatform(platform)) { + throw new Error(`Conformance report platform is not admitted: ${platform}`); + } + if (!/^[a-f0-9]{64}$/.test(digest)) { + throw new Error(`Conformance ${platform} implementation digest is malformed.`); + } + const existing = map[platform]; + if (existing && existing !== digest) { + throw new Error(`Conflicting implementation digest for ${platform}.`); + } + map[platform] = digest; + } + return map; +} + +export function requireCompleteImplementationDigestMap( + map: Partial> +): Record { + for (const platform of AGENT_FRAMEWORK_ADMISSION_PLATFORMS) { + if (!/^[a-f0-9]{64}$/.test(map[platform] ?? '')) { + throw new Error(`Missing implementation digest for ${platform}.`); + } + } + return map as Record; +} + +export function liveImplementationDigestBlockers(input: { + liveImplementationSha256: string; + implementationSha256ByPlatform: Partial>; + platform?: string; +}): string[] { + const platform = input.platform ?? process.platform; + if (!isAdmissionPlatform(platform)) { + return [`host platform ${platform} is not an admitted qualification platform`]; + } + if (input.implementationSha256ByPlatform[platform] !== input.liveImplementationSha256) { + return [`live implementation digest does not match ${platform} evidence`]; + } + return []; +} + export type AgentFrameworkAdmissionAssessment = { status: 'admitted' | 'blocked'; requiredLanes: AgentFrameworkConformanceLane[]; @@ -44,6 +100,10 @@ function requiredLanes(manifest: AgentFrameworkAdapterManifest): AgentFrameworkC export function assessAgentFrameworkAdmission(input: { manifest: AgentFrameworkAdapterManifest; manifestSha256: string; + implementationSha256?: string; + implementationSha256ByPlatform?: Partial< + Record + >; reports: AgentFrameworkConformanceReport[]; }): AgentFrameworkAdmissionAssessment { const blockers: string[] = []; @@ -104,6 +164,15 @@ export function assessAgentFrameworkAdmission(input: { reportBlocked = true; blockers.push(`conformance ${label}: manifest digest does not match`); } + const expectedImplementationDigest = + input.implementationSha256ByPlatform?.[lane.platform] ?? input.implementationSha256; + if ( + !expectedImplementationDigest || + report.adapter.implementationSha256 !== expectedImplementationDigest + ) { + reportBlocked = true; + blockers.push(`conformance ${label}: implementation digest does not match`); + } if (!input.manifest.implementation.platforms.includes(lane.platform)) { reportBlocked = true; blockers.push(`conformance ${label}: platform is not advertised by the manifest`); diff --git a/packages/cli/src/agent-frameworks/lifecycle.ts b/packages/cli/src/agent-frameworks/lifecycle.ts index 4042063c..574a6ea8 100644 --- a/packages/cli/src/agent-frameworks/lifecycle.ts +++ b/packages/cli/src/agent-frameworks/lifecycle.ts @@ -23,6 +23,7 @@ import { } from '../utils/artifact-path-compat.js'; import { withInterprocessLock } from '../utils/interprocess-lock.js'; import { assertJsonSchemaContract } from '../utils/json-schema-contract.js'; +import { WORKSPACE_SUPPLEMENTAL_ARTIFACTS } from '../contracts/workspace-intelligence-runtime-registry.js'; import { resolveWorkspaceProjectPaths } from '../utils/workspace-project-paths.js'; import { WORKSPACE_MODEL_REPORT_PATH, type WorkspaceModel } from '../workspace-model.js'; import { hashCanonicalJson } from '../workspace-model-hash.js'; @@ -38,6 +39,82 @@ import { import type { AgentFrameworkRegistry } from './registry.js'; const PLAN_ROLE = 'agent-framework-change-plan'; +const PROJECT_ROOT_MANIFESTS = [ + '.workspai/project.json', + 'package.json', + 'pyproject.toml', + 'go.mod', + 'Cargo.toml', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', +]; + +async function predictedArchitectureOperations(input: { + target: AgentFrameworkTarget; + plan: AgentFrameworkChangePlan; +}): Promise< + Array<{ + operation: 'change'; + targetKind: 'artifact'; + targetId: string; + rationale: string; + confidence: 'high'; + }> +> { + const operations: Array<{ + operation: 'change'; + targetKind: 'artifact'; + targetId: string; + rationale: string; + confidence: 'high'; + }> = []; + const seen = new Set(); + const addTarget = (targetId: string, rationale: string) => { + if (seen.has(targetId)) return; + seen.add(targetId); + operations.push({ + operation: 'change', + targetKind: 'artifact', + targetId, + rationale, + confidence: 'high', + }); + }; + const add = (relativePath: string, rationale: string) => { + addTarget(artifactPath(input.target.artifactPrefix, relativePath), rationale); + }; + + for (const file of input.plan.files) { + add(file.path, `${file.overwrite} under the admitted agent-framework plan.`); + } + add( + '.workspai/project.json', + 'Create and attach refresh the governed project lens after nested runtime files exist.' + ); + addTarget( + WORKSPACE_SUPPLEMENTAL_ARTIFACTS.workspaceContract, + 'Agent required environment is recorded on the workspace contract during Graph refresh.' + ); + for (const relativePath of PROJECT_ROOT_MANIFESTS) { + if (await fsExtra.pathExists(path.join(input.target.projectRoot, relativePath))) { + add( + relativePath, + 'Nested runtime attach re-observes the existing project-root manifest during Graph refresh.' + ); + } + } + const rootEntries = await fsExtra.readdir(input.target.projectRoot).catch(() => []); + for (const entry of rootEntries) { + if (/\.(?:cs|fs|vb)proj$/i.test(entry)) { + add( + entry, + 'Nested runtime attach re-observes the existing .NET project manifest during Graph refresh.' + ); + } + } + return operations; +} const MANAGED_FILE_SCHEMA_VERSION = 'workspai.agent-framework-managed-file.v1'; const MAX_MANAGED_FILE_BYTES = 1024 * 1024; @@ -360,15 +437,11 @@ export async function prepareAgentFrameworkChange(input: { baselineGeneration: '', nonCanonical: true, proofEligible: false, - operations: plan.files.map((file) => ({ - operation: 'change', - targetKind: 'artifact', - targetId: artifactPath(target.artifactPrefix, file.path), - rationale: `${file.overwrite} under the admitted agent-framework plan.`, - confidence: 'high', - })), + operations: await predictedArchitectureOperations({ target, plan }), assumptions: [ 'Only hash-bound Workspai-managed files in the admitted ownership roots will change.', + 'The project lens metadata at .workspai/project.json is rewritten after the nested runtime is applied.', + 'Existing project-root manifests are re-observed when the nested runtime makes the project polyglot.', ], predictedRisk: plan.files.length > 0 ? 'low' : 'none', }, diff --git a/packages/cli/src/agent-frameworks/project-kits.ts b/packages/cli/src/agent-frameworks/project-kits.ts index 8b15fc21..b3200452 100644 --- a/packages/cli/src/agent-frameworks/project-kits.ts +++ b/packages/cli/src/agent-frameworks/project-kits.ts @@ -78,7 +78,7 @@ export function describeAgentFrameworkProjectKits(): AgentFrameworkProjectKit[] } export function listAgentFrameworkProjectKits(): AgentFrameworkProjectKit[] { - return PROJECT_KITS.filter(admitted).map((kit) => structuredClone(kit)); + return describeAgentFrameworkProjectKits(); } export function isAdmittedAgentFrameworkProjectKit( @@ -102,8 +102,7 @@ export function lookupAgentFrameworkProjectKit( export function resolveAgentFrameworkProjectKit( value: string | undefined ): AgentFrameworkProjectKit | null { - const kit = lookupAgentFrameworkProjectKit(value); - return kit && admitted(kit) ? kit : null; + return lookupAgentFrameworkProjectKit(value); } export function isAgentFrameworkProjectKit(value: string | undefined): boolean { diff --git a/packages/cli/src/agent-frameworks/registry.ts b/packages/cli/src/agent-frameworks/registry.ts index e155c76e..b66061b3 100644 --- a/packages/cli/src/agent-frameworks/registry.ts +++ b/packages/cli/src/agent-frameworks/registry.ts @@ -13,6 +13,7 @@ import type { AgentFrameworkAdapter } from './adapter.js'; export type AgentFrameworkRegistryEntry = { manifest: AgentFrameworkAdapterManifest; manifestSha256: string; + implementationSha256: string; source: 'builtin' | 'package' | 'workspace'; conformanceReports: AgentFrameworkConformanceReport[]; releaseAdapter?: AgentFrameworkAdapter; @@ -42,6 +43,7 @@ function cloneEntry(entry: AgentFrameworkRegistryEntry): AgentFrameworkRegistryE return structuredClone({ manifest: entry.manifest, manifestSha256: entry.manifestSha256, + implementationSha256: entry.implementationSha256, source: entry.source, conformanceReports: entry.conformanceReports, }); @@ -72,6 +74,9 @@ export class AgentFrameworkRegistry { if (!/^[a-f0-9]{64}$/.test(entry.manifestSha256)) { throw new Error('Agent framework manifest digest must be a lowercase SHA-256 value.'); } + if (!/^[a-f0-9]{64}$/.test(entry.implementationSha256)) { + throw new Error('Agent framework implementation digest must be a lowercase SHA-256 value.'); + } const adapterId = normalizedToken(entry.manifest.adapter.id); if (this.#entries.has(adapterId)) { throw new Error(`Agent framework adapter id is already registered: ${adapterId}`); @@ -103,6 +108,7 @@ export class AgentFrameworkRegistry { const admission = assessAgentFrameworkAdmission({ manifest: entry.manifest, manifestSha256: entry.manifestSha256, + implementationSha256: entry.implementationSha256, reports: entry.conformanceReports, }); return admission.status === 'admitted' @@ -154,6 +160,7 @@ export class AgentFrameworkRegistry { const admission = assessAgentFrameworkAdmission({ manifest: selected.manifest, manifestSha256: selected.manifestSha256, + implementationSha256: selected.implementationSha256, reports: selected.conformanceReports, }); if (admission.status !== 'admitted' && releaseAdmission?.status !== 'admitted') { diff --git a/packages/cli/src/agent-frameworks/release-admission.ts b/packages/cli/src/agent-frameworks/release-admission.ts index 5c442294..b6a8ab8f 100644 --- a/packages/cli/src/agent-frameworks/release-admission.ts +++ b/packages/cli/src/agent-frameworks/release-admission.ts @@ -1,16 +1,16 @@ -import { createHash } from 'node:crypto'; - -import admissionDocument from './release-admissions.v1.json' with { type: 'json' }; +import admissionDocument from './release-admissions.v2.json' with { type: 'json' }; import type { AgentFrameworkAdapter } from './adapter.js'; +import { digestAgentFrameworkManifest } from './adapter-digest.js'; -const RELEASE_ADMISSION_SCHEMA_VERSION = 'workspai.agent-framework-release-admissions.v1' as const; +const RELEASE_ADMISSION_SCHEMA_VERSION = 'workspai.agent-framework-release-admissions.v2' as const; const PLATFORMS = ['linux', 'darwin', 'win32'] as const; export type AgentFrameworkReleaseAdmission = { id: string; version: string; manifestSha256: string; + implementationSha256ByPlatform: Record<(typeof PLATFORMS)[number], string>; frameworkVersion: string; runtime: string; platforms: Array<(typeof PLATFORMS)[number]>; @@ -44,8 +44,8 @@ function validateDocument(value: unknown): AgentFrameworkReleaseAdmission[] { ) { throw new Error('Bundled agent framework release admission source is invalid.'); } - if (!Array.isArray(value.adapters) || value.adapters.length === 0) { - throw new Error('Bundled agent framework release admission inventory is empty.'); + if (!Array.isArray(value.adapters)) { + throw new Error('Bundled agent framework release admission inventory is invalid.'); } const seen = new Set(); return value.adapters.map((candidate, index) => { @@ -55,12 +55,18 @@ function validateDocument(value: unknown): AgentFrameworkReleaseAdmission[] { const id = String(candidate.id ?? ''); const version = String(candidate.version ?? ''); const manifestSha256 = String(candidate.manifestSha256 ?? ''); + const implementationSha256ByPlatform = isRecord(candidate.implementationSha256ByPlatform) + ? candidate.implementationSha256ByPlatform + : {}; const frameworkVersion = String(candidate.frameworkVersion ?? ''); const runtime = String(candidate.runtime ?? ''); if ( !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(id) || !version || !/^[a-f0-9]{64}$/.test(manifestSha256) || + PLATFORMS.some( + (platform) => !/^[a-f0-9]{64}$/.test(String(implementationSha256ByPlatform[platform] ?? '')) + ) || !frameworkVersion || !runtime ) { @@ -79,6 +85,9 @@ function validateDocument(value: unknown): AgentFrameworkReleaseAdmission[] { id, version, manifestSha256, + implementationSha256ByPlatform: Object.fromEntries( + PLATFORMS.map((platform) => [platform, String(implementationSha256ByPlatform[platform])]) + ) as Record<(typeof PLATFORMS)[number], string>, frameworkVersion, runtime, platforms: [...PLATFORMS], @@ -88,12 +97,6 @@ function validateDocument(value: unknown): AgentFrameworkReleaseAdmission[] { const RELEASE_ADMISSIONS = Object.freeze(validateDocument(admissionDocument)); -function manifestDigest(adapter: AgentFrameworkAdapter): string { - return createHash('sha256') - .update(`${JSON.stringify(adapter.manifest)}\n`) - .digest('hex'); -} - export function assessBundledAgentFrameworkRelease( adapter: AgentFrameworkAdapter ): AgentFrameworkReleaseAdmissionResolution { @@ -111,7 +114,7 @@ export function assessBundledAgentFrameworkRelease( if (admission.version !== adapter.manifest.adapter.version) { blockers.push('adapter version changed after release admission'); } - if (admission.manifestSha256 !== manifestDigest(adapter)) { + if (admission.manifestSha256 !== digestAgentFrameworkManifest(adapter)) { blockers.push('adapter manifest changed after release admission'); } if (!adapter.manifest.framework.testedVersions.includes(admission.frameworkVersion)) { diff --git a/packages/cli/src/agent-frameworks/release-admissions.v1.json b/packages/cli/src/agent-frameworks/release-admissions.v1.json index 2be21abe..48578def 100644 --- a/packages/cli/src/agent-frameworks/release-admissions.v1.json +++ b/packages/cli/src/agent-frameworks/release-admissions.v1.json @@ -1,11 +1,11 @@ { "schemaVersion": "workspai.agent-framework-release-admissions.v1", - "reviewedAt": "2026-09-18T14:46:18.000Z", + "reviewedAt": "2026-09-19T00:17:28.000Z", "source": { "repository": "chistiq/workspai", - "commit": "8eb13087b64db13762c046d413972f6034e312fd", - "workflowRunId": 35357989405, - "workflowUrl": "https://github.com/chistiq/workspai/actions/runs/35357989405", + "commit": "ff659fa8a7101dd750d48e504b1b19671aab8f73", + "workflowRunId": 35408663712, + "workflowUrl": "https://github.com/chistiq/workspai/actions/runs/35408663712", "conclusion": "success" }, "adapters": [ @@ -36,7 +36,7 @@ { "id": "openai-agents-python", "version": "0.1.0", - "manifestSha256": "17eecb2854bd5459cde78c2f00ce4f32d707819028faec926163e8b32a7e4993", + "manifestSha256": "f76c9973e07b1a3c7807efc489374cdede84038692d0012e03b530db4bcbba55", "frameworkVersion": "0.22.2", "runtime": "python", "platforms": [ @@ -48,7 +48,7 @@ { "id": "openai-agents-typescript", "version": "0.1.0", - "manifestSha256": "f6a745a659ec662c89565f73bfff240ffd065816e54d8f8a87fb91bad6c01adb", + "manifestSha256": "35b4e04cda42e227ea8d352e3c87ec1ed7c5f53b39aafb734e895d456a32c38f", "frameworkVersion": "0.18.0", "runtime": "node", "platforms": [ diff --git a/packages/cli/src/agent-frameworks/release-admissions.v2.json b/packages/cli/src/agent-frameworks/release-admissions.v2.json new file mode 100644 index 00000000..5877e1db --- /dev/null +++ b/packages/cli/src/agent-frameworks/release-admissions.v2.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": "workspai.agent-framework-release-admissions.v2", + "reviewedAt": "2026-09-20T02:13:30.000Z", + "source": { + "repository": "chistiq/workspai", + "commit": "2b25305c6806cd9f39fbda66b646ed9304af167f", + "workflowRunId": 35483229302, + "workflowUrl": "https://github.com/chistiq/workspai/actions/runs/35483229302", + "conclusion": "success" + }, + "adapters": [ + { + "id": "microsoft-agent-framework-python", + "version": "0.1.0", + "manifestSha256": "e4dfa55256ec2ed7f43927d109b09b541a13217f8a4df964f678fd036bcef060", + "implementationSha256ByPlatform": { + "linux": "828d1fce4903fdfb3f57c219a299ca10dcd88f819be1d4216b3f64e144decff7", + "darwin": "828d1fce4903fdfb3f57c219a299ca10dcd88f819be1d4216b3f64e144decff7", + "win32": "c6d803516c5c4f8836a474c6e5f6e29d8894e21d1a915d816c713fa522b57e1f" + }, + "frameworkVersion": "1.18.0", + "runtime": "python", + "platforms": ["linux", "darwin", "win32"] + }, + { + "id": "microsoft-agent-framework-dotnet", + "version": "0.1.0", + "manifestSha256": "1efa079735d51cc3cbbd2d2fe55815850d8aa39f87de4bac4589d9fbfaf2e8e9", + "implementationSha256ByPlatform": { + "linux": "1217f28fdcdacacb2af6bfb9b62053bb4e30fb5a78c41df8e4cb27e03c1aefae", + "darwin": "1217f28fdcdacacb2af6bfb9b62053bb4e30fb5a78c41df8e4cb27e03c1aefae", + "win32": "1217f28fdcdacacb2af6bfb9b62053bb4e30fb5a78c41df8e4cb27e03c1aefae" + }, + "frameworkVersion": "1.21.0", + "runtime": "dotnet", + "platforms": ["linux", "darwin", "win32"] + }, + { + "id": "openai-agents-python", + "version": "0.1.0", + "manifestSha256": "f76c9973e07b1a3c7807efc489374cdede84038692d0012e03b530db4bcbba55", + "implementationSha256ByPlatform": { + "linux": "cb707b5264728afba17f81639a6551ca4252316773560fe9cde66436c4318b44", + "darwin": "cb707b5264728afba17f81639a6551ca4252316773560fe9cde66436c4318b44", + "win32": "ecf4451ed364532f17a0a1677753a4c55f9a16e2f924d9033e270c926a552811" + }, + "frameworkVersion": "0.22.2", + "runtime": "python", + "platforms": ["linux", "darwin", "win32"] + }, + { + "id": "openai-agents-typescript", + "version": "0.1.0", + "manifestSha256": "35b4e04cda42e227ea8d352e3c87ec1ed7c5f53b39aafb734e895d456a32c38f", + "implementationSha256ByPlatform": { + "linux": "36f76afbf7f9aa06535cb9261a0600dfc61f41658d7a7c9f755d416ae748ba61", + "darwin": "36f76afbf7f9aa06535cb9261a0600dfc61f41658d7a7c9f755d416ae748ba61", + "win32": "36f76afbf7f9aa06535cb9261a0600dfc61f41658d7a7c9f755d416ae748ba61" + }, + "frameworkVersion": "0.18.0", + "runtime": "node", + "platforms": ["linux", "darwin", "win32"] + } + ] +} diff --git a/packages/cli/src/agent-frameworks/user-flow.ts b/packages/cli/src/agent-frameworks/user-flow.ts index 193144b8..f7bbd402 100644 --- a/packages/cli/src/agent-frameworks/user-flow.ts +++ b/packages/cli/src/agent-frameworks/user-flow.ts @@ -241,7 +241,9 @@ export async function applyPreparedAgentFrameworkAttachment(input: { })), ownershipReceipt: applied.ownershipReceipt, nextActions: [ - `workspai workspace intelligence run --workspace ${JSON.stringify(input.prepared.workspacePath)} --for-agent generic --strict --json`, + `workspai workspace run init --workspace ${JSON.stringify(input.prepared.workspacePath)} --scope ${JSON.stringify(input.prepared.project)} --json`, + `workspai workspace run test --workspace ${JSON.stringify(input.prepared.workspacePath)} --scope ${JSON.stringify(input.prepared.project)} --json`, + `workspai workspace run build --workspace ${JSON.stringify(input.prepared.workspacePath)} --scope ${JSON.stringify(input.prepared.project)} --json`, `workspai change verify --workspace ${JSON.stringify(input.prepared.workspacePath)} --change ${input.prepared.changeId} --json`, ], }; @@ -286,7 +288,9 @@ export async function applyAgentFrameworkAttachmentByChange(input: { files: applied.files, ownershipReceipt: applied.ownershipReceipt, nextActions: [ - `workspai workspace intelligence run --workspace ${JSON.stringify(input.workspacePath)} --for-agent generic --strict --json`, + `workspai workspace run init --workspace ${JSON.stringify(input.workspacePath)} --scope ${JSON.stringify(applied.project)} --json`, + `workspai workspace run test --workspace ${JSON.stringify(input.workspacePath)} --scope ${JSON.stringify(applied.project)} --json`, + `workspai workspace run build --workspace ${JSON.stringify(input.workspacePath)} --scope ${JSON.stringify(applied.project)} --json`, `workspai change verify --workspace ${JSON.stringify(input.workspacePath)} --change ${input.changeId} --json`, ], }; diff --git a/packages/cli/src/commands/agent-framework.ts b/packages/cli/src/commands/agent-framework.ts index 6b7e0d77..425e99e0 100644 --- a/packages/cli/src/commands/agent-framework.ts +++ b/packages/cli/src/commands/agent-framework.ts @@ -244,6 +244,7 @@ export function registerAgentFrameworkCommands(agentCommand: Command): void { framework: entry.manifest.framework.name, runtime: entry.manifest.implementation.runtimes[0], frameworkVersion: entry.manifest.framework.testedVersions[0], + stability: entry.manifest.adapter.stability, status: resolution.status, blockers: resolution.blockers, }; @@ -258,7 +259,7 @@ export function registerAgentFrameworkCommands(agentCommand: Command): void { for (const adapter of adapters) { const icon = adapter.status === 'admitted' ? chalk.green('●') : chalk.red('■'); console.log( - `${icon} ${adapter.framework} · ${adapter.runtime} · ${adapter.frameworkVersion} · ${adapter.status}` + `${icon} ${adapter.framework} · ${adapter.runtime} · ${adapter.frameworkVersion} · ${adapter.stability} · ${adapter.status}` ); } } diff --git a/packages/cli/src/contracts/agent-framework-contract.ts b/packages/cli/src/contracts/agent-framework-contract.ts index 71347e3b..cdacbe40 100644 --- a/packages/cli/src/contracts/agent-framework-contract.ts +++ b/packages/cli/src/contracts/agent-framework-contract.ts @@ -3,13 +3,13 @@ export const AGENT_FRAMEWORK_CAPABILITIES_SCHEMA_VERSION = export const AGENT_FRAMEWORK_ADAPTER_MANIFEST_SCHEMA_VERSION = 'workspai.agent-framework-adapter-manifest.v1' as const; export const AGENT_FRAMEWORK_CONFORMANCE_REPORT_SCHEMA_VERSION = - 'workspai.agent-framework-conformance-report.v1' as const; + 'workspai.agent-framework-conformance-report.v2' as const; export const AGENT_FRAMEWORK_CHANGE_PLAN_SCHEMA_VERSION = 'workspai.agent-framework-change-plan.v1' as const; export const AGENT_FRAMEWORK_OWNERSHIP_RECEIPT_SCHEMA_VERSION = 'workspai.agent-framework-ownership-receipt.v1' as const; export const AGENT_FRAMEWORK_ADMISSION_CANDIDATE_SCHEMA_VERSION = - 'workspai.agent-framework-admission-candidate.v1' as const; + 'workspai.agent-framework-admission-candidate.v2' as const; export const AGENT_FRAMEWORK_ADAPTER_PROTOCOL_VERSION = 'workspai.agent-framework-adapter-protocol.v1' as const; @@ -18,13 +18,13 @@ export const AGENT_FRAMEWORK_CAPABILITIES_CONTRACT_PATH = export const AGENT_FRAMEWORK_ADAPTER_MANIFEST_CONTRACT_PATH = 'contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json' as const; export const AGENT_FRAMEWORK_CONFORMANCE_REPORT_CONTRACT_PATH = - 'contracts/workspace-intelligence/agent-framework-conformance-report.v1.json' as const; + 'contracts/workspace-intelligence/agent-framework-conformance-report.v2.json' as const; export const AGENT_FRAMEWORK_CHANGE_PLAN_CONTRACT_PATH = 'contracts/workspace-intelligence/agent-framework-change-plan.v1.json' as const; export const AGENT_FRAMEWORK_OWNERSHIP_RECEIPT_CONTRACT_PATH = 'contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json' as const; export const AGENT_FRAMEWORK_ADMISSION_CANDIDATE_CONTRACT_PATH = - 'contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json' as const; + 'contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json' as const; export const AGENT_FRAMEWORK_CAPABILITY_IDS = [ 'single-agent', @@ -230,6 +230,7 @@ export type AgentFrameworkConformanceReport = { id: string; version: string; manifestSha256: string; + implementationSha256: string; }; frameworkVersion: string; cliVersion: string; @@ -269,6 +270,7 @@ export type AgentFrameworkAdmissionCandidate = { id: string; version: string; manifestSha256: string; + implementationSha256ByPlatform: Record<'linux' | 'darwin' | 'win32', string>; framework: { id: string }; lanes: Array<{ platform: 'linux' | 'darwin' | 'win32'; @@ -615,8 +617,8 @@ export function buildAgentFrameworkAdapterManifestSchema() { export function buildAgentFrameworkConformanceReportSchema() { return { $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://workspai.dev/contracts/workspace-intelligence/agent-framework-conformance-report.v1.json', - title: 'Workspai Agent Framework Conformance Report v1', + $id: 'https://workspai.dev/contracts/workspace-intelligence/agent-framework-conformance-report.v2.json', + title: 'Workspai Agent Framework Conformance Report v2', ...strictObject({ schemaVersion: { const: AGENT_FRAMEWORK_CONFORMANCE_REPORT_SCHEMA_VERSION }, protocolVersion: { const: AGENT_FRAMEWORK_ADAPTER_PROTOCOL_VERSION }, @@ -625,6 +627,7 @@ export function buildAgentFrameworkConformanceReportSchema() { id: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$' }, version: { type: 'string', minLength: 1 }, manifestSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + implementationSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, }), frameworkVersion: { type: 'string', minLength: 1 }, cliVersion: { type: 'string', minLength: 1 }, @@ -786,8 +789,8 @@ export function buildAgentFrameworkAdmissionCandidateSchema() { }); return { $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://workspai.dev/contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json', - title: 'Workspai Agent Framework Admission Candidate v1', + $id: 'https://workspai.dev/contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json', + title: 'Workspai Agent Framework Admission Candidate v2', ...strictObject({ schemaVersion: { const: AGENT_FRAMEWORK_ADMISSION_CANDIDATE_SCHEMA_VERSION }, protocolVersion: { const: AGENT_FRAMEWORK_ADAPTER_PROTOCOL_VERSION }, @@ -802,6 +805,11 @@ export function buildAgentFrameworkAdmissionCandidateSchema() { id: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$' }, version: { type: 'string', minLength: 1 }, manifestSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + implementationSha256ByPlatform: strictObject({ + linux: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + darwin: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + win32: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + }), framework: strictObject({ id: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$' }, }), diff --git a/packages/cli/src/contracts/published-contract-versions.ts b/packages/cli/src/contracts/published-contract-versions.ts index 2a2675cf..7889bc06 100644 --- a/packages/cli/src/contracts/published-contract-versions.ts +++ b/packages/cli/src/contracts/published-contract-versions.ts @@ -193,9 +193,11 @@ export function getPublishedContractVersions() { agentFrameworkCapabilities: AGENT_FRAMEWORK_CAPABILITIES_SCHEMA_VERSION, agentFrameworkAdapterManifest: AGENT_FRAMEWORK_ADAPTER_MANIFEST_SCHEMA_VERSION, agentFrameworkConformanceReport: AGENT_FRAMEWORK_CONFORMANCE_REPORT_SCHEMA_VERSION, + agentFrameworkConformanceReportLegacy: 'workspai.agent-framework-conformance-report.v1', agentFrameworkChangePlan: AGENT_FRAMEWORK_CHANGE_PLAN_SCHEMA_VERSION, agentFrameworkOwnershipReceipt: AGENT_FRAMEWORK_OWNERSHIP_RECEIPT_SCHEMA_VERSION, agentFrameworkAdmissionCandidate: AGENT_FRAMEWORK_ADMISSION_CANDIDATE_SCHEMA_VERSION, + agentFrameworkAdmissionCandidateLegacy: 'workspai.agent-framework-admission-candidate.v1', agentCustomizationPackReport: WORKSPACE_INTELLIGENCE_ARTIFACT_SCHEMAS.agentCustomizationPack, agentReportsIndex: WORKSPACE_INTELLIGENCE_ARTIFACT_SCHEMAS.agentIndex, workspaceOperationalSkill: WORKSPACE_OPERATIONAL_SKILL_SCHEMA_VERSION, @@ -336,12 +338,16 @@ export function getPublishedContractCatalog() { agentFrameworkAdapterManifest: 'contracts/workspace-intelligence/agent-framework-adapter-manifest.v1.json', agentFrameworkConformanceReport: + 'contracts/workspace-intelligence/agent-framework-conformance-report.v2.json', + agentFrameworkConformanceReportLegacy: 'contracts/workspace-intelligence/agent-framework-conformance-report.v1.json', agentFrameworkChangePlan: 'contracts/workspace-intelligence/agent-framework-change-plan.v1.json', agentFrameworkOwnershipReceipt: 'contracts/workspace-intelligence/agent-framework-ownership-receipt.v1.json', agentFrameworkAdmissionCandidate: + 'contracts/workspace-intelligence/agent-framework-admission-candidate.v2.json', + agentFrameworkAdmissionCandidateLegacy: 'contracts/workspace-intelligence/agent-framework-admission-candidate.v1.json', agentCustomizationPackReport: 'contracts/workspace-intelligence/agent-customization-pack-report.v1.json', diff --git a/packages/cli/src/goal-lifecycle.ts b/packages/cli/src/goal-lifecycle.ts index aa46be31..f510283f 100644 --- a/packages/cli/src/goal-lifecycle.ts +++ b/packages/cli/src/goal-lifecycle.ts @@ -161,6 +161,21 @@ async function assertGoalBindings(workspacePath: string, entry: GoalIndexEntry): break; } } + if (!sanctioned) { + const { assertAppliedProofCarryingChangeCurrent } = + await import('./proof-carrying-change.js'); + for (const changeId of [...changeTransactionIds].reverse()) { + const binding = await assertAppliedProofCarryingChangeCurrent({ + workspacePath, + changeId, + goalId: entry.id, + }).catch(() => undefined); + if (binding?.modelHash === currentModelHash && binding.graphHash === currentGraphHash) { + sanctioned = true; + break; + } + } + } const transactionIds = entry.repairTransactionIds ?? (entry.repairTransactionId ? [entry.repairTransactionId] : []); const { assertClosedGoalRepairTransactionCurrent } = diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0f6bd075..812dd37d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -81,6 +81,7 @@ import { } from './utils/cli-lifecycle-contract.js'; import { findRapidkitProjectRoot } from './utils/project-command-capabilities.js'; import { missingShellActivationDiagnostic } from './utils/shell-activation-diagnostics.js'; +import { enrichBridgeBackedProjectMetadata } from './utils/bridge-kit-metadata.js'; import { canonicalizeProjectMetadata } from './utils/project-metadata.js'; import { ProjectWorkspaceResolutionError, @@ -1111,13 +1112,18 @@ async function finalizeCreatedProjectWorkspace( }) => Promise; } = {} ): Promise { - if (args.includes('--no-workspace') || args.includes('--dry-run')) { + if (args.includes('--dry-run')) { return undefined; } if (!(await fsExtra.pathExists(projectPath))) { logger.warn(`Created project path was not found for workspace linking: ${projectPath}`); return undefined; } + await canonicalizeProjectMetadata(projectPath); + await enrichBridgeBackedProjectMetadata(projectPath); + if (args.includes('--no-workspace')) { + return undefined; + } let workspacePath = findWorkspaceUp(projectPath) || findWorkspaceUp(process.cwd()); let linkedToDefaultWorkspace = false; @@ -1141,7 +1147,6 @@ async function finalizeCreatedProjectWorkspace( let relationship = 'managed'; try { - await canonicalizeProjectMetadata(projectPath); const relativeProjectPath = path.relative(workspacePath, projectPath); const projectIsExternal = relativeProjectPath.startsWith('..') || path.isAbsolute(relativeProjectPath); diff --git a/packages/cli/src/polyglot-lifecycle-plan.ts b/packages/cli/src/polyglot-lifecycle-plan.ts index a9da95a3..341d0f70 100644 --- a/packages/cli/src/polyglot-lifecycle-plan.ts +++ b/packages/cli/src/polyglot-lifecycle-plan.ts @@ -2,15 +2,28 @@ import fs from 'fs'; import path from 'path'; import { detectNodePackageManager } from './utils/node-package-manager.js'; -import { getDefaultPythonCommand } from './utils/platform-capabilities.js'; +import { + argvPathFrom, + getDefaultPythonCommand, + getVenvPipPath, + getVenvPythonPath, + portablePathFrom, + resolvePythonLifecycleInterpreter, +} from './utils/platform-capabilities.js'; export const POLYGLOT_LIFECYCLE_PLAN_SCHEMA_VERSION = 'polyglot-lifecycle-plan.v1' as const; +export type PolyglotLifecycleArgvStep = { + executable: string; + args: string[]; +}; + export type PolyglotLifecycleStage = { stage: 'init' | 'test' | 'build' | 'start'; command: string; confidence: 'high' | 'medium'; preflight: 'executable-and-inputs' | 'executable'; + argvSequence?: PolyglotLifecycleArgvStep[]; }; export type PolyglotRuntimeUnit = { @@ -481,12 +494,144 @@ function isOwnedWorkspaceMember( ); } +function quoteLifecycleToken(token: string): string { + if (/^[A-Za-z0-9_./:@%+,=\\-]+$/.test(token)) { + return token; + } + return `"${token.replace(/(["\\])/g, '\\$1')}"`; +} + +function formatArgvSequence(steps: PolyglotLifecycleArgvStep[]): string { + return steps + .map((step) => + [quoteLifecycleToken(step.executable), ...step.args.map(quoteLifecycleToken)].join(' ') + ) + .join(' && '); +} + function stage( name: PolyglotLifecycleStage['stage'], command: string, - confidence: PolyglotLifecycleStage['confidence'] = 'high' + confidence: PolyglotLifecycleStage['confidence'] = 'high', + argvSequence?: PolyglotLifecycleArgvStep[] ): PolyglotLifecycleStage { - return { stage: name, command, confidence, preflight: 'executable-and-inputs' }; + return { + stage: name, + command, + confidence, + preflight: 'executable-and-inputs', + ...(argvSequence && argvSequence.length > 0 ? { argvSequence } : {}), + }; +} + +function pythonLifecycleStages(input: { + projectRoot: string; + unitRoot: string; + contents: string; + poetryManaged: boolean; +}): PolyglotLifecycleStage[] { + const { unitRoot, contents, poetryManaged } = input; + const resolution = resolvePythonLifecycleInterpreter({ + unitRoot, + projectRoot: input.projectRoot, + }); + const hasStartScript = + hasTomlScript(contents, 'tool.poetry.scripts', 'start') || + hasTomlScript(contents, 'project.scripts', 'start'); + const hasMainModule = fs.existsSync(path.join(unitRoot, 'main.py')); + const requiresUvLock = + /^\s*\[tool\.workspai\]\s*$[\s\S]*?^\s*lifecycle-lock-tool\s*=\s*["']uv["']\s*$/m.test( + contents + ); + const venvPythonPath = getVenvPythonPath(resolution.venvPath); + const displayVenvPython = portablePathFrom(unitRoot, venvPythonPath); + const argvVenvPython = argvPathFrom(unitRoot, venvPythonPath); + const displayPython = poetryManaged ? getDefaultPythonCommand() : displayVenvPython; + const testCommand = pythonTestCommand(unitRoot, contents, poetryManaged, displayPython); + + if (poetryManaged) { + return [ + stage('init', 'poetry install'), + ...(testCommand ? [stage('test', testCommand)] : []), + ...(hasMainModule + ? [stage('build', `${displayPython} -m compileall .`)] + : /\[build-system\]/.test(contents) + ? [stage('build', 'poetry build', 'medium')] + : []), + ...(hasMainModule + ? [stage('start', `${displayPython} main.py`)] + : hasStartScript + ? [stage('start', 'poetry run start', 'medium')] + : []), + ]; + } + + const venvDir = argvPathFrom(unitRoot, resolution.venvPath); + const pipAvailable = + resolution.pipAvailable || fs.existsSync(getVenvPipPath(resolution.venvPath)); + const initSteps: PolyglotLifecycleArgvStep[] = []; + if (!resolution.venvExists) { + initSteps.push({ + executable: getDefaultPythonCommand(), + args: ['-m', 'venv', venvDir], + }); + } + if (!resolution.venvExists || !pipAvailable) { + initSteps.push({ + executable: argvVenvPython, + args: ['-m', 'ensurepip', '--upgrade'], + }); + } + initSteps.push({ + executable: argvVenvPython, + args: ['-m', 'pip', 'install', '-e', '.'], + }); + if (requiresUvLock) { + initSteps.push({ + executable: 'uv', + args: ['lock'], + }); + } + + const pytestOwned = Boolean( + testCommand && (testCommand.includes(' -m pytest') || testCommand.includes('pytest')) + ); + + return [ + stage('init', formatArgvSequence(initSteps), 'high', initSteps), + ...(testCommand + ? [ + stage('test', testCommand, 'high', [ + { + executable: argvVenvPython, + args: pytestOwned ? ['-m', 'pytest'] : ['-m', 'unittest', 'discover', '-s', 'tests'], + }, + ]), + ] + : []), + ...(hasMainModule + ? [ + stage('build', `${displayVenvPython} -m compileall .`, 'high', [ + { executable: argvVenvPython, args: ['-m', 'compileall', '.'] }, + ]), + ] + : /\[build-system\]/.test(contents) + ? [ + stage('build', `${displayVenvPython} -m build`, 'medium', [ + { executable: argvVenvPython, args: ['-m', 'build'] }, + ]), + ] + : []), + ...(hasMainModule + ? [ + stage('start', `${displayVenvPython} main.py`, 'high', [ + { executable: argvVenvPython, args: ['main.py'] }, + ]), + ] + : hasStartScript + ? [stage('start', 'start', 'medium')] + : []), + ]; } function nodeStages(root: string, contents: string): PolyglotLifecycleStage[] { @@ -618,26 +763,12 @@ function manifestUnit(projectRoot: string, manifest: string): PolyglotRuntimeUni runtime = 'python'; ecosystem = 'python'; const poetryManaged = /^\s*\[tool\.poetry\]\s*$/m.test(contents); - const python = getDefaultPythonCommand(); - const hasStartScript = - hasTomlScript(contents, 'tool.poetry.scripts', 'start') || - hasTomlScript(contents, 'project.scripts', 'start'); - const hasMainModule = fs.existsSync(path.join(root, 'main.py')); - const testCommand = pythonTestCommand(root, contents, poetryManaged, python); - stages = [ - stage('init', poetryManaged ? 'poetry install' : `${python} -m pip install -e .`), - ...(testCommand ? [stage('test', testCommand)] : []), - ...(hasMainModule - ? [stage('build', `${python} -m compileall .`)] - : /\[build-system\]/.test(contents) - ? [stage('build', poetryManaged ? 'poetry build' : `${python} -m build`, 'medium')] - : []), - ...(hasMainModule - ? [stage('start', `${python} main.py`)] - : hasStartScript - ? [stage('start', poetryManaged ? 'poetry run start' : 'start', 'medium')] - : []), - ]; + stages = pythonLifecycleStages({ + projectRoot, + unitRoot: root, + contents, + poetryManaged, + }); } else if (name === 'go.mod') { runtime = 'go'; ecosystem = 'go'; diff --git a/packages/cli/src/proof-carrying-change.ts b/packages/cli/src/proof-carrying-change.ts index 1ee4c8cf..dc8b311d 100644 --- a/packages/cli/src/proof-carrying-change.ts +++ b/packages/cli/src/proof-carrying-change.ts @@ -61,6 +61,7 @@ import { buildWorkspaceVerify, evaluateWorkspaceVerifyGate, writeWorkspaceVerify, + type WorkspaceVerify, } from './workspace-verify.js'; type ChangePaths = { @@ -348,6 +349,28 @@ function operationKey(operation: string, targetKind: string, targetId: string): return `${operation}\u0000${targetKind}\u0000${targetId}`; } +const AGENT_FRAMEWORK_PLAN_ROLE = 'agent-framework-change-plan'; +const AGENT_FRAMEWORK_RUNTIME_STAGES = new Set(['init', 'test', 'build']); + +function agentFrameworkRuntimeVerificationPending(input: { + record: DecisionTransactionRecord; + lease: ArchitectureChangeLease; + verify: WorkspaceVerify; +}): boolean { + const isAgentFramework = input.record.transaction.plans.some( + (plan) => plan.role === AGENT_FRAMEWORK_PLAN_ROLE + ); + if (!isAgentFramework) return false; + const scoped = new Set(input.lease.scope.projects); + const scopedStages = input.verify.steps.filter((step) => { + if (step.scope !== 'project' || !step.project || !scoped.has(step.project)) return false; + const stage = step.id.split('.').at(-1); + return stage !== undefined && AGENT_FRAMEWORK_RUNTIME_STAGES.has(stage); + }); + if (scopedStages.some((step) => step.status === 'fail')) return false; + return scopedStages.length === 0 || scopedStages.some((step) => step.status === 'missing'); +} + function actualOperations(overlay: WorkspaceKnowledgeGraphChangeOverlay) { const operations: Array<{ operation: string; targetKind: string; targetId: string }> = []; for (const [targetKind, group] of [ @@ -1744,6 +1767,31 @@ export async function verifyProofCarryingChange(input: { ], }); } + const verify = await buildWorkspaceVerify({ workspacePath }); + if ( + !evaluateWorkspaceVerifyGate(verify, { strict: input.strict === true }).passed && + agentFrameworkRuntimeVerificationPending({ record, lease, verify }) + ) { + await writeWorkspaceVerify(verify, workspacePath); + const capsule = await publishCapsule(workspacePath, lease, record); + const scopedProject = lease.scope.projects[0]; + return result({ + operation: 'verify', + lease, + record, + capsule, + nextActions: [ + ...(scopedProject + ? [ + `workspai workspace run init --workspace ${JSON.stringify(workspacePath)} --scope ${JSON.stringify(scopedProject)} --json`, + `workspai workspace run test --workspace ${JSON.stringify(workspacePath)} --scope ${JSON.stringify(scopedProject)} --json`, + `workspai workspace run build --workspace ${JSON.stringify(workspacePath)} --scope ${JSON.stringify(scopedProject)} --json`, + ] + : []), + `workspai change verify --change ${input.changeId} --json`, + ], + }); + } record = await append( workspacePath, record, @@ -1755,7 +1803,6 @@ export async function verifyProofCarryingChange(input: { actorId ) ); - const verify = await buildWorkspaceVerify({ workspacePath }); const verifyPath = await writeWorkspaceVerify(verify, workspacePath); const relativeVerifyPath = path.relative(workspacePath, verifyPath).split(path.sep).join('/'); const persistedVerify = await readJson(workspacePath, relativeVerifyPath); @@ -2244,6 +2291,159 @@ export function decisionTransactionForCapsule( ); } +export async function assertAppliedProofCarryingChangeCurrent(input: { + workspacePath: string; + changeId: string; + goalId: string; +}): Promise<{ modelHash: string; graphHash: string }> { + const { lease, record } = await loadChange(input.workspacePath, input.changeId); + if (lease.goalId !== input.goalId) { + throw new Error( + `Proof-carrying change ${input.changeId} is not bound to Goal ${input.goalId}.` + ); + } + const pendingStates = new Set(['authorized', 'executing', 'verifying']); + const blockedPendingVerification = + record.transaction.state === 'blocked' && + record.transaction.blockers.length > 0 && + record.transaction.blockers.every( + (blocker) => blocker.code === 'change.verification.criteria_missing' + ); + if (!pendingStates.has(record.transaction.state) && !blockedPendingVerification) { + throw new Error( + `Proof-carrying change ${input.changeId} is not an applied scaffold pending verification.` + ); + } + const filesystemEffects = record.transaction.effects.filter( + (effect) => effect.effectClass === 'filesystem' + ); + if (filesystemEffects.length === 0) { + throw new Error( + `Proof-carrying change ${input.changeId} has no succeeded filesystem effect receipt.` + ); + } + if ( + filesystemEffects.some( + (effect) => effect.status !== 'succeeded' || effect.artifacts.length === 0 + ) + ) { + throw new Error( + `Proof-carrying change ${input.changeId} has incomplete or unsuccessful filesystem effects.` + ); + } + for (const effect of filesystemEffects) { + for (const artifact of effect.artifacts) { + if (!(await artifactDigestMatches(input.workspacePath, artifact))) { + throw new Error( + `Proof-carrying change ${input.changeId} no longer matches effect artifact ${artifact.artifact}.` + ); + } + } + } + + const agentPlanReference = record.transaction.plans.find( + (reference) => reference.role === AGENT_FRAMEWORK_PLAN_ROLE + ); + if (agentPlanReference) { + const plan = await readJson>( + input.workspacePath, + agentPlanReference.artifact + ); + if (!plan || hashCanonicalJson(plan) !== agentPlanReference.digest.value) { + throw new Error( + `Proof-carrying change ${input.changeId} has a missing or corrupt agent framework plan.` + ); + } + const target = plan.target as { artifactPrefix?: unknown } | undefined; + const prefix = + typeof target?.artifactPrefix === 'string' + ? normalizedArtifactIdentity(target.artifactPrefix).replace(/\/$/u, '') + : ''; + const expectedFiles = Array.isArray(plan.files) + ? plan.files.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object') return []; + const filePath = (candidate as { path?: unknown }).path; + if (typeof filePath !== 'string') return []; + const normalized = normalizedArtifactIdentity(filePath); + return [prefix ? `${prefix}/${normalized}` : normalized]; + }) + : []; + const receiptedArtifacts = new Set( + filesystemEffects.flatMap((effect) => + effect.artifacts.map((artifact) => normalizedArtifactIdentity(artifact.artifact)) + ) + ); + const missing = expectedFiles.filter((artifact) => !receiptedArtifacts.has(artifact)); + if (expectedFiles.length === 0 || missing.length > 0) { + throw new Error( + `Proof-carrying change ${input.changeId} has a partial filesystem effect receipt.` + ); + } + } + + const snapshot = await currentSnapshot(input.workspacePath); + const current = snapshotGeneration(snapshot); + const baseline = await readJson( + input.workspacePath, + lease.privateMaterialization.artifact + ); + if (!baseline || hashCanonicalJson(baseline) !== lease.privateMaterialization.digest.value) { + throw new Error( + `Proof-carrying change ${input.changeId} has a missing or corrupt architecture baseline.` + ); + } + const overlay = buildWorkspaceKnowledgeGraphChangeOverlay(baseline, snapshot.graph); + const allowedArtifacts = new Set(); + const changesRoot = await resolveContainedWorkspaceArtifactPath( + input.workspacePath, + '.workspai/changes', + 'directory' + ); + const entries = changesRoot ? await fsExtra.readdir(changesRoot, { withFileTypes: true }) : []; + for (const entry of entries) { + if (!entry.isDirectory() || !/^change-[a-z0-9][a-z0-9-]{7,95}$/.test(entry.name)) continue; + const candidate = await loadChange(input.workspacePath, entry.name).catch(() => null); + if (!candidate || candidate.record.transaction.createdAt < lease.createdAt) continue; + const successfulFilesystemEffects = candidate.record.transaction.effects.filter( + (effect) => effect.effectClass === 'filesystem' && effect.status === 'succeeded' + ); + if (successfulFilesystemEffects.length === 0) continue; + for (const effect of successfulFilesystemEffects) { + for (const artifact of effect.artifacts) { + if (await artifactDigestMatches(input.workspacePath, artifact)) { + allowedArtifacts.add(normalizedArtifactIdentity(artifact.artifact)); + } + } + } + const predictionReference = candidate.record.transaction.plans.find( + (reference) => reference.role === 'noncanonical-prediction' + ); + if (!predictionReference) continue; + const prediction = await readJson( + input.workspacePath, + predictionReference.artifact + ); + if (!prediction || hashCanonicalJson(prediction) !== predictionReference.digest.value) continue; + for (const operation of prediction.operations) { + if (operation.targetKind === 'artifact') { + allowedArtifacts.add(normalizedArtifactIdentity(operation.targetId)); + } + } + } + const unexplained = overlay.changedArtifacts + .map(normalizedArtifactIdentity) + .filter((artifact) => !allowedArtifacts.has(artifact)); + if (unexplained.length > 0) { + throw new Error( + `Proof-carrying change ${input.changeId} does not explain current architecture artifacts: ${unexplained.slice(0, 3).join(', ')}.` + ); + } + return { + modelHash: current.modelHash, + graphHash: current.graphHash, + }; +} + export async function assertSealedProofCarryingChangeCurrent(input: { workspacePath: string; changeId: string; diff --git a/packages/cli/src/utils/bridge-kit-metadata.ts b/packages/cli/src/utils/bridge-kit-metadata.ts new file mode 100644 index 00000000..a6c02cdf --- /dev/null +++ b/packages/cli/src/utils/bridge-kit-metadata.ts @@ -0,0 +1,210 @@ +import fs from 'fs'; +import path from 'path'; + +import fsExtra from 'fs-extra'; + +import { isPythonCoreContextEngine } from '../core-bridge/coreForwarding.js'; +import { getVersion } from '../update-checker.js'; +import { resolveKitDefinition, type KitDefinition } from './kit-registry.js'; +import { + LEGACY_RAPIDKIT_WORKSPACE_MARKER, + WORKSPAI_WORKSPACE_MARKER, + projectMetadataPath, +} from './workspace-paths.js'; + +export async function enrichBridgeBackedProjectMetadata(projectRoot: string): Promise { + const root = path.resolve(projectRoot); + const projectJsonPath = projectMetadataPath(root, 'project.json'); + if (!(await fsExtra.pathExists(projectJsonPath))) { + return false; + } + + const existingProject = await readJsonRecord(projectJsonPath); + if (!existingProject) { + return false; + } + + const kitName = + (typeof existingProject.kit_name === 'string' && existingProject.kit_name) || + (typeof existingProject.kit === 'string' && existingProject.kit) || + undefined; + const kit = resolveKitDefinition(kitName); + if (!kit || kit.owner !== 'core') { + return false; + } + + const projectName = authoredString(existingProject.name) || path.basename(root); + const pythonProjectEngine = await detectPythonInstallEngineFromProject(root); + const contextJsonPath = projectMetadataPath(root, 'context.json'); + const existingContext = (await readJsonRecord(contextJsonPath)) ?? {}; + const contextEngine = await resolveBridgeContextEngine({ + kit, + projectRoot: root, + existingContextEngine: existingContext.engine, + pythonProjectEngine, + }); + const coreVersion = resolveRapidkitCoreVersion(existingProject); + const generatedAt = + authoredString(existingProject.generated_at) || + authoredString(existingProject.created_at) || + new Date().toISOString(); + + const nextProject: Record = { + ...existingProject, + schema_version: + authoredString(existingProject.schema_version) ?? existingProject.schema_version ?? '1.0', + name: authoredString(existingProject.name) || projectName, + slug: authoredString(existingProject.slug) || projectName, + kind: authoredString(existingProject.kind) || kit.category, + project_type: authoredString(existingProject.project_type) || kit.category, + category: authoredString(existingProject.category) || kit.category, + runtime: authoredString(existingProject.runtime) || kit.runtime, + framework: authoredString(existingProject.framework) || kit.framework, + kit_name: kit.id, + kit: authoredString(existingProject.kit) || kit.id, + engine: + kit.runtime === 'node' + ? authoredString(existingProject.engine) === 'npm' + ? existingProject.engine + : 'npm' + : pythonProjectEngine || + (isPythonCoreContextEngine(existingProject.engine) + ? existingProject.engine + : contextEngine), + module_support: + typeof existingProject.module_support === 'boolean' + ? existingProject.module_support + : kit.moduleSupport, + workspai_version: authoredString(existingProject.workspai_version) || getVersion(), + generated_by: authoredString(existingProject.generated_by) || 'workspai', + generated_at: generatedAt, + generator: existingProject.generator ?? { + id: kit.id, + source: 'rapidkit-core-bridge', + official: false, + }, + }; + if (coreVersion && nextProject.rapidkit_core_version === undefined) { + nextProject.rapidkit_core_version = coreVersion; + } + + const nextContext: Record = { + ...existingContext, + engine: contextEngine, + project: authoredString(existingContext.project) || projectName, + kind: authoredString(existingContext.kind) || kit.category, + category: authoredString(existingContext.category) || kit.category, + runtime: authoredString(existingContext.runtime) || kit.runtime, + framework: authoredString(existingContext.framework) || kit.framework, + source: authoredString(existingContext.source) || 'core-bridge', + }; + + await fsExtra.ensureDir(path.dirname(projectJsonPath)); + await fsExtra.writeJson(projectJsonPath, nextProject, { spaces: 2 }); + await fsExtra.writeJson(contextJsonPath, nextContext, { spaces: 2 }); + return true; +} + +function authoredString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function resolveRapidkitCoreVersion(payload: Record): string | undefined { + if (typeof payload.rapidkit_core_version === 'string' && payload.rapidkit_core_version.trim()) { + return payload.rapidkit_core_version; + } + const generatedBy = authoredString(payload.generated_by); + const createdBy = authoredString(payload.created_by); + const version = authoredString(payload.rapidkit_version); + if (!version) return undefined; + if (generatedBy === 'workspai' || createdBy === 'workspai-cli-fallback') { + return undefined; + } + if (payload.schema_version) { + return undefined; + } + return version; +} + +async function detectPythonInstallEngineFromProject( + projectRoot: string +): Promise<'poetry' | 'uv' | 'pip' | undefined> { + const pyprojectPath = path.join(projectRoot, 'pyproject.toml'); + if (await fsExtra.pathExists(pyprojectPath)) { + const content = await fsExtra.readFile(pyprojectPath, 'utf8'); + if (/\[tool\.poetry\]/.test(content)) return 'poetry'; + if (/\[tool\.uv\]/.test(content)) return 'uv'; + } + if (await fsExtra.pathExists(path.join(projectRoot, 'uv.lock'))) return 'uv'; + if (await fsExtra.pathExists(path.join(projectRoot, 'requirements.txt'))) return 'pip'; + return undefined; +} + +async function resolveBridgeContextEngine(input: { + kit: KitDefinition; + projectRoot: string; + existingContextEngine: unknown; + pythonProjectEngine: 'poetry' | 'uv' | 'pip' | undefined; +}): Promise { + if (input.kit.runtime === 'python') { + return ( + input.pythonProjectEngine || + fallbackPythonControlPlaneEngine(input.existingContextEngine) || + 'pip' + ); + } + + const existing = fallbackPythonControlPlaneEngine(input.existingContextEngine, { + allowPip: false, + }); + if (existing) return existing; + const workspaceEngine = await detectWorkspacePythonControlPlaneEngine(input.projectRoot); + if (workspaceEngine) return workspaceEngine; + return fallbackPythonControlPlaneEngine(input.existingContextEngine) || 'pip'; +} + +function fallbackPythonControlPlaneEngine( + value: unknown, + options: { allowPip?: boolean } = {} +): string | undefined { + if (!isPythonCoreContextEngine(value)) return undefined; + if (options.allowPip === false && (value === 'pip' || value === 'python')) { + return undefined; + } + return value; +} + +async function detectWorkspacePythonControlPlaneEngine( + startPath: string +): Promise<'poetry' | 'venv' | 'pipx' | undefined> { + let current = path.resolve(startPath); + while (true) { + for (const markerName of [WORKSPAI_WORKSPACE_MARKER, LEGACY_RAPIDKIT_WORKSPACE_MARKER]) { + const markerPath = path.join(current, markerName); + if (!fs.existsSync(markerPath)) continue; + const marker = await readJsonRecord(markerPath); + const installMethod = (marker?.metadata as Record | undefined)?.npm as + Record | undefined; + const method = installMethod?.installMethod; + if (method === 'poetry' || method === 'venv' || method === 'pipx') { + return method; + } + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return undefined; +} + +async function readJsonRecord(filePath: string): Promise | null> { + if (!(await fsExtra.pathExists(filePath))) return null; + try { + const payload = await fsExtra.readJson(filePath); + return payload && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as Record) + : null; + } catch { + return null; + } +} diff --git a/packages/cli/src/utils/doctor-surface-probes.ts b/packages/cli/src/utils/doctor-surface-probes.ts index 63d47837..97350e38 100644 --- a/packages/cli/src/utils/doctor-surface-probes.ts +++ b/packages/cli/src/utils/doctor-surface-probes.ts @@ -1153,6 +1153,20 @@ function buildManualRepair(input: { }; } +async function existingRepairFiles( + projectPath: string, + relativeFiles: string[] +): Promise { + const unique = [...new Set(relativeFiles.filter((file) => file && !file.includes('*')))]; + const existing: string[] = []; + for (const relativeFile of unique) { + if (await fsExtra.pathExists(path.join(projectPath, relativeFile))) { + existing.push(relativeFile); + } + } + return existing; +} + function buildDockerignoreRepair(projectPath: string): DoctorRepairCapability { return buildFileCreateRepairCapability({ issueId: 'surface-dockerignore', @@ -1535,11 +1549,11 @@ async function buildSecurityHygieneProbe(input: SurfaceInput): Promise 0 diff --git a/packages/cli/src/utils/platform-capabilities.ts b/packages/cli/src/utils/platform-capabilities.ts index 4611f5d0..72150e47 100644 --- a/packages/cli/src/utils/platform-capabilities.ts +++ b/packages/cli/src/utils/platform-capabilities.ts @@ -249,6 +249,181 @@ export function getVenvPythonPath( : path.join(venvPath, 'bin', 'python'); } +export function getVenvPipPath( + venvPath: string, + platform: NodeJS.Platform = process.platform +): string { + return isWindowsPlatform(platform) + ? path.join(venvPath, 'Scripts', 'pip.exe') + : path.join(venvPath, 'bin', 'pip'); +} + +export const WORKSPAI_PYTHON_INTERPRETER_ENV = 'WORKSPAI_PYTHON'; + +export type PythonLifecycleInterpreterSource = + 'configured' | 'project-venv' | 'unit-venv' | 'active-project-venv' | 'system'; + +export type PythonLifecycleInterpreterResolution = { + interpreter: string; + source: PythonLifecycleInterpreterSource; + venvPath: string; + venvExists: boolean; + pipAvailable: boolean; + usedForDependencyInstall: boolean; +}; + +function pathIsInside(parent: string, child: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(child)); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function isExistingFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +export function findOwningWorkspaiProjectRoot(startPath: string): string { + let cursor = path.resolve(startPath); + for (let depth = 0; depth < 8; depth += 1) { + if (fs.existsSync(path.join(cursor, '.workspai', 'project.json'))) { + return cursor; + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return path.resolve(startPath); +} + +function interpreterFromVenv( + venvPath: string, + platform: NodeJS.Platform +): { interpreter: string; pipAvailable: boolean } | null { + const interpreter = getVenvPythonPath(venvPath, platform); + if (!isExistingFile(interpreter)) { + return null; + } + return { + interpreter, + pipAvailable: isExistingFile(getVenvPipPath(venvPath, platform)), + }; +} + +/** + * Resolve the Python interpreter for a nested runtime unit. + * + * Preference order: + * 1. `WORKSPAI_PYTHON` when it points at an existing interpreter + * 2. `/.venv` (`bin/python` or `Scripts/python.exe`) + * 3. `/.venv` when the owning project has no venv + * 4. an active `VIRTUAL_ENV` only when it is contained by the project + * 5. the system interpreter, which may create a venv but must never receive + * `pip install` of agent dependencies + */ +export function resolvePythonLifecycleInterpreter(input: { + unitRoot: string; + projectRoot?: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}): PythonLifecycleInterpreterResolution { + const platform = input.platform ?? process.platform; + const env = input.env ?? process.env; + const unitRoot = path.resolve(input.unitRoot); + const projectRoot = path.resolve(input.projectRoot ?? findOwningWorkspaiProjectRoot(unitRoot)); + const projectVenvPath = path.join(projectRoot, '.venv'); + const unitVenvPath = path.join(unitRoot, '.venv'); + + const configured = env[WORKSPAI_PYTHON_INTERPRETER_ENV]?.trim(); + if ( + configured && + isExistingFile(configured) && + (pathIsInside(projectVenvPath, configured) || pathIsInside(unitVenvPath, configured)) + ) { + const configuredVenv = pathIsInside(projectVenvPath, configured) + ? projectVenvPath + : unitVenvPath; + return { + interpreter: configured, + source: 'configured', + venvPath: configuredVenv, + venvExists: true, + pipAvailable: + isExistingFile(getVenvPipPath(configuredVenv, platform)) || + pathIsInside(configuredVenv, configured), + usedForDependencyInstall: true, + }; + } + + const projectVenv = interpreterFromVenv(projectVenvPath, platform); + if (projectVenv) { + return { + interpreter: projectVenv.interpreter, + source: 'project-venv', + venvPath: projectVenvPath, + venvExists: true, + pipAvailable: projectVenv.pipAvailable, + usedForDependencyInstall: true, + }; + } + + const unitVenv = interpreterFromVenv(unitVenvPath, platform); + if (unitVenv) { + return { + interpreter: unitVenv.interpreter, + source: 'unit-venv', + venvPath: unitVenvPath, + venvExists: true, + pipAvailable: unitVenv.pipAvailable, + usedForDependencyInstall: true, + }; + } + + const virtualEnv = env.VIRTUAL_ENV?.trim(); + if (virtualEnv) { + const active = interpreterFromVenv(virtualEnv, platform); + if (active && (pathIsInside(projectRoot, virtualEnv) || pathIsInside(unitRoot, virtualEnv))) { + return { + interpreter: active.interpreter, + source: 'active-project-venv', + venvPath: virtualEnv, + venvExists: true, + pipAvailable: active.pipAvailable, + usedForDependencyInstall: true, + }; + } + } + + return { + interpreter: getDefaultPythonCommand(platform), + source: 'system', + venvPath: projectVenvPath, + venvExists: false, + pipAvailable: false, + usedForDependencyInstall: false, + }; +} + +export function portablePathFrom(from: string, to: string): string { + return path.relative(from, to).split(path.sep).join('/') || '.'; +} + +export function argvPathFrom( + from: string, + to: string, + platform: NodeJS.Platform = process.platform +): string { + const relative = path.relative(from, to); + if (!relative) { + return '.'; + } + return isWindowsPlatform(platform) ? relative : relative.split(path.sep).join('/'); +} + export function getVenvRapidkitPath( venvPath: string, platform: NodeJS.Platform = process.platform diff --git a/packages/cli/src/utils/workspace-contract.ts b/packages/cli/src/utils/workspace-contract.ts index 64bb5be3..110089f5 100644 --- a/packages/cli/src/utils/workspace-contract.ts +++ b/packages/cli/src/utils/workspace-contract.ts @@ -37,9 +37,11 @@ import { import type { WorkspaceKnowledgeGraph } from '../contracts/workspace-knowledge-graph-contract.js'; import { isPythonVirtualEnvironmentDirectory } from './workspace-scan-policy.js'; import { + detectBackendFrameworkFromHints, detectBackendFrameworkFromProject, isWorkspaiManagedLinkedProjectMetadata, } from './backend-framework-contract.js'; +import { resolveKitDefinition } from './kit-registry.js'; import { readWorkspaceMarker } from '../workspace-marker.js'; export const WORKSPACE_CONTRACT_PATH = WORKSPACE_SUPPLEMENTAL_ARTIFACTS.workspaceContract; @@ -291,6 +293,9 @@ function isNonServiceKit(kit?: string): boolean { const value = (kit || '').toLowerCase(); return ( value.includes('vscode-extension') || + value.includes('desktop.electron') || + value.startsWith('extension.') || + (value.startsWith('desktop.') && !value.includes('tauri')) || value.includes('agent.microsoft') || value.includes('agent.openai') ); @@ -632,14 +637,22 @@ export async function buildWorkspaceContract(input: { ? detectBackendFrameworkFromProject(projectPath, payload) : undefined; const hasLiveDetection = liveDetection !== undefined && liveDetection.key !== 'unknown'; + const hinted = detectBackendFrameworkFromHints({ + runtime: typeof payload.runtime === 'string' ? payload.runtime : undefined, + framework: typeof payload.framework === 'string' ? payload.framework : undefined, + kitName: metadataKit, + }); + const kitDefinition = resolveKitDefinition(metadataKit); const runtime = hasLiveDetection ? liveDetection.runtime - : typeof payload.runtime === 'string' - ? payload.runtime - : undefined; + : hinted.runtime !== 'unknown' + ? hinted.runtime + : kitDefinition?.runtime; const framework = hasLiveDetection ? liveDetection.key : (typeof payload.framework === 'string' && payload.framework) || + (hinted.key !== 'unknown' ? hinted.key : undefined) || + kitDefinition?.framework || projectKindFromKit(metadataKit); const kitPrefix = registryEntry?.relationship === 'adopted' ? 'adopted' : 'imported'; const kit = hasLiveDetection ? `${kitPrefix}.${liveDetection.key}` : metadataKit; diff --git a/packages/cli/src/utils/workspace-run-evidence.ts b/packages/cli/src/utils/workspace-run-evidence.ts index bf320a35..0c116b86 100644 --- a/packages/cli/src/utils/workspace-run-evidence.ts +++ b/packages/cli/src/utils/workspace-run-evidence.ts @@ -24,15 +24,33 @@ export const LEGACY_AUTOPILOT_WORKSPACE_RUN_TEST_FILENAME = 'autopilot-workspace /** @deprecated Autopilot no longer writes separate stage files; use workspace-run-last.json stages map. */ export const LEGACY_AUTOPILOT_WORKSPACE_RUN_BUILD_FILENAME = 'autopilot-workspace-run-build.json'; +export type WorkspaceRunProjectStageRecord = { + generatedAt: string; + status: string; + projectName: string; + relativePath: string; + command?: string; + exitCode: number | null; + runtime?: string; +}; + export interface WorkspaceRunEvidence { schemaVersion: typeof WORKSPACE_RUN_EVIDENCE_SCHEMA_VERSION; generatedAt: string; workspacePath: string; latestStage: WorkspaceRunStageName; stages: Partial>; + projectStages?: Record< + string, + Partial> + >; enterpriseControls: { jsonReady: boolean; evidencePath: string; + projectStages?: Record< + string, + Partial> + >; }; } @@ -96,7 +114,12 @@ export function isWorkspaceRunEvidenceAggregate(payload: unknown): payload is Wo export function normalizeWorkspaceRunEvidence(payload: unknown): WorkspaceRunEvidence | null { if (isWorkspaceRunEvidenceAggregate(payload)) { - return payload; + const controls = payload.enterpriseControls as WorkspaceRunEvidence['enterpriseControls']; + return { + ...payload, + projectStages: payload.projectStages ?? controls.projectStages, + enterpriseControls: controls, + }; } if (isLegacyWorkspaceRunStageReport(payload)) { const stage = payload.stage; @@ -147,6 +170,23 @@ export async function readWorkspaceRunEvidence( return normalizeWorkspaceRunEvidence(raw); } +function projectEvidenceKey(project: { + relativePath?: string; + path?: string; + projectName?: string; +}): string { + const relative = + typeof project.relativePath === 'string' ? project.relativePath.replace(/\\/g, '/') : ''; + if (relative) { + return relative.toLowerCase(); + } + const absolute = typeof project.path === 'string' ? project.path.replace(/\\/g, '/') : ''; + if (absolute) { + return absolute.toLowerCase(); + } + return (project.projectName ?? '').toLowerCase(); +} + export async function publishWorkspaceRunStageReport( workspacePath: string, stageReport: WorkspaceRunReport @@ -176,8 +216,42 @@ export async function publishWorkspaceRunStageReport( ? { ...normalized.stages } : {}; + // The stage view is always the exact latest run. Historical per-project + // receipts live below with their own timestamps; copying old rows into a + // new report would make stale evidence appear freshly generated. stages[stageReport.stage] = stageReport; + const projectStages: NonNullable = { + ...(normalized?.projectStages ?? + ( + normalized?.enterpriseControls as { + projectStages?: WorkspaceRunEvidence['projectStages']; + } + )?.projectStages ?? + {}), + }; + for (const project of stageReport.projects) { + if (!project.selected) { + continue; + } + const key = projectEvidenceKey(project); + if (!key) { + continue; + } + projectStages[key] = { + ...projectStages[key], + [stageReport.stage]: { + generatedAt: stageReport.generatedAt, + status: project.status, + projectName: project.projectName, + relativePath: project.relativePath, + command: project.executionCommand, + exitCode: project.exitCode, + runtime: project.runtimeDetected, + }, + }; + } + const evidence: WorkspaceRunEvidence = { schemaVersion: WORKSPACE_RUN_EVIDENCE_SCHEMA_VERSION, generatedAt: stageReport.generatedAt, @@ -187,6 +261,7 @@ export async function publishWorkspaceRunStageReport( enterpriseControls: { jsonReady: true, evidencePath: WORKSPACE_RUN_LAST_REPORT_RELATIVE_PATH, + projectStages, }, }; diff --git a/packages/cli/src/workspace-run.ts b/packages/cli/src/workspace-run.ts index a8f5fba1..3b8f90b4 100644 --- a/packages/cli/src/workspace-run.ts +++ b/packages/cli/src/workspace-run.ts @@ -28,6 +28,7 @@ import { buildCleanGitEnv } from './utils/git-worktree.js'; import { buildPackageRunnerSubprocessEnv, resolvePackageRunnerInvocation, + resolvePythonLifecycleInterpreter, } from './utils/platform-capabilities.js'; import { discoverWorkspaceProjects as discoverWorkspaceProjectsShared } from './utils/workspace-discovery.js'; import { closureFromAdjacency } from './workspace-graph-traversal.js'; @@ -88,7 +89,7 @@ interface ProjectExecutionResult { projectName: string; selected: boolean; affected: boolean; - status: 'passed' | 'failed' | 'skipped'; + status: 'passed' | 'failed' | 'skipped' | 'blocked'; exitCode: number | null; durationMs: number; reason?: string; @@ -108,6 +109,7 @@ interface ProjectExecutionResult { durationMs: number; reason?: string; errorCategory?: ErrorCategory; + testCounts?: { ran: number; skipped: number; failed: number }; failureDiagnostic?: { category: ErrorCategory; exitCode: number; @@ -176,6 +178,7 @@ export interface WorkspaceRunReport { passed: number; failed: number; skipped: number; + blocked: number; exitCode: number; }; projects: ProjectExecutionResult[]; @@ -729,6 +732,106 @@ function parseDirectLifecycleCommand(command: string): { file: string; args: str return { file, args }; } +type LifecycleArgvStep = { executable: string; args: string[] }; + +function looksLikePythonInterpreter(executable: string): boolean { + const base = path.basename(executable).toLowerCase(); + return ( + base === 'python' || + base === 'python3' || + base === 'python.exe' || + base === 'python3.exe' || + /(?:^|[\\/])\.venv[\\/]/.test(executable) + ); +} + +function materializePythonArgvSequence( + unitPath: string, + steps: LifecycleArgvStep[] +): LifecycleArgvStep[] { + const live = resolvePythonLifecycleInterpreter({ unitRoot: unitPath }); + const materialized: LifecycleArgvStep[] = []; + for (const step of steps) { + const createsVenv = step.args[0] === '-m' && step.args[1] === 'venv'; + if (createsVenv && live.venvExists) { + continue; + } + if (looksLikePythonInterpreter(step.executable) && !createsVenv) { + const interpreter = live.usedForDependencyInstall + ? live.interpreter + : path.isAbsolute(step.executable) + ? step.executable + : path.resolve(unitPath, step.executable); + materialized.push({ ...step, executable: interpreter }); + continue; + } + materialized.push(step); + } + return materialized; +} + +function formatArgvSequence(steps: LifecycleArgvStep[]): string { + return steps.map((step) => [step.executable, ...step.args].join(' ')).join(' && '); +} + +async function validateArgvSequenceBeforeMutation( + unitPath: string, + steps: LifecycleArgvStep[] +): Promise<{ valid: true } | { valid: false; reason: string }> { + const createdRoots: string[] = []; + for (const step of steps) { + const createsVenv = step.args[0] === '-m' && step.args[1] === 'venv' && step.args[2]; + const executablePath = path.isAbsolute(step.executable) + ? path.resolve(step.executable) + : step.executable.includes('/') || step.executable.includes('\\') + ? path.resolve(unitPath, step.executable) + : null; + const createdByEarlierStep = Boolean( + executablePath && + createdRoots.some((root) => { + const relative = path.relative(root, executablePath); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + }) + ); + if (!createdByEarlierStep) { + const validation = await validateCommand(step.executable, unitPath); + if (!validation.valid) { + const tool = path.basename(step.executable); + return { + valid: false, + reason: + tool === 'uv' || tool === 'uv.exe' + ? 'Missing admitted lock tool `uv`. Install uv and retry; no environment was changed.' + : validation.reason || `Required executable is unavailable: ${step.executable}`, + }; + } + } + if (createsVenv) { + createdRoots.push(path.resolve(unitPath, step.args[2])); + } + } + return { valid: true }; +} + +function parsePythonUnittestCounts(output: string): { + ran: number; + skipped: number; + failed: number; +} | null { + const ranMatch = output.match(/Ran (\d+) tests?/i); + if (!ranMatch) { + return null; + } + const skipped = Number(output.match(/skipped\s*=\s*(\d+)/i)?.[1] ?? 0); + const failures = Number(output.match(/failures\s*=\s*(\d+)/i)?.[1] ?? 0); + const errors = Number(output.match(/errors\s*=\s*(\d+)/i)?.[1] ?? 0); + return { + ran: Number(ranMatch[1]), + skipped, + failed: failures + errors, + }; +} + function resolvePositiveDuration(name: string, fallback: number): number { const parsed = Number.parseInt(process.env[name] ?? '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; @@ -1173,7 +1276,8 @@ async function executeStageCommand( commandOverrides?: Record, environmentCommandVariants?: EnvironmentVariant, environment?: 'dev' | 'staging' | 'prod', - streamOutput = false + streamOutput = false, + argvSequence?: LifecycleArgvStep[] ): Promise<{ exitCode: number; command: string; @@ -1182,6 +1286,7 @@ async function executeStageCommand( errorCategory?: ErrorCategory; healthStatus?: { healthy: boolean; reason?: string }; failureDiagnostic?: ProjectExecutionResult['failureDiagnostic']; + testCounts?: { ran: number; skipped: number; failed: number }; }> { const useRapidkitWrapper = !commandOverrides?.[stage] && isWrapperOwnedRuntime(runtime); @@ -1226,6 +1331,13 @@ async function executeStageCommand( }; } + const materializedArgv = + argvSequence && argvSequence.length > 0 + ? runtime === 'python' + ? materializePythonArgvSequence(projectPath, argvSequence) + : argvSequence + : undefined; + // Step 1: Preflight validation const nativeStageCommand = resolveWorkspaceStageCommand({ projectPath, @@ -1234,25 +1346,43 @@ async function executeStageCommand( stage, }); - if (!useRapidkitWrapper) { - const validation = await validateCommand(finalCommand, projectPath); - if (!validation.valid) { - return { - exitCode: 127, - command: finalCommand, - message: validation.reason || 'Command not available', - errorCategory: 'setup', - }; + if (!materializedArgv?.length) { + if (!useRapidkitWrapper) { + const validation = await validateCommand(finalCommand, projectPath); + if (!validation.valid) { + return { + exitCode: 127, + command: finalCommand, + message: validation.reason || 'Command not available', + errorCategory: 'setup', + }; + } + } else if (nativeStageCommand) { + const validation = useRapidkitWrapper + ? await validateWrapperStagePreflight(projectPath, runtime, nativeStageCommand) + : await validateCommand(nativeStageCommand, projectPath); + if (!validation.valid) { + return { + exitCode: 127, + command: finalCommand, + message: validation.reason || 'Command not available', + errorCategory: 'setup', + }; + } } - } else if (nativeStageCommand) { - const validation = useRapidkitWrapper - ? await validateWrapperStagePreflight(projectPath, runtime, nativeStageCommand) - : await validateCommand(nativeStageCommand, projectPath); + } + + const resolvedCommand = materializedArgv?.length + ? formatArgvSequence(materializedArgv) + : finalCommand; + + if (materializedArgv?.length) { + const validation = await validateArgvSequenceBeforeMutation(projectPath, materializedArgv); if (!validation.valid) { return { exitCode: 127, - command: finalCommand, - message: validation.reason || 'Command not available', + command: resolvedCommand, + message: validation.reason, errorCategory: 'setup', }; } @@ -1273,7 +1403,7 @@ async function executeStageCommand( stage === 'start' ? await runStartupSmoke({ projectPath, - finalCommand, + finalCommand: resolvedCommand, useRapidkitWrapper, runtime, framework, @@ -1283,23 +1413,72 @@ async function executeStageCommand( ? stage === 'init' && isVitestRuntime() ? await runRapidkitInitInProcess(projectPath) : await runRapidkitSelfCommand([stage], projectPath, timeoutMs, streamOutput) - : await (async () => { - const directCommand = parseDirectLifecycleCommand(finalCommand); - return directCommand - ? execa(directCommand.file, directCommand.args, { + : materializedArgv?.length + ? await (async () => { + let combinedStdout = ''; + let combinedStderr = ''; + let lastExit = 0; + let timedOut = false; + for (const step of materializedArgv) { + const stepResult = await execa(step.executable, step.args, { cwd: projectPath, reject: false, timeout: timeoutMs, forceKillAfterDelay: 1000, - }) - : execa(finalCommand, [], { - cwd: projectPath, - reject: false, - shell: true, - timeout: timeoutMs, - forceKillAfterDelay: 1000, }); - })(); + combinedStdout += `${stepResult.stdout ?? ''}\n`; + combinedStderr += `${stepResult.stderr ?? ''}\n`; + timedOut = Boolean( + typeof stepResult === 'object' && + stepResult !== null && + 'timedOut' in stepResult && + (stepResult as { timedOut?: unknown }).timedOut + ); + const rawExit = stepResult.exitCode; + const failed = + timedOut || + (typeof rawExit === 'number' + ? rawExit !== 0 + : Boolean((stepResult as { failed?: boolean }).failed)); + lastExit = timedOut + ? 124 + : typeof rawExit === 'number' + ? rawExit + : failed + ? 127 + : 0; + if (lastExit !== 0 && step.executable === 'uv') { + combinedStderr += + '\nMissing admitted lock tool `uv`. Install uv and retry; Workspai does not fall back to an unlocked pip freeze.'; + } + if (lastExit !== 0) { + break; + } + } + return { + exitCode: lastExit, + stdout: combinedStdout.trim(), + stderr: combinedStderr.trim(), + timedOut, + }; + })() + : await (async () => { + const directCommand = parseDirectLifecycleCommand(finalCommand); + return directCommand + ? execa(directCommand.file, directCommand.args, { + cwd: projectPath, + reject: false, + timeout: timeoutMs, + forceKillAfterDelay: 1000, + }) + : execa(finalCommand, [], { + cwd: projectPath, + reject: false, + shell: true, + timeout: timeoutMs, + forceKillAfterDelay: 1000, + }); + })(); commandTimedOut = Boolean( typeof result === 'object' && @@ -1334,7 +1513,7 @@ async function executeStageCommand( Boolean((error as { timedOut?: unknown }).timedOut); return { exitCode: timedOut ? 124 : 1, - command: finalCommand, + command: resolvedCommand, message: timedOut ? `Stage timed out after ${timeoutMs}ms` : error instanceof Error @@ -1360,11 +1539,24 @@ async function executeStageCommand( if (noApplicableGoTests) { return { exitCode: 0, - command: finalCommand, + command: resolvedCommand, skipped: true, message: 'No Go packages matched; test stage is not applicable to this runtime unit.', }; } + const pythonTestCounts = + stage === 'test' && runtime === 'python' + ? parsePythonUnittestCounts(`${stdout}\n${stderr}`) + : null; + const requiredPythonTestsSkipped = + pythonTestCounts !== null && + pythonTestCounts.ran > 0 && + pythonTestCounts.failed === 0 && + pythonTestCounts.skipped === pythonTestCounts.ran; + if (requiredPythonTestsSkipped && exitCode === 0) { + exitCode = 1; + errorCategory = 'runtime'; + } const timedOut = commandTimedOut || exitCode === 124 || @@ -1376,7 +1568,7 @@ async function executeStageCommand( : { category: normalizedCategory, exitCode, - command: finalCommand, + command: resolvedCommand, timedOut, timeoutMs, ...(outputExcerpt ? { outputExcerpt } : {}), @@ -1384,17 +1576,20 @@ async function executeStageCommand( return { exitCode, - command: finalCommand, + command: resolvedCommand, errorCategory: normalizedCategory, healthStatus, failureDiagnostic, + testCounts: pythonTestCounts ?? undefined, message: timedOut ? `Stage timed out after ${timeoutMs}ms` - : exitCode !== 0 - ? failureSummary - ? `Stage failed with exit code ${exitCode}: ${failureSummary}` - : `Stage failed with exit code ${exitCode}` - : undefined, + : requiredPythonTestsSkipped + ? `Required Python tests were skipped (${pythonTestCounts?.skipped ?? 0}/${pythonTestCounts?.ran ?? 0}); missing framework dependencies cannot pass.` + : exitCode !== 0 + ? failureSummary + ? `Stage failed with exit code ${exitCode}: ${failureSummary}` + : `Stage failed with exit code ${exitCode}` + : undefined, }; } @@ -1418,7 +1613,7 @@ function runtimeInstallHint(runtime: RuntimeFamily | undefined): string | null { case 'node': return 'Install Node.js LTS and npm/pnpm/yarn, then rerun `npx workspai setup node` or `npx workspai init`.'; case 'python': - return 'Install Python 3.10+ and pip/Poetry, then rerun `npx workspai setup python` or `npx workspai init`.'; + return 'Install Python 3.10+, then rerun `wspai workspace run init` so Workspai can create the project `.venv` and install into it. Do not pip-install agent dependencies into system Python.'; default: return null; } @@ -1691,8 +1886,9 @@ export async function runWorkspaceStage(options: WorkspaceRunOptions): Promise row.status === 'passed').length; const failed = rows.filter((row) => row.status === 'failed').length; + const blocked = rows.filter((row) => row.status === 'blocked').length; const skipped = rows.filter((row) => row.status === 'skipped').length; emitActivityBlock({ blockId: 'workspace.run.execute', status: failed > 0 ? 'failed' : blockingGate ? 'blocked' : 'succeeded', - message: `Workspace run finished: ${passed} passed, ${failed} failed, ${skipped} skipped`, + message: `Workspace run finished: ${passed} passed, ${failed} failed, ${blocked} blocked, ${skipped} skipped`, component: 'workspace-run', progress: { completed: totalTargets, total: totalTargets, percent: 100 }, - attributes: { passed, failed, skipped, stage: options.stage }, + attributes: { passed, failed, blocked, skipped, stage: options.stage }, }); const strict = options.strict === true; const exitCode = failed > 0 || + blocked > 0 || (strict && gateResults.some((gate) => gate.status === 'fail' || gate.status === 'warn')) ? 1 : 0; @@ -2137,6 +2338,7 @@ export async function runWorkspaceStage(options: WorkspaceRunOptions): Promise passed: ${passed}, failed: ${failed}, skipped: ${skipped}` + `Workspace run (${options.stage}) => passed: ${passed}, failed: ${failed}, blocked: ${blocked}, skipped: ${skipped}` ) ); console.log(chalk.gray(`Report: ${reportPath}`)); diff --git a/packages/cli/src/workspace-verify.ts b/packages/cli/src/workspace-verify.ts index ca7fb9ac..a581cc58 100644 --- a/packages/cli/src/workspace-verify.ts +++ b/packages/cli/src/workspace-verify.ts @@ -470,6 +470,47 @@ function evaluatePipelineEvidence(payload: Record): EvidenceEva return { status: 'warn', message: 'Pipeline evidence status is unknown.' }; } +function resolveKeyedProjectStage( + payload: Record, + projectName: string, + stage: string +): { generatedAt?: string; status?: string } | 'ambiguous' | null { + const projectStages = + asRecord(payload.projectStages) ?? + asRecord(asRecord(payload.enterpriseControls)?.projectStages); + if (!projectStages) { + return null; + } + const normalizedProject = projectName.replace(/\\/g, '/').toLowerCase(); + const matches = Object.entries(projectStages).filter(([key, value]) => { + const normalizedKey = key.replace(/\\/g, '/').toLowerCase(); + const stages = asRecord(value); + const candidate = asRecord(stages?.[stage]); + const candidateName = + typeof candidate?.projectName === 'string' ? candidate.projectName.toLowerCase() : ''; + const candidatePath = + typeof candidate?.relativePath === 'string' + ? candidate.relativePath.replace(/\\/g, '/').toLowerCase() + : ''; + return ( + normalizedKey === normalizedProject || + candidateName === normalizedProject || + candidatePath === normalizedProject + ); + }); + if (matches.length > 1) { + return 'ambiguous'; + } + const stages = asRecord(matches[0]?.[1]); + const record = asRecord(stages?.[stage]); + return record + ? { + generatedAt: typeof record.generatedAt === 'string' ? record.generatedAt : undefined, + status: typeof record.status === 'string' ? record.status : undefined, + } + : null; +} + function evaluateWorkspaceRunEvidence( payload: Record, command: WorkspaceImpactCommand, @@ -498,7 +539,7 @@ function evaluateWorkspaceRunEvidence( message: 'Project-scoped workspace run evidence is missing a project identifier.', }; } - const projectRow = projects.find((entry) => { + const matchingProjectRows = projects.filter((entry) => { const record = asRecord(entry); if (!record) { return false; @@ -510,11 +551,16 @@ function evaluateWorkspaceRunEvidence( .map((value) => value.replace(/\\/g, '/').toLowerCase()); return ( name === projectName || - projectPathCandidates.some( - (projectPath) => projectPath.endsWith(`/${projectName}`) || projectPath === projectName - ) + projectPathCandidates.some((projectPath) => projectPath === projectName) ); }); + if (matchingProjectRows.length > 1) { + return { + status: 'fail', + message: `Workspace run evidence is ambiguous for project ${command.project}.`, + }; + } + const projectRow = matchingProjectRows[0]; if (projectRow) { const staleMessage = staleEvidenceMessage( stageReport.generatedAt, @@ -526,13 +572,45 @@ function evaluateWorkspaceRunEvidence( } const record = asRecord(projectRow); const status = typeof record?.status === 'string' ? record.status : 'unknown'; - if (status === 'failed') { - return { status: 'fail', message: `Workspace run evidence failed for ${command.project}.` }; + if (status === 'failed' || status === 'blocked') { + return { + status: 'fail', + message: `Workspace run evidence ${status} for ${command.project}.`, + }; } if (status === 'passed') { return { status: 'pass', message: `Workspace run evidence passed for ${command.project}.` }; } if (status === 'skipped') { + const keyed = resolveKeyedProjectStage(payload, projectName, stage ?? stageReport.stage); + if (keyed === 'ambiguous') { + return { + status: 'fail', + message: `Workspace run history is ambiguous for project ${command.project}.`, + }; + } + if (keyed) { + const staleKeyed = staleEvidenceMessage( + keyed.generatedAt, + minGeneratedAt, + `Workspace run evidence for ${command.project ?? command.id}` + ); + if (staleKeyed) { + return { status: 'fail', message: staleKeyed }; + } + if (keyed.status === 'passed') { + return { + status: 'pass', + message: `Workspace run evidence passed for ${command.project} from keyed project/stage history.`, + }; + } + if (keyed.status === 'failed' || keyed.status === 'blocked') { + return { + status: 'fail', + message: `Workspace run evidence ${keyed.status} for ${command.project}.`, + }; + } + } return { status: 'warn', message: `Workspace run evidence skipped for ${command.project}.` }; } return { diff --git a/packages/wspai/package.json b/packages/wspai/package.json index 478439c7..3525ed65 100644 --- a/packages/wspai/package.json +++ b/packages/wspai/package.json @@ -1,6 +1,6 @@ { "name": "wspai", - "version": "0.75.2", + "version": "0.76.0", "type": "module", "description": "Short npm alias for the Workspai CLI.", "keywords": [ @@ -39,7 +39,7 @@ "scripts" ], "dependencies": { - "workspai": "0.75.2" + "workspai": "0.76.0" }, "publishConfig": { "registry": "https://registry.npmjs.org",