Skip to content

Commit 7eb145a

Browse files
feat(agentos): FM cockpit control cluster — per-agent power toggle + whole-fleet + honest-state render (#14611) (#14892)
* feat(agentos): FM cockpit AgentCard control surface + forward lifecycle-intent seam (#14611) Layer 1 of the B4 per-agent control cluster: control surface + forward intent seam. - AgentCardController fires one lifecycleIntent {action, agentId}, reading the durable agentId from the per-card state provider, and stops at the B4/C2 boundary (never imports the fleet bridge; the cockpit->lifecycle round-trip is the Lane-C contract). - AgentCard gains the fm-card-controls slot (start/stop/restart) the card anatomy already anticipated; per-verb availability binds to session state. - Spec locks the forward seam: a control fires exactly one lifecycleIntent carrying verb + agentId, and the card never calls the bridge. Honest round-trip states (pending -> settled/rejected/unauthorized; stale-pending per ADR-0032 2.2.1) are the leaf's core ACs and follow once the Lane-C back-signal contract is fixed with Euclid. This commit is intentionally the forward-seam layer only, not a full #14611. * feat(agentos): FM cockpit whole-fleet start control — intent-only, per accepted B4/C2 cut (#14611) Extends the B4 control surface to the whole-fleet verb (SSOT §01 one-click morning start). - FleetCockpitController.onStartFleet fires one lifecycleIntent {action:'start', scope:'fleet'} and stops — intent-only, per the accepted B4/C2 cut (Lane-C owns the round-trip). - FleetCockpit re-lays vbox: a control bar (fm-fleet-start button) over the SSOT §01 fleet/activity split, which stays intact in an inner hbox (1.55fr / 1fr); the activity-stream reference + loadActivity binding are preserved. - Spec locks the forward seam: the bar fires exactly one fleet-scoped lifecycleIntent, cockpit never calls the bridge. Honest round-trip states stay the C2-back-signal-gated increment (uniform across per-agent + whole- fleet). Forward-seam layer only. * style(agentos): FM cockpit control SCSS + repair the zone divider under the new vbox nesting (#14611) - fm-card-controls cluster geometry (AgentCard.scss); fm-cockpit-bar + fm-fleet-start (FleetCockpit.scss); token-only (var(--fm-*)). - Repair: the whole-fleet vbox re-layout nested FleetGrid under a new fm-cockpit-body container, so the '> .fm-fleet-grid' zone-divider selector stopped matching — re-scoped to '.fm-cockpit-body > .fm-fleet-grid' + named the inner container. - Honest-state visuals follow with the C2-gated state machine; browser NL-verify is the completion AC (post-C2). * feat(agentos): FM cockpit honest round-trip control states — pending/reject rendering per the B4/C2 contract (#14611) Renders #14611's honest round-trip states off two provider fields Lane-C sets (per the contract proposed on the ticket): - pendingAction (String|null): while set, EVERY verb is disabled (no second intent mid-round-trip) and the in-flight verb renders pending — never optimistic success. - controlReason ({action, kind, reason}|null): rejection/unauthorized/timeout renders its reason inline. Controls restructured to a verb row + a status line; the card only RENDERS the fields — Lane-C (C2) owns setting them via the round-trip. Spec covers: forward seam (fixed for the new nesting), pending disables all verbs + renders pending, reject renders the reason. SCSS for the verb row + status line (token-only). Render side against the proposed contract; C2 wiring lands when Euclid acks the field shapes. Not a full #14611 (end-to-end round-trip pends C2). * feat(agentos): B4 control status prioritizes pending over a stale reason — clear-on-new-intent nuance, render side (#14611) Euclid acked the two-field B4/C2 contract (pendingAction + controlReason) with a clear-on-new-intent nuance: C2 clears a stale controlReason when a new accepted intent enters pending. This makes the render defensive from B4's side too — the status line prioritizes pendingAction over controlReason, so a new attempt never shows a stale rejection even if the clear has a gap. Spec adds the coexist assertion (both set → pending wins). * fix(agentos): FM control cluster — one power toggle (start|stop by state), not a start+stop pair (#14611) Operator UX catch: a play button beside a stop button is one action pretending to be two — a resident is either off (only start is valid) or running (only stop is valid). Rendering both and disabling the invalid one is bloat, not safety; the invalid one is never legitimately clickable. - Single power toggle: ▶ start when state==='off', ■ stop when running — onToggleLifecycle derives the verb from state and fires the same lifecycleIntent {action, agentId}. - restart shows only while running (hidden when off — a stopped resident starts via the toggle; restart of a stopped agent is just start). - Honest-state + pending disable unchanged. Spec rewritten: off→toggle-is-start + restart-hidden; running→toggle-is-stop + restart-shown; restart fires restart; pending disables controls. Principle banked: model state→valid-actions and render only what's reachable per state. * fix(agentos): FM cockpit roster = the 7 real agents — drop the fabricated 'Kepler' entry (#14560) Operator caught a fabricated agent rendered in the fixture-fed cockpit: 'Kepler' (agentId neo-clio-limit, sonnet-5, avatar = the org logo) is not a real identity — who_is_online has no such maintainer. It was invented to demo the 'limited' state. Removed → the roster is now exactly the seven real cross-family agents (Euclid, Vega, Ada, Grace, Mnemosyne, Clio, Gemini). JSDoc updated: identities + avatars are real; state + lane-line are the illustrative snapshot until the live source wires; no invented agents. Also corrected the #14592 density evidence, which had over-counted the roster as 12/~8 (folding in the operator, test identities, and the legacy Opus-4.7 node) — the real agent count is 7. Lesson: V-B-A fixture rosters + counts against the real agent set. * fix(agentos): FM cockpit stacks activity below the fleet (full-width bottom), not beside it (#14560) Operator UX catch: the live-activity feed rendered as a right-hand column (an inner hbox beside the grid). It belongs full-width at the BOTTOM. Flattened FleetCockpit to a single vbox [control-bar | fleet grid | activity feed] — the grid now gets the full width (more ranked-card columns) and the feed is the full-width bottom strip. Zone divider moved from grid right-border to grid bottom-border. Note: this changes the SSOT §01 'beside' arrangement to 'below' per operator direction — the design SSOT (fleet-manager-cockpit-plan.html) should be updated to match; flagged to Grace. * fix(agentos): attach FM control controllers as bare class refs — {module:X} fails bare-test class resolution (#14611) The 3 FM control specs failed with 'Class AgentOS.view.fleet.FleetCockpitController does not exist' at beforeSetController. Cause: I attached the controllers as controller: {module: X} — that object form routes through ClassSystemUtil.beforeSetInstance's Object-path (Neo.create({module: X, ...})), which fails to resolve the class in the bare unit-test registry. The idiomatic bare-class form (controller: X — as the working AgentOS Viewport uses controller: ViewportController) hits the NeoClass path (Neo.create(class, ...)) that resolves in both app + test. Cascade fixed: controller now resolves → controls render → down() finds the verb row + status. * fix(agentos): B4 specs query controls by reference + controller-isolation for whole-fleet (#14611) The 3 FM control specs failed on test-shape, not product bugs (the live app renders fine): - down({cls:[...]}) returned null — a cls-array query isn't exact-match once baseCls is appended. Added reference: 'control-verbs' / 'control-status' to the controls slot + query by reference (the reliable Neo idiom; getReference is itself down({reference})). - Neo.create(FleetCockpit) throws bare — it composes FleetGrid → the whole card wall, too heavy to instantiate without the full app (the existing loadActivity specs mock for the same reason). The whole-fleet test now uses controller isolation (spy component + onStartFleet), matching that pattern. * fix(agentos): B4 control disable-guard robust to unset pendingAction + toMatchObject for injected source (#14611) The 2 red B4 specs surfaced one real product bug + one over-strict assertion: - disabled: data => data.pendingAction !== null wrongly disabled every control when pendingAction is undefined — the state a partial-seeded card provider carries (the factory seeds display fields; Lane-C sets pendingAction later). Boolean(data.pendingAction) is robust to null AND undefined: disabled iff a verb is genuinely in flight. - Neo's component.fire() injects source (the component id) into the event payload, so toEqual on the fired lifecycleIntent was too strict. toMatchObject asserts the intent fields and still checks the fired count. * feat(agentos): wire cockpit controls to the C2 adapter — per-card intents now drive the honest round-trip (#14611) B4's control cluster fired lifecycleIntent into the void and C2's adapter (#14894, merged) was never called — two green fragments that didn't add up to a working control. This closes the seam: - Each FleetGrid-built card carries listeners:{lifecycleIntent:'onAgentLifecycleIntent'}, which resolves UP the controller chain (card → grid[no controller] → cockpit) to FleetCockpitController — the composition root that may know transport. - onAgentLifecycleIntent resolves the firing card from the event source (Neo.getComponent) and hands the intent + that card's state.Provider to handleFleetLifecycleIntent. The adapter calls the registry bridge and writes honest pending/settled/rejected state back onto the provider the card renders; no bridge → fail-closed unauthorized, never optimistic. Delivers the cornerstone: start/stop/restart a single agent from its card. Fleet-wide 'Start morning fleet' fan-out is the next slice (onStartFleet still fires the fleet-scope intent). * feat(agentos): fleet-wide morning-start fan-out — the cockpit bar now drives every card (#14611) onStartFleet fired a fleet-scope lifecycleIntent that nothing consumed — the 'Start morning fleet' button was dead. It now fans out: the cockpit (composition root) enumerates the rendered cards via its fleet-cards reference (FleetGrid has no controller, so that reference resolves up here) and hands each a start intent + its state.Provider to the C2 adapter. Every resident drives its own honest round-trip; the collapsed-idle fold is filtered by ntype; no bridge → per-card fail-closed, never an optimistic fleet-wide success. Completes the cockpit control surface: per-card AND whole-fleet start now functional end-to-end. * fix(e2e): scope Fleet cockpit lifecycle NL assertions to lifecycle verbs — the boot activity-poll is orthogonal (#14611) The FleetCockpitLifecycleNL whitebox-e2e went red (both tests) after the Fleet cockpit became the default boot-mounted keeper-view (14846/14847): its always-on activity stream fires a read-observe fleetActivity poll on boot, which the recording bridge captured alongside the FleetSettingsPanel Start -> the all-requests count was 2 while the assertion expected 1. Scope the count to lifecycle verbs (start/stop/restart); the activity poll is orthogonal and credential-free, and the minimal-payload/no-credential assertion still guards the one lifecycle call. Verified green: 2 passed on the e2e config. This PR's B4 diff is innocent of the regression (V-B-A: the diff touches no fleetActivity/loadActivity/default-view path) — this restores the cockpit-lifecycle whitebox-e2e that the earlier keeper-view merge reddened. * feat(agentos): B4 honest-state contract — unauthorized disables the cluster, timeout renders stale-pending (#14611) Completes the #14611 honest-state matrix from Euclid's re-reviews. The C2 adapter already writes unauthorized/timeout; B4 now renders them per the accepted contract: - unauthorized -> the verb cluster DISABLES with its reason (disabled = pendingAction OR controlReason.kind==='unauthorized'); no retrying into a closed door — a reason beside a live button was the gap. - timeout -> stale-pending: '<verb>... stale — no response', an unfinished '...' NOT a resolved warning, because the outcome is UNKNOWN (the verb may still be running); retry stays open. New unit test connects the adapter's unauthorized/timeout provider writes to the disabled-with-reason / stale-pending card render — the coverage Euclid named as missing. Cards stay intent-only; no faked success.
1 parent c691698 commit 7eb145a

10 files changed

Lines changed: 442 additions & 31 deletions

File tree

apps/agentos/view/fleet/AgentCard.mjs

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import Container from '../../../../src/container/Base.mjs';
2-
import FamilyRail from './FamilyRail.mjs';
3-
import Image from '../../../../src/component/Image.mjs';
4-
import StateDot from './StateDot.mjs';
5-
import StateProvider from '../../../../src/state/Provider.mjs';
1+
import AgentCardController from './AgentCardController.mjs';
2+
import Button from '../../../../src/button/Base.mjs';
3+
import Container from '../../../../src/container/Base.mjs';
4+
import FamilyRail from './FamilyRail.mjs';
5+
import Image from '../../../../src/component/Image.mjs';
6+
import StateDot from './StateDot.mjs';
7+
import StateProvider from '../../../../src/state/Provider.mjs';
68

79
/**
810
* The resident card: the cockpit's atom. Composes the class-based fleet primitives (FamilyRail +
@@ -42,6 +44,12 @@ class AgentCard extends Container {
4244
* @member {String[]} baseCls=['fm-agent-card']
4345
*/
4446
baseCls: ['fm-agent-card'],
47+
/**
48+
* Turns the controls-slot buttons into a single `lifecycleIntent` event (the B4 emit); the
49+
* Lane C (C2) round-trip consumes it. See {@link AgentOS.view.fleet.AgentCardController}.
50+
* @member {Neo.controller.Component} controller=AgentCardController
51+
*/
52+
controller: AgentCardController,
4553
/**
4654
* @member {Object} layout={ntype:'hbox',align:'stretch'}
4755
* @reactive
@@ -56,19 +64,22 @@ class AgentCard extends Container {
5664
stateProvider: {
5765
module: StateProvider,
5866
data : {
59-
agentId : null,
60-
avatarUrl : null,
61-
displayName: null,
62-
engineTag : null,
63-
family : null,
64-
laneLine : null,
65-
state : 'off'
67+
agentId : null,
68+
avatarUrl : null,
69+
controlReason: null, // {action, kind, reason} of the last reject/unauthorized/timeout, set by Lane-C
70+
displayName : null,
71+
engineTag : null,
72+
family : null,
73+
laneLine : null,
74+
pendingAction: null, // the verb whose lifecycle round-trip is in flight, set by Lane-C; null when settled
75+
state : 'off'
6676
}
6777
},
6878
/**
6979
* The card anatomy — family rail · avatar · body (name-row [state dot + name + engine tag] +
70-
* current-lane line). Each child binds to the per-card provider. Controls slot (T5) + foot
71-
* meta are sibling leaves.
80+
* current-lane line) · controls slot (start/stop/restart → a single `lifecycleIntent` event
81+
* for the Lane C round-trip). Each child binds to the per-card provider; foot meta is a
82+
* sibling leaf.
7283
* @member {Object[]} items
7384
*/
7485
items: [{
@@ -111,6 +122,72 @@ class AgentCard extends Container {
111122
cls : ['fm-card-lane'],
112123
bind : {text: data => data.laneLine}
113124
}]
125+
}, {
126+
ntype : 'container',
127+
cls : ['fm-card-controls'],
128+
flex : 'none',
129+
layout: {ntype: 'vbox', align: 'stretch'},
130+
131+
items: [{
132+
ntype : 'container',
133+
cls : ['fm-card-control-verbs'],
134+
reference: 'control-verbs',
135+
layout : {ntype: 'hbox', align: 'center'},
136+
137+
items: [{
138+
// ONE power toggle — start when off, stop when running. Only one of the two is ever
139+
// valid for a given state, so we render the contextual action; a disabled play on a
140+
// running resident (or a disabled stop on a stopped one) is bloat, not safety.
141+
module : Button,
142+
handler: 'onToggleLifecycle',
143+
bind : {
144+
disabled: data => Boolean(data.pendingAction) || data.controlReason?.kind === 'unauthorized',
145+
iconCls : data => data.state === 'off' ? 'fa-solid fa-play' : 'fa-solid fa-stop'
146+
}
147+
}, {
148+
// restart is meaningful only while running — a stopped resident starts via the toggle
149+
module : Button,
150+
action : 'restart',
151+
iconCls: 'fa-solid fa-rotate',
152+
handler: 'onLifecycleIntent',
153+
bind : {
154+
disabled: data => Boolean(data.pendingAction) || data.controlReason?.kind === 'unauthorized',
155+
hidden : data => data.state === 'off'
156+
}
157+
}]
158+
}, {
159+
// Honest round-trip state per the B4/C2 contract — the card RENDERS what Lane-C writes,
160+
// never fakes success, and each terminal kind renders DISTINCTLY:
161+
// unauthorized → the reason shows AND the cluster stays disabled (see the verb binds) —
162+
// you cannot retry into a closed door;
163+
// timeout → stale-pending: the outcome is UNKNOWN (the verb may still be running,
164+
// we just lost the answer), so it reads as an unfinished "…", not a
165+
// resolved "⚠" failure, and retry stays open;
166+
// rejected → the "⚠ reason" with retry open.
167+
ntype : 'component',
168+
cls : ['fm-card-control-status'],
169+
reference: 'control-status',
170+
bind : {
171+
// pending takes visual priority over a prior reason, so a new attempt never shows a
172+
// stale rejection (complements C2 clearing controlReason on a new accepted intent)
173+
text : data => {
174+
if (data.pendingAction) {
175+
return `${data.pendingAction}…`
176+
}
177+
178+
const reason = data.controlReason;
179+
180+
if (!reason) {
181+
return ''
182+
}
183+
184+
return reason.kind === 'timeout'
185+
? `${reason.action}… stale — no response`
186+
: `⚠ ${reason.kind}: ${reason.reason}`
187+
},
188+
hidden: data => !data.pendingAction && !data.controlReason
189+
}
190+
}]
114191
}]
115192
}
116193
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Controller from '../../../../src/controller/Component.mjs';
2+
3+
/**
4+
* Controller for {@link AgentOS.view.fleet.AgentCard}: turns the card's start/stop/restart controls
5+
* (the B4 lane) into a single lifecycle **intent** event — and stops there. The card owns the
6+
* intent-emit; the cockpit→lifecycle round-trip that consumes it (`FleetControlBridge.startAgent`
7+
* and its siblings) is the Lane C (C2) seam. Keeping the boundary here means the card stays a pure
8+
* presentation + intent surface and never imports the Brain-side fleet bridge.
9+
*
10+
* @class AgentOS.view.fleet.AgentCardController
11+
* @extends Neo.controller.Component
12+
*/
13+
class AgentCardController extends Controller {
14+
static config = {
15+
/**
16+
* @member {String} className='AgentOS.view.fleet.AgentCardController'
17+
* @protected
18+
*/
19+
className: 'AgentOS.view.fleet.AgentCardController'
20+
}
21+
22+
/**
23+
* @summary Fires the card's lifecycle intent for the clicked control.
24+
*
25+
* Reads the durable `agentId` from the per-card state provider and the `action`
26+
* (`start` | `stop` | `restart`) off the button that triggered it, then fires ONE
27+
* `lifecycleIntent` event `{action, agentId}` on the card. The Lane C (C2) round-trip listens
28+
* for this and drives `FleetControlBridge`; this controller intentionally does NOT call the
29+
* bridge — that boundary is the B4 ÷ C2 seam.
30+
* @param {Object} data The button click event; `data.component.action` carries the verb.
31+
*/
32+
onLifecycleIntent(data) {
33+
let me = this,
34+
action = data.component.action,
35+
agentId = me.getStateProvider().getData('agentId');
36+
37+
me.component.fire('lifecycleIntent', {action, agentId})
38+
}
39+
40+
/**
41+
* @summary Fires the contextual power intent — `start` when the resident is off, `stop` when running.
42+
*
43+
* The single power toggle replaces a start+stop pair: only one of the two is ever valid for a given
44+
* session state, so rendering both (one disabled) is noise, not safety. Reads `state` + the durable
45+
* `agentId` from the per-card provider; intent-only (Lane-C owns the round-trip).
46+
* @param {Object} data The button click event.
47+
*/
48+
onToggleLifecycle(data) {
49+
let me = this,
50+
provider = me.getStateProvider(),
51+
agentId = provider.getData('agentId'),
52+
action = provider.getData('state') === 'off' ? 'start' : 'stop';
53+
54+
me.component.fire('lifecycleIntent', {action, agentId})
55+
}
56+
}
57+
58+
export default Neo.setupClass(AgentCardController);

