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
4,117 changes: 4,117 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions packages/state-watcher/src/cogmesh-poller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { parseCogmeshConfig } from './parsers/retortconfig.js'
import type { CognitiveMeshHealth } from './protocol.js'

const HEALTH_INTERVAL_MS = 30_000
const HEALTH_TIMEOUT_MS = 5_000

/**
* Polls the cognitive-mesh `/health` endpoint on a 30s interval.
* Emits `unconfigured` when no endpoint is set in `.retortconfig`.
*
* Returns `reload()` (call when .retortconfig changes) and `stop()`.
*/
export function createCogmeshPoller(
root: string,
onHealth: (h: CognitiveMeshHealth) => void,
): { reload: () => void; stop: () => void } {
let config = parseCogmeshConfig(root)
let timer: ReturnType<typeof setInterval> | null = null

async function checkHealth(): Promise<void> {
if (!config.endpoint) {
onHealth({ status: 'unconfigured' })
return
}

const url = `${config.endpoint.replace(/\/$/, '')}/health`
const headers: Record<string, string> = {}
if (config.secret) headers['Authorization'] = `Bearer ${config.secret}`

const start = Date.now()
try {
const res = await fetch(url, {
headers,
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
})
Comment on lines +31 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for Node.js version requirements in package.json or similar configs
fd -t f 'package.json' --exec cat {} \; | jq -r '.engines.node // empty' 2>/dev/null || echo "No engines.node found"

Repository: phoenixvc/retort-plugins

Length of output: 50


🏁 Script executed:

# Check if package.json exists and examine its full contents
cd packages/state-watcher && cat package.json || echo "No package.json found in state-watcher"

Repository: phoenixvc/retort-plugins

Length of output: 631


🏁 Script executed:

# Also check project root package.json
cat package.json 2>/dev/null || echo "No package.json in root"

Repository: phoenixvc/retort-plugins

Length of output: 885


🏁 Script executed:

# Check for any Node.js version constraint files
fd -t f '\.nvmrc|\.node-version|\.tool-versions' -x cat {}

Repository: phoenixvc/retort-plugins

Length of output: 50


Add explicit engines.node field to package.json to clarify minimum Node.js version.

The code uses AbortSignal.timeout() (requires Node.js ≥17.3) and global fetch (requires Node.js ≥18). While the project's @types/node dependency is pinned to ^20.0.0, suggesting Node.js 20+, there is no explicit engines.node field in either packages/state-watcher/package.json or the root package.json. Add "engines": { "node": ">=18.0.0" } (or higher) to both package.json files to enforce this requirement and prevent accidental use with incompatible Node versions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/state-watcher/src/cogmesh-poller.ts` around lines 31 - 35, The
project uses AbortSignal.timeout and global fetch (seen in cogmesh-poller.ts
with AbortSignal.timeout(HEALTH_TIMEOUT_MS) and fetch), so add an explicit
engines.node field to both the root package.json and
packages/state-watcher/package.json to require Node.js >=18.0.0 (or a higher
minimum you prefer); update each package.json's "engines" section to include
"node": ">=18.0.0" so CI and consumers are warned/enforced about the Node
version compatibility.

const latencyMs = Date.now() - start
onHealth(res.ok
? { status: 'connected', latencyMs }
: { status: 'degraded', latencyMs },
)
} catch {
onHealth({ status: 'unreachable' })
}
}

function reload(): void {
config = parseCogmeshConfig(root)
void checkHealth()
}

function stop(): void {
if (timer) clearInterval(timer)
timer = null
}

// Start immediately, then on interval
void checkHealth()
timer = setInterval(() => { void checkHealth() }, HEALTH_INTERVAL_MS)

return { reload, stop }
}
11 changes: 11 additions & 0 deletions packages/state-watcher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { parseBacklog, parseTasks, parseTeams, parseSession } from './parsers/index.js'
import { createWatcher, type ChangeKind } from './watcher.js'
import { createServer } from './server.js'
import { createCogmeshPoller } from './cogmesh-poller.js'
import type { HostMessage } from './protocol.js'

