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
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion app/components/ai-upgrade-review/template.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{{#if this.commands.pendingPlan}}
<div class="alert alert-info" role="status" data-testid="ai-upgrade-pending">
{{this.commands.pendingPlan.displayPath}}
<button type="button" class="btn btn-sm btn-primary" {{action "review"}} data-testid="ai-upgrade-review">{{t "webmcp.review"}}</button>
<button type="button" class="btn btn-sm btn-primary" {{action "review"}} data-testid="ai-upgrade-review">{{t "webmcp.reviewPlan"}}</button>
</div>
{{/if}}
61 changes: 49 additions & 12 deletions app/components/modal-ai-collaboration/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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')); }),

Expand All @@ -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) {
Expand All @@ -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});
Expand All @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions app/components/modal-ai-collaboration/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@
<h4>{{t "webmcp.collaboration.pendingPlans"}}</h4>
<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.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>
<div class="mt-10"><button type="button" class="btn btn-danger" disabled={{this.cancelling}} {{action "cancelPlan"}} data-testid="ai-collaboration-confirm-cancel">{{t "webmcp.cancelPlan"}}</button>
<button type="button" class="btn btn-link" disabled={{this.cancelling}} {{action "keepPlan"}}>{{t "generic.cancel"}}</button></div>
</div>{{/if}}
{{#if this.loading}}<p role="status">{{t "generic.loading"}}</p>
{{else if this.loadError}}<p class="alert alert-warning" role="alert">{{t "webmcp.collaboration.loadFailed"}}</p>
{{else if this.hasLoaded}}
<ul class="list-unstyled mt-15" data-testid="ai-collaboration-plans">
{{#each this.plans as |plan|}}<li class="mb-15">
{{#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>
<div class="force-wrap">{{plan.currentImage}} → {{plan.requestedImage}}</div>
<button type="button" class="btn btn-sm btn-primary" {{action "review" plan}} data-testid="ai-collaboration-review-plan">{{t "webmcp.review"}}</button>
{{#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}}
<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}}
</ul>
{{else}}<p>{{t "webmcp.collaboration.loadRequired"}}</p>{{/if}}
Expand Down
10 changes: 8 additions & 2 deletions app/components/modal-confirm-ai-upgrade/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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 ''; }
Expand All @@ -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() {
Expand Down
20 changes: 14 additions & 6 deletions app/components/modal-confirm-ai-upgrade/template.hbs
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
<h3 id="ai-upgrade-title">{{t "webmcp.title"}}</h3>
<h3 id="ai-upgrade-title">{{#if this.isResourceChange}}{{t "webmcp.resourceChangeTitle" operation=this.reviewLabels.operationLabel resource=this.reviewLabels.resourceTypeLabel}}{{else}}{{t "webmcp.title"}}{{/if}}</h3>
<dl class="horizontal-form">
<dt>{{t "webmcp.service"}}</dt><dd class="force-wrap" data-testid="ai-upgrade-target">{{this.plan.displayPath}}</dd>
<dt>{{t "webmcp.currentImage"}}</dt><dd class="force-wrap">{{this.plan.currentImage}}</dd>
<dt>{{t "webmcp.requestedImage"}}</dt><dd class="force-wrap">{{this.plan.requestedImage}}</dd>
<dt>{{#if this.isResourceChange}}{{t "generic.name"}}{{else}}{{t "webmcp.service"}}{{/if}}</dt><dd class="force-wrap" data-testid="ai-upgrade-target">{{this.plan.displayPath}}</dd>
{{#if this.isResourceChange}}
<dt>{{t "generic.type"}}</dt><dd class="force-wrap">{{this.reviewLabels.resourceTypeLabel}}</dd>
<dt>{{t "generic.actions"}}</dt><dd class="force-wrap">{{this.reviewLabels.operationLabel}}</dd>
{{#each this.reviewLabels.displayChanges as |change|}}<dt class="force-wrap">{{change.fieldLabel}}</dt><dd class="force-wrap">{{#unless change.informational}}<pre class="ai-resource-diff-value">{{change.before}}</pre>→{{/unless}}<pre class="ai-resource-diff-value">{{change.after}}</pre></dd>{{/each}}
{{else}}
<dt>{{t "webmcp.currentImage"}}</dt><dd class="force-wrap">{{this.plan.currentImage}}</dd>
<dt>{{t "webmcp.requestedImage"}}</dt><dd class="force-wrap">{{this.plan.requestedImage}}</dd>
{{/if}}
<dt>{{t "webmcp.expires"}}</dt><dd>{{this.displayExpiry}}</dd>
</dl>
<div class="alert alert-warning">{{t "webmcp.notice"}}</div>
<div class="alert alert-warning">{{#if this.isResourceChange}}{{t "webmcp.resourceChangeNotice"}}{{else}}{{t "webmcp.notice"}}{{/if}}</div>
{{#if this.reviewLabels.createsStopped}}<div class="alert alert-info" data-testid="ai-resource-create-stopped">{{t "webmcp.createStoppedNotice"}}</div>{{/if}}
{{#if this.reviewLabels.mountedDataNotice}}<div class="alert alert-warning" data-testid="ai-resource-mounted-data-notice">{{this.reviewLabels.mountedDataNotice}}</div>{{/if}}
{{#if this.stale}}<div class="alert alert-danger" role="alert">{{t "webmcp.stale"}}</div>{{/if}}
{{#if this.approved}}<div class="alert alert-success" role="status" data-testid="ai-upgrade-approved">{{t "webmcp.approved"}}</div>{{/if}}
{{#if this.alreadyApproved}}<div class="alert alert-success" role="status" data-testid="ai-upgrade-approved">{{t "webmcp.approved"}}</div>{{/if}}
{{top-errors errors=this.errors}}
<div class="footer-actions">
<button type="button" class="btn btn-primary" disabled={{this.submitDisabled}} {{action "confirm"}} data-testid="ai-upgrade-confirm">
Expand Down
Loading
Loading