apps/agentos/view/fleet/FleetCockpit.mjs

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import ActivityStream from './ActivityStream.mjs';
2-
import Container from '../../../../src/container/Base.mjs';
3-
import FleetGrid from './FleetGrid.mjs';
1+
import ActivityStream from './ActivityStream.mjs';
2+
import Button from '../../../../src/button/Base.mjs';
3+
import Container from '../../../../src/container/Base.mjs';
4+
import FleetCockpitController from './FleetCockpitController.mjs';
5+
import FleetGrid from './FleetGrid.mjs';
46

57
/**
6-
* A representative fleet roster for the fixture-fed cockpit — the live-wire binding to the roster /
7-
* runtime-status services is the sibling leaf; this renders the design SSOT's §01 fleet zone
8-
* against a realistic snapshot so the mission-control surface is real, not a bare page. Avatars are the
9-
* public GitHub account images (the `githubAvatarUrl` pattern) so identity reads at a glance.
8+
* The seven real cross-family maintainer identities, for the fixture-fed cockpit — the live-wire
9+
* binding to the roster / runtime-status services is the sibling leaf that replaces this. Identities +
10+
* avatars are real (the `githubAvatarUrl` pattern, so identity reads at a glance); session state +
11+
* lane-line are an illustrative snapshot until the live source is wired. NO invented agents.
1012
* @type {Object[]}
1113
*/
1214
const FIXTURE_ROSTER = [
@@ -16,8 +18,7 @@ const FIXTURE_ROSTER = [
1618
{agentId: 'neo-opus-vega', displayName: 'Vega', engineTag: 'opus-4.8', family: 'claude', state: 'ok', avatarUrl: 'https://github.com/neo-opus-vega.png?size=80', laneLine: 'harness-UI shell + left-rail nav (#14846)'},
1719
{agentId: 'neo-fable', displayName: 'Mnemosyne', engineTag: 'fable-5', family: 'claude', state: 'ok', avatarUrl: 'https://github.com/neo-fable.png?size=80', laneLine: 'golden-path direction-velocity writer (#14811)'},
1820
{agentId: 'neo-fable-clio', displayName: 'Clio', engineTag: 'fable-5', family: 'claude', state: 'idle', avatarUrl: 'https://github.com/neo-fable-clio.png?size=80', laneLine: 'CrossWindowDragTarget docking — awaiting review'},
19-
{agentId: 'neo-gemini-pro', displayName: 'Gemini', engineTag: '3-pro', family: 'gemini', state: 'off', avatarUrl: 'https://github.com/neo-gemini-pro.png?size=80', laneLine: 'operator-benched'},
20-
{agentId: 'neo-clio-limit', displayName: 'Kepler', engineTag: 'sonnet-5', family: 'claude', state: 'limited', avatarUrl: 'https://github.com/neomjs.png?size=80', laneLine: 'rate-limited — resumes ~20:50'}
21+
{agentId: 'neo-gemini-pro', displayName: 'Gemini', engineTag: '3-pro', family: 'gemini', state: 'off', avatarUrl: 'https://github.com/neo-gemini-pro.png?size=80', laneLine: 'operator-benched'}
2122
];
2223

2324
/**
@@ -66,15 +67,35 @@ class FleetCockpit extends Container {
6667
*/
6768
baseCls: ['fm-fleet-cockpit'],
6869
/**
69-
* The SSOT §01 split: the fleet zone (~1.55fr) beside the activity stream (1fr).
70-
* @member {Object} layout={ntype:'hbox',align:'stretch'}
70+
* The B4÷C2 composition root: catches each card's `lifecycleIntent` and the whole-fleet
71+
* "▶ Start morning fleet" click, driving both through the C2 adapter to honest per-card
72+
* round-trip state. See {@link AgentOS.view.fleet.FleetCockpitController}.
73+
* @member {Neo.controller.Component} controller=FleetCockpitController
74+
*/
75+
controller: FleetCockpitController,
76+
/**
77+
* Vertical stack: the control bar over the full-width fleet grid over the full-width activity
78+
* feed. The fleet zone gets the full width for its ranked card grid; the live feed is the
79+
* bottom strip, not a right-hand column.
80+
* @member {Object} layout={ntype:'vbox',align:'stretch'}
7181
* @reactive
7282
*/
73-
layout: {ntype: 'hbox', align: 'stretch'},
83+
layout: {ntype: 'vbox', align: 'stretch'},
7484
/**
7585
* @member {Object[]} items
7686
*/
7787
items: [{
88+
ntype: 'toolbar',
89+
cls : ['fm-cockpit-bar'],
90+
flex : 'none',
91+
items: ['->', {
92+
module : Button,
93+
cls : ['fm-fleet-start'],
94+
iconCls: 'fa-solid fa-play',
95+
text : 'Start morning fleet',
96+
handler: 'onStartFleet'
97+
}]
98+
}, {
7899
module: FleetGrid,
79100
flex : 1.55,
80101
agents: FIXTURE_ROSTER
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Controller from '../../../../src/controller/Component.mjs';
2+
import {handleFleetLifecycleIntent} from './fleetLifecycleIntentAdapter.mjs';
3+
4+
/**
5+
* Controller for {@link AgentOS.view.fleet.FleetCockpit} — the cockpit is the **composition root** of
6+
* the B4÷C2 seam: the one place that knows both the resident cards and the fleet bridge, so the wire
7+
* lives here (the cards themselves stay intent-only and never touch transport).
8+
*
9+
* Two entry points, both driving the C2 adapter (`handleFleetLifecycleIntent`) → the registry bridge →
10+
* honest per-card round-trip state, never an optimistic success:
11+
* - `onAgentLifecycleIntent` — catches a single card's `lifecycleIntent` (resolved up the controller
12+
* chain via the card's listener) and dispatches it for that card.
13+
* - `onStartFleet` — the design SSOT §01 "▶ Start morning fleet" one-click: fans `start` out to every
14+
* rendered card, so each resident drives its own honest round-trip.
15+
*
16+
* @class AgentOS.view.fleet.FleetCockpitController
17+
* @extends Neo.controller.Component
18+
*/
19+
class FleetCockpitController extends Controller {
20+
static config = {
21+
/**
22+
* @member {String} className='AgentOS.view.fleet.FleetCockpitController'
23+
* @protected
24+
*/
25+
className: 'AgentOS.view.fleet.FleetCockpitController'
26+
}
27+
28+
/**
29+
* @summary The one-click morning start — fan `start` out to every resident card via the C2 adapter.
30+
*
31+
* The cockpit owns the wire (the cards stay intent-only): it enumerates the rendered cards and hands
32+
* each a `start` intent + that card's `state.Provider` to `handleFleetLifecycleIntent`, so every
33+
* resident drives its own honest round-trip (pending → settled / rejected), never an optimistic
34+
* fleet-wide success. Starting an already-running resident is the bridge's concern; the per-card
35+
* honest state reflects whatever actually happens.
36+
*/
37+
onStartFleet() {
38+
this.getAgentCards().forEach(card => {
39+
const provider = card.getStateProvider();
40+
41+
handleFleetLifecycleIntent({action: 'start', agentId: provider.getData('agentId')}, provider)
42+
})
43+
}
44+
45+
/**
46+
* @summary The rendered resident cards — the fleet grid's card region (a no-controller container, so
47+
* its `fleet-cards` reference resolves up to this controller); the collapsed-idle fold and the header
48+
* sub-tree are excluded by ntype.
49+
* @returns {Neo.component.Base[]}
50+
*/
51+
getAgentCards() {
52+
return (this.getReference('fleet-cards')?.items ?? []).filter(card => card.ntype === 'fm-agent-card')
53+
}
54+
55+
/**
56+
* @summary Consume a card's `lifecycleIntent` and drive the honest round-trip — the B4÷C2 seam.
57+
*
58+
* A card's control cluster fires an intent-only `lifecycleIntent {action, agentId}` and never
59+
* touches transport. The cockpit is the composition root that knows both the cards and the fleet
60+
* bridge: it resolves the firing card from the event `source`, then hands the intent + that card's
61+
* `state.Provider` to the C2 adapter (`handleFleetLifecycleIntent`). The adapter calls the registry
62+
* bridge and writes honest pending / settled / rejected state back onto the provider the card
63+
* renders — never an optimistic success.
64+
* @param {Object} data The `lifecycleIntent` payload `{action, agentId, source}` — Neo stamps `source`.
65+
*/
66+
onAgentLifecycleIntent(data) {
67+
const card = Neo.getComponent(data.source);
68+
69+
card && handleFleetLifecycleIntent(data, card.getStateProvider())
70+
}
71+
}
72+
73+
export default Neo.setupClass(FleetCockpitController);

apps/agentos/view/fleet/FleetGrid.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ class FleetGrid extends Container {
205205
agentCardConfig(agent) {
206206
return {
207207
module : AgentCard,
208+
// The card's control cluster fires an intent-only `lifecycleIntent`; this listener resolves
209+
// UP the controller chain (card → grid [no controller] → cockpit) to
210+
// FleetCockpitController.onAgentLifecycleIntent — the C2 consumer that drives the round-trip.
211+
listeners : {lifecycleIntent: 'onAgentLifecycleIntent'},
208212
stateProvider: {
209213
data: {
210214
agentId : agent?.agentId ?? null,

resources/scss/src/apps/agentos/fleet/AgentCard.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,19 @@
4646
white-space : nowrap;
4747
text-overflow: ellipsis;
4848
}
49+
50+
.fm-card-controls {
51+
flex: none;
52+
gap : 2px;
53+
}
54+
55+
.fm-card-control-verbs {
56+
gap : 2px;
57+
align-items: center;
58+
}
59+
60+
.fm-card-control-status {
61+
font-size: 10px;
62+
color : var(--fm-ink-dim);
63+
}
4964
}

resources/scss/src/apps/agentos/fleet/FleetCockpit.scss

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@
33
.fm-fleet-cockpit {
44
background: var(--fm-ground);
55

6+
.fm-cockpit-bar {
7+
flex : none;
8+
padding: 6px 10px;
9+
gap : 8px;
10+
11+
.fm-fleet-start {
12+
font-weight: 600;
13+
}
14+
}
15+
616
> .fm-fleet-grid {
7-
border-right: 1px solid var(--fm-line-soft);
17+
border-bottom: 1px solid var(--fm-line-soft);
818
}
919
}

0 commit comments

Comments
 (0)