From 5d30b122da610920589a016e260d3afbeebabb05 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:48:16 +0800 Subject: [PATCH 1/3] Add typed AI resource discovery and human-reviewed workload plans --- README.md | 22 +- app/components/ai-upgrade-review/template.hbs | 2 +- .../modal-ai-collaboration/component.js | 61 ++- .../modal-ai-collaboration/template.hbs | 14 +- .../modal-confirm-ai-upgrade/component.js | 10 +- .../modal-confirm-ai-upgrade/template.hbs | 20 +- app/services/ai-command-service.js | 59 ++- app/styles/components/_modals.scss | 11 + app/utils/ai-command-contract.js | 84 +++- app/utils/ai-command-engine.js | 334 ++++++++++++- app/utils/ai-resource-registry.js | 84 ++++ app/utils/ai-resource-review-labels.js | 35 ++ app/utils/api-key-policy-matrix.js | 13 +- bridge/README.md | 24 +- bridge/core.mjs | 23 +- bridge/package-lock.json | 4 +- bridge/package.json | 2 +- bridge/server.mjs | 8 +- bridge/shared-modules.mjs | 4 +- bridge/test/core.test.mjs | 4 +- bridge/test/resources.test.mjs | 2 + bridge/test/stdio.test.mjs | 55 ++- ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.198.md | 37 ++ docs/webmcp.md | 61 ++- package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-webmcp-engine-contract.mjs | 19 +- scripts/package-webmcp-bridge.py | 5 +- scripts/test-ai-bridge-policy-matrix.js | 70 +++ scripts/test-ai-collaboration-ui.js | 98 +++- scripts/test-ai-resource-review-labels.mjs | 71 +++ scripts/test-webmcp-commands.js | 7 +- scripts/test-webmcp-path-boundaries.mjs | 35 +- scripts/test-webmcp-resources.mjs | 437 ++++++++++++++++++ .../unit/utils/api-key-policy-matrix-test.js | 8 +- translations/de-de.yaml | 28 +- translations/en-us.yaml | 28 +- translations/fa-ir.yaml | 28 +- translations/fil-ph.yaml | 28 +- translations/fr-fr.yaml | 28 +- translations/hu-hu.yaml | 28 +- translations/ja-jp.yaml | 28 +- translations/ko-kr.yaml | 28 +- translations/pt-br.yaml | 28 +- translations/ru-ru.yaml | 28 +- translations/uk-ua.yaml | 28 +- translations/zh-hans.yaml | 28 +- translations/zh-tw.yaml | 28 +- 50 files changed, 1928 insertions(+), 173 deletions(-) create mode 100644 app/utils/ai-resource-registry.js create mode 100644 app/utils/ai-resource-review-labels.js create mode 100644 bridge/test/resources.test.mjs create mode 100644 docs/releases/web-console-1.6.198.md create mode 100644 scripts/test-ai-bridge-policy-matrix.js create mode 100644 scripts/test-ai-resource-review-labels.mjs create mode 100644 scripts/test-webmcp-resources.mjs diff --git a/README.md b/README.md index 465a3a44f3..8cf0a10d66 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,16 @@ history, authorship, licenses, and dependency notices. ## Current component -Web Console [1.6.197](https://github.com/PastureStack/web-console/releases/tag/1.6.197) -restores the verified browser actor when an existing cookie is opened in a -fresh browser session. This allows the human approval inbox to work without -requiring a new login. Optional AI collaboration still provides seven typed -tools, conditional description edits and a dedicated-Key stdio bridge. -Browser tools start paused and require an explicit user choice. See the -[release note](docs/releases/web-console-1.6.197.md) for the session boundary, -original producer and verified publication. +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 +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. The release producer uses Node.js `24.22.1` / npm `12.0.2` in a digest-pinned container and produces an immutable static archive with separate runtime and @@ -30,8 +32,8 @@ for deployment. Historical changes remain in [release notes](docs/releases). ## Features and operator guides -- [Experimental AI collaboration](docs/webmcp.md): seven typed tools for - discovery, conditional description edits and server-owned upgrade plans. +- [Experimental AI collaboration](docs/webmcp.md): twelve typed tools for + resource discovery, conditional descriptions and server-owned lifecycle plans. Disabled by default. Includes human review controls and an optional [dedicated-Key stdio bridge](bridge/README.md); native browser tools require a supported secure-context browser. diff --git a/app/components/ai-upgrade-review/template.hbs b/app/components/ai-upgrade-review/template.hbs index 01fbcd40a3..26951dd546 100644 --- a/app/components/ai-upgrade-review/template.hbs +++ b/app/components/ai-upgrade-review/template.hbs @@ -1,6 +1,6 @@ {{#if this.commands.pendingPlan}}
{{this.commands.pendingPlan.displayPath}} - +
{{/if}} diff --git a/app/components/modal-ai-collaboration/component.js b/app/components/modal-ai-collaboration/component.js index 3e8149ddd8..903c9fe573 100644 --- a/app/components/modal-ai-collaboration/component.js +++ b/app/components/modal-ai-collaboration/component.js @@ -3,6 +3,7 @@ import { service } from '@ember/service'; import ModalBase from 'lacsso/components/modal-base'; import config from 'ui/config/environment'; import { boundedCollaborationMode, collaborationStatusKey } from 'ui/utils/ai-collaboration-controls'; +import { resourceReviewLabels } from 'ui/utils/ai-resource-review-labels'; export default ModalBase.extend({ classNames: ['lacsso', 'modal-container', 'medium-modal'], @@ -23,6 +24,10 @@ export default ModalBase.extend({ loadError: false, hasLoaded: false, plans: null, + cancelCandidate: null, + cancelling: false, + cancelNotice: null, + errors: null, canEnable: computed('buildMode', function() { return this.get('buildMode') === 'enabled'; }), statusKey: computed('registry.status', function() { return collaborationStatusKey(this.get('registry.status')); }), @@ -40,7 +45,7 @@ export default ModalBase.extend({ invalidateInbox() { this._loadGeneration++; - if ( !this.isDestroying && !this.isDestroyed ) { this.setProperties({plans: [], loading: false, hasLoaded: false, loadError: false}); } + if ( !this.isDestroying && !this.isDestroyed ) { this.setProperties({plans: [], loading: false, hasLoaded: false, loadError: false, cancelCandidate: null, cancelling: false, cancelNotice: null, errors: null}); } }, currentLoad(generation, captured) { @@ -51,23 +56,29 @@ export default ModalBase.extend({ async loadPlans() { const generation = ++this._loadGeneration; - this.setProperties({loading: true, loadError: false, plans: [], hasLoaded: false}); + this.setProperties({loading: true, loadError: false, plans: [], hasLoaded: false, cancelCandidate: null, cancelNotice: null, errors: null}); let captured; try { captured = this.get('commands').captureReviewContext(); await this.get('access').ensureSession(); if ( !this.currentLoad(generation, captured) ) { return; } - const response = await this.get('userStore').rawRequest({ - url: `projects/${encodeURIComponent(captured.projectId)}/aiServiceUpgradePlans?limit=20`, method: 'GET', - }); + const responses = await Promise.all(['aiServiceUpgradePlans', 'aiResourceChangePlans'].map((collection) => + this.get('userStore').rawRequest({url: `projects/${encodeURIComponent(captured.projectId)}/${collection}?limit=20`, method: 'GET'}))); if ( !this.currentLoad(generation, captured) ) { return; } - if ( !response.body || !Array.isArray(response.body.data) || response.body.data.length > 20 ) { throw new Error('InvalidInbox'); } - const plans = response.body.data.map((plan) => { - if ( !/^aiplan-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(plan.id || '') || typeof plan.serviceId !== 'string' ) { - throw new Error('InvalidInboxPlan'); - } - const safe = this.get('commands').engine.planSummary(plan, null); - return Object.assign({id: plan.id, serviceId: plan.serviceId}, safe); + const plans = responses.flatMap((response, index) => { + if ( !response.body || !Array.isArray(response.body.data) || response.body.data.length > 20 ) { throw new Error('InvalidInbox'); } + return response.body.data.map((plan) => { + const resourceChange = index === 1; + if ( !(resourceChange ? /^aichange-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i : /^aiplan-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).test(plan.id || '') || + !resourceChange && typeof plan.serviceId !== 'string' ) { + throw new Error('InvalidInboxPlan'); + } + const safe = this.get('commands').engine.planSummary(plan, null); + const typed = resourceChange ? {targetPath: safe.displayPath, changes: safe.displayDiff, + ...resourceReviewLabels(safe, (key) => this.get('intl').t(key))} : {}; + return Object.assign({id: plan.id, serviceId: plan.serviceId, resourceId: plan.resourceId, status: plan.status, operationId: plan.operationId, + canCancel: this.get('commands').canCancelInboxPlan(plan)}, safe, typed); + }); }); this._loadedContext = captured; this.setProperties({plans, hasLoaded: true}); @@ -91,6 +102,32 @@ export default ModalBase.extend({ finally { if ( !this.isDestroying && !this.isDestroyed ) { this.set('changing', false); } } }, refresh() { return this.loadPlans(); }, + requestCancel(plan) { + if ( this.get('cancelling') || !plan.canCancel ) { return; } + this.setProperties({cancelCandidate: plan, errors: null, cancelNotice: null}); + }, + keepPlan() { if ( !this.get('cancelling') ) { this.set('cancelCandidate', null); } }, + async cancelPlan() { + const plan = this.get('cancelCandidate'), generation = this._loadGeneration, captured = this._loadedContext; + if ( !plan || this.get('cancelling') || !this.get('plans').includes(plan) ) { return; } + this.setProperties({cancelling: true, errors: null}); + try { + const result = await this.get('commands').cancelInboxPlan(plan, captured); + if ( !this.currentLoad(generation, captured) ) { return; } + if ( result.status === 'succeeded' && result.confirmationState === 'cancelled' ) { + this.setProperties({plans: this.get('plans').filter((entry) => entry.id !== plan.id), cancelCandidate: null, cancelNotice: this.get('intl').t('webmcp.planCancelled')}); + } else { + if ( result.reason === 'CANNOT_CANCEL' ) { + this.setProperties({plans: this.get('plans').map((entry) => entry.id === plan.id ? Object.assign({}, entry, {canCancel: false}) : entry), cancelCandidate: null}); + } + this.set('errors', [this.get('intl').t(result.reason === 'CANNOT_CANCEL' ? 'webmcp.cannotCancel' : 'webmcp.cancelFailed')]); + } + } catch (error) { + if ( !this.isDestroying && !this.isDestroyed && generation === this._loadGeneration ) { this.set('errors', [this.get('intl').t('webmcp.cancelFailed')]); } + } finally { + if ( !this.isDestroying && !this.isDestroyed && generation === this._loadGeneration ) { this.set('cancelling', false); } + } + }, review(plan) { if ( this.isDestroying || this.isDestroyed ) { return; } const result = this.get('commands').reviewInboxPlan(plan, this._loadedContext); diff --git a/app/components/modal-ai-collaboration/template.hbs b/app/components/modal-ai-collaboration/template.hbs index a6e144968c..1e0c65f92f 100644 --- a/app/components/modal-ai-collaboration/template.hbs +++ b/app/components/modal-ai-collaboration/template.hbs @@ -15,14 +15,22 @@

{{t "webmcp.collaboration.pendingPlans"}}

{{t "webmcp.collaboration.confirmationSource"}}

+{{top-errors errors=this.errors}} +{{#if this.cancelNotice}}
{{this.cancelNotice}}
{{/if}} +{{#if this.cancelCandidate}}{{/if}} {{#if this.loading}}

{{t "generic.loading"}}

{{else if this.loadError}} {{else if this.hasLoaded}} {{else}}

{{t "webmcp.collaboration.loadRequired"}}

{{/if}} diff --git a/app/components/modal-confirm-ai-upgrade/component.js b/app/components/modal-confirm-ai-upgrade/component.js index c6b9bbae04..eabae5a345 100644 --- a/app/components/modal-confirm-ai-upgrade/component.js +++ b/app/components/modal-confirm-ai-upgrade/component.js @@ -2,6 +2,7 @@ import { computed } from '@ember/object'; import { alias } from '@ember/object/computed'; import { service } from '@ember/service'; import ModalBase from 'lacsso/components/modal-base'; +import { resourceReviewLabels } from 'ui/utils/ai-resource-review-labels'; export default ModalBase.extend({ classNames: ['lacsso', 'modal-container', 'medium-modal'], @@ -14,7 +15,12 @@ export default ModalBase.extend({ plan: alias('modalService.modalOpts.plan'), saving: false, approved: false, + alreadyApproved: computed('approved', 'plan.confirmationState', function() { return this.get('approved') || this.get('plan.confirmationState') === 'approved'; }), errors: null, + isResourceChange: computed('plan.planKind', function() { return this.get('plan.planKind') === 'resource-change'; }), + reviewLabels: computed('plan.displayDiff.[]', 'plan.resourceType', 'plan.operation', 'intl.primaryLocale', function() { + return resourceReviewLabels(this.get('plan') || {}, (key) => this.get('intl').t(key)); + }), displayExpiry: computed('plan.expiresAt', 'intl.primaryLocale', function() { const value = new Date(this.get('plan.expiresAt')); if (!Number.isFinite(value.getTime())) { return ''; } @@ -25,8 +31,8 @@ export default ModalBase.extend({ stale: computed('plan.planRef', 'commands.pendingPlan.planRef', function() { return !this.get('plan.planRef') || this.get('plan.planRef') !== this.get('commands.pendingPlan.planRef'); }), - submitDisabled: computed('saving', 'approved', 'stale', function() { - return this.get('saving') || this.get('approved') || this.get('stale'); + submitDisabled: computed('saving', 'alreadyApproved', 'stale', function() { + return this.get('saving') || this.get('alreadyApproved') || this.get('stale'); }), didInsertElement() { diff --git a/app/components/modal-confirm-ai-upgrade/template.hbs b/app/components/modal-confirm-ai-upgrade/template.hbs index af1c112360..16ce8958fd 100644 --- a/app/components/modal-confirm-ai-upgrade/template.hbs +++ b/app/components/modal-confirm-ai-upgrade/template.hbs @@ -1,13 +1,21 @@ -

{{t "webmcp.title"}}

+

{{#if this.isResourceChange}}{{t "webmcp.resourceChangeTitle" operation=this.reviewLabels.operationLabel resource=this.reviewLabels.resourceTypeLabel}}{{else}}{{t "webmcp.title"}}{{/if}}

-
{{t "webmcp.service"}}
{{this.plan.displayPath}}
-
{{t "webmcp.currentImage"}}
{{this.plan.currentImage}}
-
{{t "webmcp.requestedImage"}}
{{this.plan.requestedImage}}
+
{{#if this.isResourceChange}}{{t "generic.name"}}{{else}}{{t "webmcp.service"}}{{/if}}
{{this.plan.displayPath}}
+ {{#if this.isResourceChange}} +
{{t "generic.type"}}
{{this.reviewLabels.resourceTypeLabel}}
+
{{t "generic.actions"}}
{{this.reviewLabels.operationLabel}}
+ {{#each this.reviewLabels.displayChanges as |change|}}
{{change.fieldLabel}}
{{#unless change.informational}}
{{change.before}}
→{{/unless}}
{{change.after}}
{{/each}} + {{else}} +
{{t "webmcp.currentImage"}}
{{this.plan.currentImage}}
+
{{t "webmcp.requestedImage"}}
{{this.plan.requestedImage}}
+ {{/if}}
{{t "webmcp.expires"}}
{{this.displayExpiry}}
-
{{t "webmcp.notice"}}
+
{{#if this.isResourceChange}}{{t "webmcp.resourceChangeNotice"}}{{else}}{{t "webmcp.notice"}}{{/if}}
+{{#if this.reviewLabels.createsStopped}}
{{t "webmcp.createStoppedNotice"}}
{{/if}} +{{#if this.reviewLabels.mountedDataNotice}}
{{this.reviewLabels.mountedDataNotice}}
{{/if}} {{#if this.stale}}{{/if}} -{{#if this.approved}}
{{t "webmcp.approved"}}
{{/if}} +{{#if this.alreadyApproved}}
{{t "webmcp.approved"}}
{{/if}} {{top-errors errors=this.errors}}