Skip to content

Commit b1068b2

Browse files
authored
feat(fleet): add cockpit status DTO (#14562) (#14571)
* feat(fleet): add cockpit status DTO (#14562) * test(portal): tolerate empty active pull chunks (#14575)
1 parent 98d9082 commit b1068b2

3 files changed

Lines changed: 316 additions & 3 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
const WIRE_SOURCES = Object.freeze({
2+
activity : 'fleet:activity-adapters',
3+
repoStatus: 'fleet:fleetStatus',
4+
roster : 'fleet:listAgents',
5+
runtime : 'fleet:runtimeStatus',
6+
lifecycle : 'fleet:lifecycle'
7+
})
8+
9+
export const FLEET_COCKPIT_EVENT_TYPES = Object.freeze([
10+
'lifecycle-request',
11+
'lifecycle-success',
12+
'lifecycle-failure',
13+
'bridge-unavailable',
14+
'bridge-gated'
15+
])
16+
17+
/**
18+
* @summary Source labels for the Fleet Manager cockpit DTO. These labels are deliberately stable and
19+
* transport-agnostic: the Body-side cockpit can explain which live substrate produced each row or
20+
* event without importing the Node-only FleetControlBridge / registry / lifecycle service chain.
21+
* @type {Object}
22+
*/
23+
export const FLEET_COCKPIT_SOURCES = Object.freeze({...WIRE_SOURCES})
24+
25+
/**
26+
* @summary Build the first Fleet Manager cockpit snapshot from the already-shipped bridge reads:
27+
* `listAgents()` for the redacted roster and `fleetStatus()` for repo-provisioning state. Runtime
28+
* process truth and A2A/PR/lane activity are explicit capability slots, not guessed browser state; an
29+
* unwired adapter is rendered as `not-wired` so Lane 1 never mistakes placeholder data for fact.
30+
*
31+
* @param {Object} options={}
32+
* @param {Object[]} options.agents Public agent definitions from `registryBridge.listAgents()`.
33+
* @param {Object[]} options.fleetStatus Repo-status entries from `registryBridge.fleetStatus()`.
34+
* @param {Object[]} options.events Optional already-normalized cockpit events.
35+
* @returns {Object} serializable cockpit DTO `{sources, capabilities, rows, events}`.
36+
*/
37+
export function createFleetCockpitStatus({agents = [], fleetStatus = [], events = []} = {}) {
38+
const statusByAgentId = new Map(
39+
fleetStatus.map(status => [status.agentId || status.id, sanitizePayload(status)])
40+
)
41+
42+
return {
43+
sources : FLEET_COCKPIT_SOURCES,
44+
capabilities: {
45+
activity: createNotWiredCapability(FLEET_COCKPIT_SOURCES.activity, 'A2A / PR / lane activity adapter not wired'),
46+
runtime : createNotWiredCapability(FLEET_COCKPIT_SOURCES.runtime, 'runtime process status is pending the Fleet runtime-status wire method')
47+
},
48+
rows: agents.map(agent => {
49+
const publicAgent = sanitizePayload(agent),
50+
agentId = publicAgent.id,
51+
repoStatus = statusByAgentId.get(agentId) || null
52+
53+
return {
54+
id : agentId,
55+
githubUsername: publicAgent.githubUsername ?? null,
56+
harnessType : publicAgent.harnessType ?? null,
57+
displayName : publicAgent.displayName ?? publicAgent.name ?? publicAgent.githubUsername ?? agentId ?? null,
58+
agent : publicAgent,
59+
repoStatus,
60+
lifecycle : {
61+
source : FLEET_COCKPIT_SOURCES.runtime,
62+
state : 'not-wired',
63+
confidence: 'none'
64+
},
65+
sources: {
66+
roster: {
67+
source : FLEET_COCKPIT_SOURCES.roster,
68+
state : 'wired',
69+
confidence: 'observed'
70+
},
71+
repoStatus: {
72+
source : FLEET_COCKPIT_SOURCES.repoStatus,
73+
state : repoStatus ? 'wired' : 'missing',
74+
confidence: repoStatus ? 'observed' : 'none'
75+
},
76+
runtime: {
77+
source : FLEET_COCKPIT_SOURCES.runtime,
78+
state : 'not-wired',
79+
confidence: 'none'
80+
}
81+
}
82+
}
83+
}),
84+
events: events.map(event => createFleetCockpitEvent(event))
85+
}
86+
}
87+
88+
/**
89+
* @summary Normalize a bounded fleet cockpit activity event. The type set is intentionally narrow so
90+
* UI and whitebox tests can prove lifecycle request/success/failure and bridge unavailable/gated
91+
* states without inventing opaque free-form activity.
92+
* @param {Object} event
93+
* @returns {Object}
94+
*/
95+
export function createFleetCockpitEvent({type, source, agentId = null, confidence = 'observed', payload = null, occurredAt = null} = {}) {
96+
if (!FLEET_COCKPIT_EVENT_TYPES.includes(type)) {
97+
throw new Error(`createFleetCockpitEvent: unsupported event type '${type}'`)
98+
}
99+
if (!source) {
100+
throw new Error('createFleetCockpitEvent: source is required')
101+
}
102+
103+
return {
104+
type,
105+
source,
106+
agentId,
107+
confidence,
108+
occurredAt,
109+
payload: sanitizePayload(payload)
110+
}
111+
}
112+
113+
/**
114+
* @summary Capability placeholder for adapters that are planned but not wired. A missing adapter is a
115+
* first-class fact, not sample data.
116+
* @param {String} source
117+
* @param {String} reason
118+
* @returns {Object}
119+
*/
120+
export function createNotWiredCapability(source, reason) {
121+
return {
122+
source,
123+
state : 'not-wired',
124+
confidence: 'none',
125+
reason
126+
}
127+
}
128+
129+
function sanitizePayload(value) {
130+
if (Array.isArray(value)) {
131+
return value.map(item => sanitizePayload(item))
132+
}
133+
134+
if (!value || typeof value !== 'object') {
135+
return value
136+
}
137+
138+
return Object.fromEntries(
139+
Object.entries(value)
140+
.filter(([key]) => !isSecretKey(key))
141+
.map(([key, item]) => [key, sanitizePayload(item)])
142+
)
143+
}
144+
145+
function isSecretKey(key) {
146+
const normalized = key.toLowerCase().replace(/[_-]/g, '')
147+
148+
return normalized === 'credential' ||
149+
normalized.endsWith('credential') ||
150+
normalized === 'pat' ||
151+
normalized.endsWith('pat') ||
152+
normalized.includes('token') ||
153+
normalized.includes('secret') ||
154+
normalized.includes('password') ||
155+
normalized.includes('signingkey') ||
156+
normalized.includes('privatekey')
157+
}

test/playwright/unit/ai/buildScripts/docs/index/PortalContentIndexes.spec.mjs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ function getChunkNumber(record) {
3535
* @param {Object[]} records
3636
* @param {String} parentId
3737
*/
38-
function expectChunkFoldersDescending(records, parentId) {
38+
function expectChunkFoldersDescending(records, parentId, {requireMultiple = true} = {}) {
3939
const chunkNumbers = records
4040
.filter(record => record.parentId === parentId && getChunkNumber(record) !== null)
4141
.map(getChunkNumber);
4242

43-
expect(chunkNumbers.length).toBeGreaterThan(1);
43+
if (requireMultiple) {
44+
expect(chunkNumbers.length).toBeGreaterThan(1);
45+
}
46+
4447
expect(chunkNumbers).toEqual([...chunkNumbers].sort((a, b) => b - a))
4548
}
4649

@@ -183,7 +186,7 @@ test.describe('Portal content index generators (#12210)', () => {
183186
test('committed pull-request index keeps active chunk folders in chunk-number order', async () => {
184187
const index = await fs.readJson(path.join(process.cwd(), 'apps/portal/resources/data/pulls/index.json'));
185188

186-
expectChunkFoldersDescending(index, 'Latest')
189+
expectChunkFoldersDescending(index, 'Latest', {requireMultiple: false})
187190
});
188191

189192
test('createDiscussionIndex groups by frontmatter category for active and archive files', async () => {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {setup} from '../../../../setup.mjs'
2+
3+
const appName = 'FleetCockpitStatusTest'
4+
5+
setup({
6+
neoConfig: {
7+
unitTestMode: true
8+
},
9+
appConfig: {
10+
name : appName,
11+
isMounted : () => true,
12+
vnodeInitialising: false
13+
}
14+
})
15+
16+
import {test, expect} from '@playwright/test'
17+
import Neo from '../../../../../../src/Neo.mjs'
18+
import * as core from '../../../../../../src/core/_export.mjs'
19+
20+
import {
21+
createFleetCockpitEvent,
22+
createFleetCockpitStatus,
23+
FLEET_COCKPIT_EVENT_TYPES,
24+
FLEET_COCKPIT_SOURCES
25+
} from '../../../../../../src/ai/fleet/fleetCockpitStatus.mjs'
26+
27+
test.describe('fleetCockpitStatus - Body-side cockpit DTO contract', () => {
28+
test('composes roster and repo status with explicit source labels', () => {
29+
const snapshot = createFleetCockpitStatus({
30+
agents: [{
31+
id : 'alice',
32+
githubUsername: 'alice-gh',
33+
harnessType : 'codex',
34+
metadata : {
35+
repo: {repoSlug: 'neomjs/neo'}
36+
}
37+
}],
38+
fleetStatus: [{
39+
agentId : 'alice',
40+
configured : true,
41+
repoSlug : 'neomjs/neo',
42+
state : 'checkout',
43+
provisioningAction: 'reuse'
44+
}]
45+
})
46+
47+
expect(snapshot.sources).toEqual(FLEET_COCKPIT_SOURCES)
48+
expect(snapshot.rows).toHaveLength(1)
49+
expect(snapshot.rows[0]).toMatchObject({
50+
id : 'alice',
51+
githubUsername: 'alice-gh',
52+
harnessType : 'codex',
53+
repoStatus : {
54+
agentId : 'alice',
55+
repoSlug: 'neomjs/neo',
56+
state : 'checkout'
57+
},
58+
sources: {
59+
roster: {
60+
source: FLEET_COCKPIT_SOURCES.roster,
61+
state : 'wired'
62+
},
63+
repoStatus: {
64+
source: FLEET_COCKPIT_SOURCES.repoStatus,
65+
state : 'wired'
66+
}
67+
}
68+
})
69+
})
70+
71+
test('marks runtime and activity adapters as not wired instead of inventing state', () => {
72+
const snapshot = createFleetCockpitStatus({
73+
agents : [{id: 'alice', githubUsername: 'alice-gh'}],
74+
fleetStatus: []
75+
})
76+
77+
expect(snapshot.capabilities.activity).toMatchObject({
78+
source: FLEET_COCKPIT_SOURCES.activity,
79+
state : 'not-wired'
80+
})
81+
expect(snapshot.capabilities.runtime).toMatchObject({
82+
source: FLEET_COCKPIT_SOURCES.runtime,
83+
state : 'not-wired'
84+
})
85+
expect(snapshot.rows[0].repoStatus).toBeNull()
86+
expect(snapshot.rows[0].sources.repoStatus).toMatchObject({
87+
source: FLEET_COCKPIT_SOURCES.repoStatus,
88+
state : 'missing'
89+
})
90+
expect(snapshot.rows[0].lifecycle).toMatchObject({
91+
source: FLEET_COCKPIT_SOURCES.runtime,
92+
state : 'not-wired'
93+
})
94+
})
95+
96+
test('strips secret-shaped fields from rows and events recursively', () => {
97+
const snapshot = createFleetCockpitStatus({
98+
agents: [{
99+
id : 'alice',
100+
credential : 'ghp_secret',
101+
credentialState: 'stored',
102+
metadata : {
103+
repo : {repoSlug: 'neomjs/neo'},
104+
privateToken: 'secret'
105+
}
106+
}],
107+
fleetStatus: [{
108+
agentId : 'alice',
109+
repoPath : '/tmp/fleet/alice',
110+
state : 'checkout',
111+
signingKey: 'secret'
112+
}],
113+
events: [{
114+
type : 'lifecycle-request',
115+
source : FLEET_COCKPIT_SOURCES.lifecycle,
116+
agentId: 'alice',
117+
payload: {
118+
id : 'alice',
119+
bridgePAT: 'ghp_secret',
120+
nested : {
121+
password: 'secret'
122+
}
123+
}
124+
}]
125+
})
126+
127+
const serialized = JSON.stringify(snapshot)
128+
129+
expect(serialized).toContain('alice')
130+
expect(serialized).toContain('credentialState')
131+
expect(serialized).toContain('repoPath')
132+
expect(serialized).not.toContain('ghp_secret')
133+
expect(serialized).not.toContain('signingKey')
134+
expect(serialized).not.toContain('privateToken')
135+
expect(serialized).not.toContain('bridgePAT')
136+
expect(serialized).not.toContain('password')
137+
})
138+
139+
test('normalizes only the bounded lifecycle and bridge event classes', () => {
140+
const events = FLEET_COCKPIT_EVENT_TYPES.map(type => createFleetCockpitEvent({
141+
type,
142+
source : type.startsWith('bridge') ? FLEET_COCKPIT_SOURCES.roster : FLEET_COCKPIT_SOURCES.lifecycle,
143+
agentId: 'alice'
144+
}))
145+
146+
expect(events.map(event => event.type)).toEqual([...FLEET_COCKPIT_EVENT_TYPES])
147+
})
148+
149+
test('rejects source-less or unsupported events', () => {
150+
expect(() => createFleetCockpitEvent({type: 'lifecycle-request'})).toThrow('source is required')
151+
expect(() => createFleetCockpitEvent({type: 'free-form', source: FLEET_COCKPIT_SOURCES.lifecycle})).toThrow('unsupported event type')
152+
})
153+
})

0 commit comments

Comments
 (0)