const workspaceRoot = process.argv[2]
Expand Down Expand Up @@ -53,6 +54,10 @@ const { broadcast, close } = createServer(
},
)

const cogmeshPoller = createCogmeshPoller(workspaceRoot, (health) => {
broadcast({ type: 'cogmesh:health:updated', health })
})

// ---------------------------------------------------------------------------
// File watcher — debounce and broadcast targeted updates
// ---------------------------------------------------------------------------
Expand All @@ -70,6 +75,11 @@ const stopWatcher = createWatcher(workspaceRoot, (kind) => {
})

function flushPending(): void {
// .retortconfig change — reload CM config before sending snapshot
if (pendingKinds.has('generic')) {
cogmeshPoller.reload()
}

// If multiple kinds changed, send a full snapshot rather than many patches —
// it keeps the protocol simple and avoids ordering issues.
if (pendingKinds.size > 1 || pendingKinds.has('generic') || pendingKinds.has('session')) {
Expand Down Expand Up @@ -105,6 +115,7 @@ function flushPending(): void {

async function shutdown(): Promise<void> {
if (debounceTimer) clearTimeout(debounceTimer)
cogmeshPoller.stop()
await stopWatcher()
close()
}
Expand Down
42 changes: 42 additions & 0 deletions packages/state-watcher/src/parsers/retortconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as fs from 'fs'
import * as path from 'path'

export interface CogmeshConfig {
endpoint: string | null
secret: string | null
}

/**
* Reads .retortconfig and extracts the `cogmesh:` block values.
* Substitutes ${VAR} references with process.env values.
*/
export function parseCogmeshConfig(root: string): CogmeshConfig {
const configPath = path.join(root, '.retortconfig')
let content: string
try {
content = fs.readFileSync(configPath, 'utf-8')
} catch {
return { endpoint: null, secret: null }
}

// Extract the indented block under `cogmesh:`
const blockMatch = /^cogmesh:\s*\n((?:[ \t]+[^\n]*\n?)*)/m.exec(content)
if (!blockMatch) return { endpoint: null, secret: null }
const block = blockMatch[1]

return {
endpoint: resolveEnv(extractScalar(block, 'endpoint')),
secret: resolveEnv(extractScalar(block, 'secret')),
}
}

function extractScalar(block: string, key: string): string | null {
const m = new RegExp(`^[ \\t]+${key}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, 'm').exec(block)
return m?.[1]?.trim() ?? null
}

/** Replaces ${VAR} with process.env.VAR (leaves intact if not set). */
function resolveEnv(value: string | null): string | null {
if (!value) return null
return value.replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? `\${${name}}`)
}
26 changes: 21 additions & 5 deletions packages/state-watcher/src/parsers/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,28 @@ import type { Team } from '../protocol.js'
* The markdown fallback extracts team names from table rows in
* AGENT_TEAMS.md and produces minimal Team objects with sensible defaults.
*/
function detectRepoName(root: string): string {
try {
const markerPath = path.join(root, '.agentkit-repo')
if (fs.existsSync(markerPath)) {
return fs.readFileSync(markerPath, 'utf-8').trim()
}
} catch { /* ignore */ }
return path.basename(root)
}

export function parseTeams(root: string): Team[] {
// Try YAML spec first
const yamlPath = path.join(root, '.agentkit', 'spec', 'AGENT_TEAMS.yaml')
if (fs.existsSync(yamlPath)) {
const teams = parseTeamsYaml(yamlPath)
if (teams.length > 0) return teams
// Try canonical spec path first, then legacy name, then overlay
const candidates = [
path.join(root, '.agentkit', 'spec', 'teams.yaml'), // canonical
path.join(root, '.agentkit', 'spec', 'AGENT_TEAMS.yaml'), // legacy
path.join(root, '.agentkit', 'overlays', detectRepoName(root), 'teams.yaml'), // overlay
]
for (const yamlPath of candidates) {
if (fs.existsSync(yamlPath)) {
const teams = parseTeamsYaml(yamlPath)
if (teams.length > 0) return teams
}
}

// Fallback: markdown table in AGENT_TEAMS.md
Expand Down
15 changes: 15 additions & 0 deletions packages/state-watcher/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type HostMessage =
| { type: 'backlog:updated'; items: BacklogItem[] }
| { type: 'teams:updated'; teams: Team[] }
| { type: 'sync:status'; state: 'idle' | 'running' | 'error' | 'success'; message?: string }
| { type: 'cogmesh:health:updated'; health: CognitiveMeshHealth }

export type ClientMessage =
| { type: 'ready' }
Expand Down Expand Up @@ -43,8 +44,22 @@ export interface AgentTask {
maxTurns?: number
createdAt: string
updatedAt: string
// Cognitive-mesh escalation — present when `retort run cogmesh` dispatched this task
escalatedTo?: string
workflowId?: string
workflowName?: string
workflowStage?: number
workflowTotalStages?: number
fallbackCli?: string
resultPath?: string
}

export type CognitiveMeshHealth =
| { status: 'connected'; latencyMs: number }
| { status: 'degraded'; latencyMs: number }
| { status: 'unreachable' }
| { status: 'unconfigured' }

export interface SessionState {
orchestratorId?: string
phase?: string
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AgentFleetPanel } from './panels/AgentFleetPanel'
import { BacklogPanel } from './panels/BacklogPanel'
import { TeamsPanel } from './panels/TeamsPanel'
import { HandoffFeedPanel } from './panels/HandoffFeedPanel'
import { CognitiveMeshPanel } from './panels/CognitiveMeshPanel'
import { OnboardingPanel } from './panels/OnboardingPanel'

interface Tab {
Expand All @@ -17,6 +18,7 @@ const TABS: Tab[] = [
{ id: 'backlog', label: 'Backlog' },
{ id: 'teams', label: 'Teams' },
{ id: 'handoff', label: 'Handoff' },
{ id: 'cogmesh', label: 'Mesh' },
]

function SyncIndicator() {
Expand All @@ -29,6 +31,17 @@ function SyncIndicator() {
)
}

function CogmeshDot() {
const health = useStore((s) => s.cogmeshHealth)
if (!health || health.status === 'unconfigured') return null
return (
<span
className={`cogmesh-dot cogmesh-dot--${health.status}`}
title={`Cognitive Mesh: ${health.status}${'latencyMs' in health ? ` (${health.latencyMs}ms)` : ''}`}
/>
)
}

export function App() {
const bridge = useBridge()
const activePanel = useStore((s) => s.activePanel)
Expand All @@ -47,6 +60,7 @@ export function App() {
case 'backlog': return <BacklogPanel />
case 'teams': return <TeamsPanel />
case 'handoff': return <HandoffFeedPanel />
case 'cogmesh': return <CognitiveMeshPanel />
default: return <AgentFleetPanel />
}
}
Expand All @@ -70,6 +84,7 @@ export function App() {
</nav>
<div className="header-status">
<SyncIndicator />
<CogmeshDot />
<span
className={`connection-dot${bridge.connected ? ' connection-dot--online' : ''}`}
title={bridge.connected ? 'Connected' : 'Disconnected'}
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/bridge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type HostMessage =
| { type: 'backlog:updated'; items: BacklogItem[] }
| { type: 'teams:updated'; teams: Team[] }
| { type: 'sync:status'; state: 'idle' | 'running' | 'error' | 'success'; message?: string }
| { type: 'cogmesh:health:updated'; health: CognitiveMeshHealth }

export type ClientMessage =
| { type: 'ready' }
Expand Down Expand Up @@ -43,8 +44,22 @@ export interface AgentTask {
maxTurns?: number
createdAt: string
updatedAt: string
// Cognitive-mesh escalation — present when `retort run cogmesh` dispatched this task
escalatedTo?: string
workflowId?: string
workflowName?: string
workflowStage?: number
workflowTotalStages?: number
fallbackCli?: string
resultPath?: string
}

export type CognitiveMeshHealth =
| { status: 'connected'; latencyMs: number }
| { status: 'degraded'; latencyMs: number }
| { status: 'unreachable' }
| { status: 'unconfigured' }

export interface SessionState {
orchestratorId?: string
phase?: string
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/bridge/useStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { AgentTask, BacklogItem, HostMessage, SessionState, Team } from './types'
import type { AgentTask, BacklogItem, CognitiveMeshHealth, HostMessage, SessionState, Team } from './types'

export type ActivePanel = 'fleet' | 'backlog' | 'teams' | 'handoff' | 'onboard'
export type ActivePanel = 'fleet' | 'backlog' | 'teams' | 'handoff' | 'cogmesh' | 'onboard'

interface SyncStatus {
state: 'idle' | 'running' | 'error' | 'success'
Expand All @@ -14,6 +14,7 @@ interface RetortStore {
tasks: AgentTask[]
session: SessionState | null
syncStatus: SyncStatus
cogmeshHealth: CognitiveMeshHealth | null
activePanel: ActivePanel

setActivePanel: (panel: ActivePanel) => void
Expand All @@ -28,6 +29,7 @@ export const useStore = create<RetortStore>((set) => ({
tasks: [],
session: null,
syncStatus: { state: 'idle' },
cogmeshHealth: null,
activePanel: 'fleet',

setActivePanel: (panel) => set({ activePanel: panel }),
Expand Down Expand Up @@ -65,6 +67,10 @@ export const useStore = create<RetortStore>((set) => ({
case 'sync:status':
set({ syncStatus: { state: msg.state, message: msg.message } })
break

case 'cogmesh:health:updated':
set({ cogmeshHealth: msg.health })
break
}
},
}))
58 changes: 58 additions & 0 deletions packages/ui/src/components/SystemHealthBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useStore } from '../bridge/useStore'
import type { CognitiveMeshHealth } from '../bridge/types'

interface ServiceIndicator {
label: string
status: 'ok' | 'warn' | 'error' | 'off'
detail?: string
}

function cogmeshIndicator(health: CognitiveMeshHealth | null): ServiceIndicator {
if (!health || health.status === 'unconfigured') {
return { label: 'CogMesh', status: 'off', detail: 'not configured' }
}
if (health.status === 'connected') {
return { label: 'CogMesh', status: 'ok', detail: `${health.latencyMs}ms` }
}
if (health.status === 'degraded') {
return { label: 'CogMesh', status: 'warn', detail: `degraded ${health.latencyMs}ms` }
}
return { label: 'CogMesh', status: 'error', detail: 'unreachable' }
}

function Pill({ indicator }: { indicator: ServiceIndicator }) {
return (
<span
className={`health-pill health-pill--${indicator.status}`}
title={indicator.detail}
>
<span className="health-pill-dot" />
{indicator.label}
{indicator.detail && <span className="health-pill-detail">{indicator.detail}</span>}
</span>
)
}

/**
* Compact service-health row shown at the top of the Fleet panel.
* Hidden entirely when all services are unconfigured (avoids noise on fresh installs).
*/
export function SystemHealthBar() {
const cogmeshHealth = useStore((s) => s.cogmeshHealth)

const indicators: ServiceIndicator[] = [
cogmeshIndicator(cogmeshHealth),
// Future: phoenix-flow, mcp-org, sluice, etc.
]

// Don't render the bar if every service is 'off' — no value shown
if (indicators.every((i) => i.status === 'off')) return null

return (
<div className="system-health-bar" role="status" aria-label="Service health">
{indicators.map((ind) => (
<Pill key={ind.label} indicator={ind} />
))}
</div>
)
}
Loading
Loading