diff --git a/README.md b/README.md index 8cf0a10d66..5eef0f24cc 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ history, authorship, licenses, and dependency notices. ## Current component -This source builds Web Console **1.6.198** and stdio bridge **0.2.0**. It adds -resource-family discovery, server-projected metadata and typed Stack, Service -and Container lifecycle plans, while retaining the seven existing tools. Browser -tools still start paused and require explicit opt-in. Pending adapters are -reported separately; this batch does not cover the whole console. - -The [release note](docs/releases/web-console-1.6.198.md) records the scope and +This source builds Web Console **1.6.199** and stdio bridge **0.2.1**. Its twelve +typed tools provide resource discovery, safe metadata and human-approved workload +plans. Unsupported-operation replies now explain that Global services follow +eligible hosts and do not support a manual instance count. Browser tools still +start paused and require explicit opt-in. Pending adapters remain separate; +this batch does not cover the whole console. + +The [release note](docs/releases/web-console-1.6.199.md) records the scope and validation boundaries. Exact producer and immutable artifact identities are recorded with the [release assets](https://github.com/PastureStack/web-console/releases). Component publication and Server/browser acceptance are separate results. diff --git a/app/utils/ai-command-contract.js b/app/utils/ai-command-contract.js index 5b1e757626..d7502ab259 100644 --- a/app/utils/ai-command-contract.js +++ b/app/utils/ai-command-contract.js @@ -99,6 +99,9 @@ export function publicError(error) { if ( code === 'UNAVAILABLE' && status === 503 ) { result.nextStep = 'Resource changes are disabled on this server. Ask an operator to enable the feature, or use the normal console.'; } + if ( code === 'UNAVAILABLE' && status === 422 && error && error.body && error.body.code === 'ResourceChangeUnsupported' ) { + result.nextStep = 'This operation is unsupported for this resource. Do not retry the same plan. If the request is service scale, Global services deploy according to eligible hosts and do not support a manual instance count. Review the service deployment configuration or eligible hosts in the console; do not guess another scale value.'; + } if ( code === 'CONFLICT' && error && error.body && error.body.code === 'ResourceChangeTargetBusy' ) { result.reason = 'TARGET_BUSY'; result.nextStep = 'Wait for the current operation to finish. If you have its operationRef, call get_operation with it. Then read the resource again before previewing. Do not submit parallel changes.'; diff --git a/bridge/README.md b/bridge/README.md index 25fbf60870..991fd5fb4a 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -5,11 +5,13 @@ command layer. It needs a compatible Engine with `api.ai.bridge.enabled=true`, a dedicated **AI collaboration bridge** Key and the parent repository's supported Node.js toolchain. It cannot approve its own change plans. -The released 0.1.0 ZIP contains the initial seven-tool contract. The current -0.2.0 candidate adds resource-family discovery, safe metadata reads and typed -stack/service/container lifecycle plans. It requires a matching Engine with +The current 0.2.1 source contains twelve typed tools for resource-family +discovery, safe metadata reads and stack/service/container lifecycle plans. +Unsupported-operation replies explain that Global services follow eligible +hosts rather than a manual instance count; they do not advise retrying the plan. +It requires a matching Engine with `api.ai.resource.change.enabled=true` for those changes (default false). Do not -expect these additions in the immutable 0.1.0 release archive. The generic +expect this reply correction in the immutable 0.2.0 release archive. The generic stdio read adapter uses server-side safe views, never direct credential-bearing domain resources. Pending adapters are reported by `list_capabilities`. @@ -20,13 +22,13 @@ native browser support. The operator must separately opt in to `api.ai.bridge.enabled` and, for upgrade plans, `api.ai.service.upgrade.enabled`; both Engine flags default to false. -The candidate source archive is `pasturestack-webmcp-bridge-0.2.0.zip` with a +The source archive is `pasturestack-webmcp-bridge-0.2.1.zip` with a `.zip.sha256` checksum. Extract the whole archive, keeping both `bridge/` and `app/utils/`; the bridge uses those exact shared command sources. With Node.js 24.22.1 available, install from the extracted directory: ```sh -cd pasturestack-webmcp-bridge-0.2.0/bridge +cd pasturestack-webmcp-bridge-0.2.1/bridge npm ci ``` @@ -35,7 +37,7 @@ records the included source hashes; the root also includes LICENSE and the bridge CycloneDX SBOM. To reproduce the source release from a checkout: ```sh -python scripts/package-webmcp-bridge.py --version 0.2.0 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.0.zip +python scripts/package-webmcp-bridge.py --version 0.2.1 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.1.zip ``` Store the following configuration outside the extracted source tree in a private diff --git a/bridge/package-lock.json b/bridge/package-lock.json index 719dd39c52..ab320f4f49 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@modelcontextprotocol/sdk": "1.32.2" }, diff --git a/bridge/package.json b/bridge/package.json index 2f89328023..fefaaa1182 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "description": "Isolated stdio adapter for the shared PastureStack command contract", diff --git a/bridge/server.mjs b/bridge/server.mjs index a3d37f06c5..37eea61dec 100644 --- a/bridge/server.mjs +++ b/bridge/server.mjs @@ -7,7 +7,7 @@ try { const path = process.env.PASTURESTACK_BRIDGE_CONFIG; if (!path) throw new SetupError('CONFIG_REQUIRED'); const bridge = await createBridge(await loadConfig(path)); - const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.0'}, { + const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.1'}, { capabilities: {tools: {}}, instructions: 'Use names and returned opaque references. Never guess IDs. A human must approve change plans in the console. Rejected calls must be repaired only as directed; never evade permissions. Accepted/running is not success. Read the existing operation until verifiedResult=true.', }); diff --git a/bridge/test/manual-scale.test.mjs b/bridge/test/manual-scale.test.mjs new file mode 100644 index 0000000000..538e0f37eb --- /dev/null +++ b/bridge/test/manual-scale.test.mjs @@ -0,0 +1 @@ +import '../../scripts/test-webmcp-manual-scale.mjs'; diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index af8b4d6cf4..41cfe5002f 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.198", + "version": "1.6.199", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.198", + "version": "1.6.199", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.199.md b/docs/releases/web-console-1.6.199.md new file mode 100644 index 0000000000..b1a2542f08 --- /dev/null +++ b/docs/releases/web-console-1.6.199.md @@ -0,0 +1,24 @@ +# Web Console 1.6.199 + +This source prepares Web Console 1.6.199 and the immutable stdio bridge 0.2.1. +Release CI, artifact verification and Server/browser acceptance are separate +gates. The published 1.6.198 and bridge 0.2.0 artifacts remain unchanged. + +An authoritative `422 ResourceChangeUnsupported` still returns +`UNAVAILABLE / rejected`. Its tool reply now explains that Global services +deploy according to eligible hosts and do not support a manual instance count. +It directs the caller to review deployment configuration or eligible hosts, +without retrying the same plan or guessing a different count. + +The matching Engine enforces manual-scale eligibility before preview or apply. +A target changed after preview retains the existing stale-snapshot conflict. +Permission failures, feature-off replies and uncertain transport outcomes keep +their existing categories. Global start/stop/restart and ordinary UI/API paths +are unchanged by this reply correction. + +Three focused cases cover unsupported preview, an unsupported legacy plan at +apply, and the unchanged conflict/permission/feature-off/unknown boundaries. +No dependency graph changes, Catalog adapter, archive/restore work or new +translation keys are included. Existing twelve-tool, human approval and initial +off behavior remain. This source evidence does not claim the full frontend +inventory, role matrix or pending live candidate acceptance has passed. diff --git a/package-lock.json b/package-lock.json index af8b4d6cf4..41cfe5002f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.198", + "version": "1.6.199", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.198", + "version": "1.6.199", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 2dc3202102..3f68d67d00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.198", + "version": "1.6.199", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 55d70b5cc8..d0f21f4c7b 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.198" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.198" +if [[ "$version" != "1.6.199" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.199" failures=$((failures + 1)) fi diff --git a/scripts/package-webmcp-bridge.py b/scripts/package-webmcp-bridge.py index 418406f550..e0d722a7fa 100644 --- a/scripts/package-webmcp-bridge.py +++ b/scripts/package-webmcp-bridge.py @@ -11,7 +11,7 @@ import zipfile -VERSION = "0.2.0" +VERSION = "0.2.1" SOURCE_FILES = ( "LICENSE", "app/utils/ai-command-context.js", @@ -25,9 +25,11 @@ "bridge/server.mjs", "bridge/shared-modules.mjs", "bridge/test/core.test.mjs", + "bridge/test/manual-scale.test.mjs", "bridge/test/resources.test.mjs", "bridge/test/stdio.test.mjs", "scripts/test-webmcp-resources.mjs", + "scripts/test-webmcp-manual-scale.mjs", ) FORBIDDEN_PARTS = {"node_modules", ".git", "credentials", "credential", "secrets"} diff --git a/scripts/test-webmcp-manual-scale.mjs b/scripts/test-webmcp-manual-scale.mjs new file mode 100644 index 0000000000..2756defaa7 --- /dev/null +++ b/scripts/test-webmcp-manual-scale.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CommandContext, CommandEngine} from '../bridge/shared-modules.mjs'; + +const privateText = 'RAW_PRIVATE_SERVICE_CONFIG_MUST_NOT_LEAVE'; +function fixture(error) { + const snapshot = {generation: 'g1', accountId: 'actor', projectId: 'project', schemaProjectId: 'project', schemaGeneration: 1, route: '/stacks'}; + let sequence = 0; + const context = new CommandContext(() => snapshot, () => 'opaque-' + ++sequence); context.setMode('enabled'); + const requests = [], audits = []; + const engine = new CommandEngine({context, ensureSession: async () => {}, + audit: async event => { audits.push(event); return {accepted: true, requestId: 'audit-' + audits.length}; }, + request: async request => { requests.push(request); throw error; }}); + const captured = context.capture(); + const targetRef = context.reference('resource', {id: 'service-one', family: 'service', resourceType: 'service', scope: 'project'}, captured); + const planRef = context.reference('plan', {id: 'aichange-11111111-1111-1111-1111-111111111111', planKind: 'resource-change', + resourceType: 'service', operation: 'scale', resourceId: 'service-one'}, captured); + return {engine, context, requests, audits, targetRef, planRef}; +} +const unsupported = () => ({status: 422, body: {code: 'ResourceChangeUnsupported', message: privateText}}); +function assertUnsupported(result) { + assert.equal(result.code, 'UNAVAILABLE'); assert.equal(result.status, 'rejected'); + assert.match(result.nextStep, /Global services deploy according to eligible hosts/); + assert.match(result.nextStep, /do not support a manual instance count/); + assert.match(result.nextStep, /Do not retry the same plan/); + assert.match(result.nextStep, /do not guess another scale value/); + assert.equal(JSON.stringify(result).includes(privateText), false); +} + +test('manual scale: authoritative unsupported preview remains rejected and explains Global eligible-host deployment', async () => { + const f = fixture(unsupported()); + const result = await f.engine.execute('preview_resource_change', {resourceType: 'service', operation: 'scale', targetRef: f.targetRef, parameters: {scale: 2}}); + assertUnsupported(result); + assert.equal(f.requests.length, 1); + assert.deepEqual(f.requests[0].data, {resourceType: 'service', operation: 'scale', resourceId: 'service-one', parameters: {scale: 2}}); + assert.deepEqual(f.audits.map(event => [event.phase, event.status, event.reason]), [['start', 'pending', 'NONE'], ['result', 'rejected', 'UNAVAILABLE']]); +}); + +test('manual scale: an existing legacy Global plan rejected at apply is not an unknown write and is never retried', async () => { + const f = fixture(unsupported()); + const result = await f.engine.execute('apply_resource_change', {planRef: f.planRef}); + assertUnsupported(result); + assert.equal(f.requests.length, 1); assert.match(f.requests[0].url, /\?action=apply$/); + assert.deepEqual(f.requests[0].data, {}); + const reference = f.context.resolve(f.planRef, 'plan', f.context.capture()); + assert.equal(f.context.resolve(reference.operationRef, 'operation', f.context.capture()).lastStatus, 'rejected'); +}); + +test('manual scale: changed-snapshot conflict, permission denial, feature-off and unknown transport retain their exact categories', async () => { + for (const [error, code, status] of [ + [{status: 409, body: {code: 'ResourceChangePlanStale'}}, 'CONFLICT', 'rejected'], + [{status: 403, body: {code: 'ResourceChangeUnsupported'}}, 'FORBIDDEN', 'rejected'], + [{status: 503, body: {code: 'ResourceChangeFeatureDisabled'}}, 'UNAVAILABLE', 'rejected'], + [{status: 503, body: {code: 'ResourceChangeUnsupported'}}, 'UNKNOWN_RESULT', 'unknown'], + [new Error(privateText), 'UNKNOWN_RESULT', 'unknown'], + ]) { + const f = fixture(error); + const result = await f.engine.execute('preview_resource_change', {resourceType: 'service', operation: 'scale', targetRef: f.targetRef, parameters: {scale: 2}}); + assert.equal(result.code, code); assert.equal(result.status, status); + assert.equal(result.nextStep.includes('Global services'), false); + assert.equal(f.requests.length, 1); assert.equal(JSON.stringify(result).includes(privateText), false); + } +});