Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Compatibility Contract

## Web Console 1.6.200 / bridge 0.2.2 source candidate

The candidate retains normal UI/session/Full-access behavior and requires a
matching Engine with private Catalog ingress protection, live actor/Key scope,
CAS and per-child graph validation. It cannot safely target Engine 0.183.358 for
Catalog launch. Archive/restore reuses the native stopped-only owner and never
starts restored workloads. Fixed background authorization-denied results remain
failed, warn about possible partial changes and cannot be blindly replayed.
Required CI and combined runtime/browser acceptance are pending. See the
[candidate boundaries](docs/releases/web-console-1.6.200.md).


## Web Console 1.6.195

Adds archive-bound browser and build CycloneDX SBOMs, isolated production and
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ history, authorship, licenses, and dependency notices.

## Current component

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.
This source prepares Web Console **1.6.200** and stdio bridge **0.2.2**.
It retains the twelve tools and adds bounded Catalog Compose launch and
stopped-resource Archive/restore workflows. Private Catalog answers require
normal human input/review; restoration does not start workloads. Other Catalog
administration, advanced forms and remaining frontend workflows are pending.

The [candidate release note](docs/releases/web-console-1.6.200.md) records the
source boundaries. Required CI, immutable artifact verification and real
Server/browser acceptance remain separate pending gates. The previously
published 1.6.199 / 0.2.1 artifacts are unchanged. This candidate does not
complete the frontend inventory or its role matrix.

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
Expand Down
3 changes: 3 additions & 0 deletions app/components/ai-upgrade-review/template.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{{#if this.commands.operationNotice}}
<div class="alert alert-danger force-wrap" role="alert" data-testid="ai-resource-authorization-denied">{{t "webmcp.backgroundAuthorizationDenied"}}</div>
{{/if}}
{{#if this.commands.pendingPlan}}
<div class="alert alert-info" role="status" data-testid="ai-upgrade-pending">
{{this.commands.pendingPlan.displayPath}}
Expand Down
24 changes: 16 additions & 8 deletions app/components/modal-ai-collaboration/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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';
import { safeCatalogQuestions } from 'ui/utils/ai-catalog-launch';

export default ModalBase.extend({
classNames: ['lacsso', 'modal-container', 'medium-modal'],
Expand All @@ -22,6 +23,7 @@ export default ModalBase.extend({
loading: false,
changing: false,
loadError: false,
partialLoadError: false,
hasLoaded: false,
plans: null,
cancelCandidate: null,
Expand All @@ -45,7 +47,7 @@ export default ModalBase.extend({

invalidateInbox() {
this._loadGeneration++;
if ( !this.isDestroying && !this.isDestroyed ) { this.setProperties({plans: [], loading: false, hasLoaded: false, loadError: false, cancelCandidate: null, cancelling: false, cancelNotice: null, errors: null}); }
if ( !this.isDestroying && !this.isDestroyed ) { this.setProperties({plans: [], loading: false, hasLoaded: false, loadError: false, partialLoadError: false, cancelCandidate: null, cancelling: false, cancelNotice: null, errors: null}); }
},

currentLoad(generation, captured) {
Expand All @@ -56,32 +58,38 @@ export default ModalBase.extend({

async loadPlans() {
const generation = ++this._loadGeneration;
this.setProperties({loading: true, loadError: false, plans: [], hasLoaded: false, cancelCandidate: null, cancelNotice: null, errors: null});
this.setProperties({loading: true, loadError: false, partialLoadError: 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 responses = await Promise.all(['aiServiceUpgradePlans', 'aiResourceChangePlans'].map((collection) =>
const responses = await Promise.allSettled(['aiServiceUpgradePlans', 'aiResourceChangePlans', 'aiCatalogStackLaunchPlans'].map((collection) =>
this.get('userStore').rawRequest({url: `projects/${encodeURIComponent(captured.projectId)}/${collection}?limit=20`, method: 'GET'})));
if ( !this.currentLoad(generation, captured) ) { return; }
const plans = responses.flatMap((response, index) => {
const plans = responses.flatMap((result, index) => {
if ( result.status !== 'fulfilled' ) { return []; }
const response = result.value;
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' ) {
const catalogLaunch = index === 2;
const prefix = catalogLaunch ? 'aicataloglaunch' : resourceChange ? 'aichange' : 'aiplan';
if ( !new RegExp(`^${prefix}-[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 && !catalogLaunch && 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,
const typed = resourceChange || catalogLaunch ? {targetPath: safe.displayPath, changes: safe.displayDiff,
...resourceReviewLabels(safe, (key) => this.get('intl').t(key))} : {};
if ( catalogLaunch ) { Object.assign(typed, {summary: safe.catalogSummary, missingHumanInputs: safeCatalogQuestions(plan.missingHumanInputs || [])}); }
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});
const failed = responses.filter((result) => result.status === 'rejected').length;
this.setProperties({plans, hasLoaded: true, loadError: failed === responses.length, partialLoadError: failed > 0 && failed < responses.length});
} catch (error) {
if ( !this.isDestroying && !this.isDestroyed && generation === this._loadGeneration ) { this.set('loadError', true); }
} finally {
Expand Down
3 changes: 2 additions & 1 deletion app/components/modal-ai-collaboration/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<p id="ai-collaboration-confirmation-source">{{t "webmcp.collaboration.confirmationSource"}}</p>
<button type="button" class="btn btn-sm btn-default" disabled={{this.loading}} {{action "refresh"}} data-testid="ai-collaboration-refresh">{{t "webmcp.collaboration.refresh"}}</button>
{{top-errors errors=this.errors}}
{{#if this.partialLoadError}}<p class="alert alert-warning" role="alert">{{t "webmcp.catalogPartialInbox"}}</p>{{/if}}
{{#if this.cancelNotice}}<div class="alert alert-success" role="status">{{this.cancelNotice}}</div>{{/if}}
{{#if this.cancelCandidate}}<div class="alert alert-warning" role="alert">
<p>{{t "webmcp.cancelPlanPrompt"}}</p><strong class="force-wrap">{{this.cancelCandidate.displayPath}}</strong>
Expand All @@ -28,7 +29,7 @@
<ul class="list-unstyled mt-15" data-testid="ai-collaboration-plans">
{{#each this.plans as |plan|}}<li class="mb-15" data-ai-plan-id={{plan.id}} data-ai-resource-type={{plan.resourceType}} data-ai-operation={{plan.operation}}>
<strong class="force-wrap">{{plan.displayPath}}</strong>
{{#if (eq plan.planKind "resource-change")}}<div class="force-wrap">{{plan.resourceTypeLabel}} · {{plan.operationLabel}}</div>{{else}}<div class="force-wrap">{{plan.currentImage}} → {{plan.requestedImage}}</div>{{/if}}
{{#if plan.resourceTypeLabel}}<div class="force-wrap">{{plan.resourceTypeLabel}} · {{plan.operationLabel}}</div>{{else}}<div class="force-wrap">{{plan.currentImage}} → {{plan.requestedImage}}</div>{{/if}}
<button type="button" class="btn btn-sm btn-primary" {{action "review" plan}} data-testid="ai-collaboration-review-plan">{{t "webmcp.reviewPlan"}}</button>
{{#if plan.canCancel}}<button type="button" class="btn btn-sm btn-default" disabled={{this.cancelling}} {{action "requestCancel" plan}} data-testid="ai-collaboration-cancel-plan">{{t "webmcp.cancelPlan"}}</button>{{/if}}
</li>{{else}}<li>{{t "webmcp.collaboration.noPendingPlans"}}</li>{{/each}}
Expand Down
101 changes: 96 additions & 5 deletions app/components/modal-confirm-ai-upgrade/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ 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';
import { createCatalogHumanForm } from 'ui/utils/ai-catalog-human-form';
import { observer } from '@ember/object';

const humanForms = new WeakMap();

export default ModalBase.extend({
classNames: ['lacsso', 'modal-container', 'medium-modal'],
Expand All @@ -11,13 +15,30 @@ export default ModalBase.extend({
'aria-modal': 'true',
'aria-labelledby': 'ai-upgrade-title',
commands: service('ai-command-service'),
store: service('store'),
intl: service(),
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'; }),
isResourceChange: computed('plan.planKind', function() { return ['resource-change', 'catalog-launch'].includes(this.get('plan.planKind')); }),
isCatalogLaunch: computed('plan.planKind', function() { return this.get('plan.planKind') === 'catalog-launch'; }),
catalogIdentityLabels: computed('plan.catalogSummary', 'intl.primaryLocale', function() {
const summary = this.get('plan.catalogSummary') || {};
const scope = ['global', 'project'].includes(summary.catalogScope) ? this.get('intl').t(`apiKeyAccess.scopes.${summary.catalogScope}`) : '';
return {source: `${scope} / ${summary.catalogId || ''} / ${summary.templateKey || ''} · ${summary.templateName || ''}`,
version: `${summary.version || ''}${Number.isSafeInteger(summary.revision) ? ` (r${summary.revision})` : ''}`};
}),
catalogAfterPreview: computed('isCatalogLaunch', 'plan.planStatus', function() { return this.get('isCatalogLaunch') && this.get('plan.planStatus') !== 'needs_user'; }),
catalogStatusKey: computed('plan.planStatus', 'plan.verifiedResult', function() {
const status = this.get('plan.planStatus');
const outcome = {needs_user: 'NOT_EXECUTED', accepted: 'ACCEPTED', running: 'PENDING', failed: 'FAILED', cancelled: 'NOT_EXECUTED'}[status];
return `apiKeyAudit.outcomes.${status === 'succeeded' && this.get('plan.verifiedResult') ? 'SUCCEEDED' : outcome || 'unknown'}`;
}),
humanQuestions: null,
loadingHumanInputs: false,
savingHumanInputs: false,
reviewLabels: computed('plan.displayDiff.[]', 'plan.resourceType', 'plan.operation', 'intl.primaryLocale', function() {
return resourceReviewLabels(this.get('plan') || {}, (key) => this.get('intl').t(key));
}),
Expand All @@ -31,19 +52,88 @@ 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', 'alreadyApproved', 'stale', function() {
return this.get('saving') || this.get('alreadyApproved') || this.get('stale');
submitDisabled: computed('saving', 'alreadyApproved', 'stale', 'savingHumanInputs', 'plan.inputsReady', 'plan.planStatus', 'isCatalogLaunch', function() {
return this.get('saving') || this.get('alreadyApproved') || this.get('stale') || this.get('savingHumanInputs') ||
this.get('isCatalogLaunch') && (!this.get('plan.inputsReady') || this.get('plan.planStatus') !== 'needs_user');
}),

clearHumanInputs() {
const form = humanForms.get(this); if ( form ) { form.clear(); }
humanForms.delete(this);
if ( !this.isDestroyed && !this.isDestroying ) { this.set('humanQuestions', []); }
},

pendingChanged: observer('commands.pendingPlan', 'commands.pendingPlan.planRef', function() {
const current = this.get('commands.pendingPlan'), plan = this.get('plan');
if ( !current || !plan || plan.planRef !== current.planRef ) { this.clearHumanInputs(); }
else if ( plan.planKind === 'catalog-launch' ) {
if ( current !== plan ) { this.set('modalService.modalOpts.plan', current); }
if ( current.inputsReady || current.planStatus !== 'needs_user' ) { this.clearHumanInputs(); }
}
}),

async loadHumanInputs() {
const plan = this.get('plan');
if ( !plan || plan.planKind !== 'catalog-launch' || plan.planStatus !== 'needs_user' || plan.inputsReady || this.get('stale') ) { return; }
this.set('loadingHumanInputs', true);
try {
const result = await this.get('commands').catalogHumanForm(plan);
if ( this.isDestroyed || this.isDestroying || this.get('stale') ) { return; }
const current = this.get('commands.pendingPlan');
if ( current && current.planRef === plan.planRef ) { this.set('modalService.modalOpts.plan', current); }
if ( result.summary && (result.summary.inputsReady || result.summary.planStatus !== 'needs_user') ) { this.clearHumanInputs(); return; }
// Existing schema selectors retain their ordinary human UI semantics.
for ( const type of [...new Set(result.questions.filter((question) => question.type !== 'reference' && ['secret', 'certificate'].includes(question.sourceType)).map((question) => question.sourceType))] ) {
await this.get('store').findAll(type); this.get('commands').catalogHumanPlanId(plan);
}
if ( this.isDestroyed || this.isDestroying || this.get('stale') ) { return; }
this.clearHumanInputs();
const form = createCatalogHumanForm(result.questions, result.referenceChoices, this.get('intl').t('generic.none')); humanForms.set(this, form);
this.set('humanQuestions', form.fields);
} catch (error) {
if ( !this.isDestroyed && !this.isDestroying ) { this.set('errors', [this.get('intl').t('webmcp.failed')]); }
} finally {
if ( !this.isDestroyed && !this.isDestroying ) { this.set('loadingHumanInputs', false); }
}
},

didInsertElement() {
this._super(...arguments);
const cancel = this.element.querySelector('[data-testid="ai-upgrade-cancel"]');
if (cancel) { cancel.focus(); }
this.loadHumanInputs();
},

escToClose() { return !this.get('saving') && this._super(...arguments); },
willDestroyElement() { this.clearHumanInputs(); this._super(...arguments); },

escToClose() { return !this.get('saving') && !this.get('savingHumanInputs') && this._super(...arguments); },

actions: {
async completeInputs() {
if ( this.get('savingHumanInputs') || this.get('stale') ) { return; }
const form = humanForms.get(this), plan = this.get('plan');
if ( !form || plan.planKind !== 'catalog-launch' ) { return; }
let reloadInputs = false;
this.setProperties({savingHumanInputs: true, errors: null});
try {
const result = await this.get('commands').completeCatalogHumanInputs(plan, form.payload());
if ( this.isDestroyed || this.isDestroying || this.get('stale') ) { return; }
if ( result.status !== 'succeeded' ) {
reloadInputs = result.status !== 'unknown';
this.set('errors', [this.get('intl').t(result.status === 'unknown' ? 'webmcp.catalogInputUnknown' : 'webmcp.failed')]); return;
}
const current = this.get('commands.pendingPlan');
if ( current && current.planRef === plan.planRef ) { this.set('modalService.modalOpts.plan', current); }
reloadInputs = current && !current.inputsReady;
} catch (error) {
reloadInputs = true;
if ( !this.isDestroyed && !this.isDestroying ) { this.set('errors', [this.get('intl').t('webmcp.failed')]); }
} finally {
this.clearHumanInputs();
if ( !this.isDestroyed && !this.isDestroying ) { this.set('savingHumanInputs', false); }
if ( reloadInputs && !this.isDestroyed && !this.isDestroying && !this.get('stale') ) { await this.loadHumanInputs(); }
}
},
confirm() {
if (this.get('submitDisabled')) { return; }
this.setProperties({saving: true, errors: null});
Expand All @@ -61,7 +151,8 @@ export default ModalBase.extend({
});
},
cancel() {
if (this.get('saving')) { return; }
if (this.get('saving') || this.get('savingHumanInputs')) { return; }
this.clearHumanInputs();
// Dismissal never grants approval and never revokes the user's login.
this.get('modalService').toggleModal();
},
Expand Down
Loading
Loading