Compare commits

...
16 Commits
Author SHA1 Message Date
David Kaya fa355197cb chore: release v0.0.29 2026-04-16 14:48:09 +02:00
David KayaandCopilot 5f263002f0 fix: pass mainWindow argument to showAndFocusWindow in second-instance handler
The showAndFocusWindow function signature was updated to require a
mainWindow parameter, but the call site in the second-instance handler
was not updated, causing a TypeScript compilation error that broke CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:43:41 +02:00
David Kaya 1376de4148 chore: release v0.0.28 2026-04-16 14:17:21 +02:00
David Kaya 5ce6bb4b0a Merge branch 'agents/tray-icon-main-window-fix' 2026-04-16 14:16:33 +02:00
David KayaandCopilot 47d3fb0a29 fix: tray icon click opens main window reliably after quick prompt use
showAndFocusWindow() used BrowserWindow.getAllWindows()[0] to find the
main window, which breaks once the quick prompt window is created —
the array order is not guaranteed and windows[0] may resolve to the
quick prompt instead, causing the show/focus calls to target a hidden,
frameless window.

Accept the main window reference as an explicit parameter and guard
against destroyed windows with isDestroyed().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:00:30 +02:00
David Kaya 4da31cbdd6 Merge branch 'agents/single-instance-app-launch' 2026-04-16 13:56:19 +02:00
David KayaandCopilot f15323e37e feat: enforce single-instance app launch
Use Electron's requestSingleInstanceLock() to prevent opening
multiple instances. When a second instance is attempted, the
existing window is restored and focused instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 13:54:22 +02:00
David KayaandCopilot f8fe9d866e fix: use platform-native paths in release tests for cross-OS compatibility
The release workflow tests hardcoded Windows-style backslash paths
(C:\workspace\...) which caused path.relative() to produce incorrect
results on Linux and macOS, where backslashes are not path separators.

Replace hardcoded paths with resolve()/join() calls that produce
platform-native paths, fixing CI failures on Linux and macOS.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 13:53:49 +02:00
David Kaya 6c24754749 chore: release v0.0.27 2026-04-16 12:57:19 +02:00
David Kaya 2a7c627008 Merge branch 'agents/dotnet-aspire-opentelemetry-explorer' 2026-04-16 11:57:06 +02:00
David KayaandCopilot 3a3e1d5eab fix: correct OTEL instrumentation wiring for agent traces
- Fix ActivitySource name from 'Microsoft.Agents.AI' to
  'Experimental.Microsoft.Agents.AI' matching the Agent Framework default
- Wrap agents with UseOpenTelemetry() via AIAgentBuilder so agent
  invocation spans are emitted to the configured OTLP collector
- Add proper disposal of OpenTelemetryAgent wrappers via
  SyncDisposableAdapter bridging IDisposable to IAsyncDisposable
- Enable workflow-level telemetry on WorkflowRunner custom graph builds
  via WithOpenTelemetry() (HandoffWorkflowBuilder and
  GroupChatWorkflowBuilder do not expose this API — framework limitation)
- Fix settings panel help text to be user-facing instead of dev jargon
- Update test assertion for corrected ActivitySource name

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 11:52:13 +02:00
David Kaya 9a9332369b Merge branch 'agents/dotnet-aspire-opentelemetry-explorer' 2026-04-16 11:25:51 +02:00
David KayaandCopilot 67fb950e61 feat: add OpenTelemetry settings UI and sidecar environment wiring
Add a global Telemetry section in the settings panel with:
- Toggle to enable/disable OTLP export
- Text input for the OTLP endpoint URL (default: http://localhost:4317)
- Guidance note about using \un run aspire\ for local testing

Wire the setting through the full stack:
- OpenTelemetrySettings type in shared domain with normalization
- IPC channel, preload binding, and handler for persistence
- SidecarClient forwards settings to createSidecarEnvironment
- Sidecar environment injects OTEL_EXPORTER_OTLP_ENDPOINT when enabled
- Settings loaded from workspace.json on startup and on change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 11:10:23 +02:00
David KayaandCopilot f7d376fc41 feat: add Aspire dashboard OTEL workflow
Add OTLP export wiring for the sidecar and dev scripts to launch the standalone Aspire Dashboard during development.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 10:39:36 +02:00
David KayaandCopilot 59ea028a24 feat: add release helper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 09:30:17 +02:00
David KayaandCopilot c7910e6e4b fix: remove streaming text boundary heuristics that injected spurious spaces
The StreamingTextMerger (C#) and mergeStreamingText (TypeScript) contained
custom word-boundary heuristics that inserted spaces between streaming deltas
based on incorrect assumptions. Two bugs caused mid-word spaces:

1. Unconditional space insertion when incoming delta started with uppercase or
   digit characters, breaking acronyms (MDA -> M DA, UAG -> UA G)
2. LooksLikeWordBoundary using global token counts that fired on nearly every
   mid-word token split (writable -> wr itable, fragile -> frag ile)

Both upstream projects (Copilot SDK and Agent Framework) use simple string
concatenation for delta accumulation. LLM streaming tokens natively include
whitespace when needed, so no boundary detection is required.

Removed all boundary heuristic methods and replaced with plain concatenation.
Kept overlap detection and snapshot handling for event redelivery scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 18:55:34 +02:00
33 changed files with 1458 additions and 187 deletions
+5 -3
View File
@@ -37,6 +37,7 @@ flowchart LR
Git[Local git repositories]
Sidecar[.NET sidecar]
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
Telemetry[OTLP collector<br/>Aspire Dashboard]
OS[Native windowing<br/>and desktop integration]
User --> Renderer
@@ -46,6 +47,7 @@ flowchart LR
Main <--> Git
Main <--> Sidecar
Sidecar <--> Copilot
Sidecar --> Telemetry
Main <--> OS
```
@@ -56,8 +58,8 @@ flowchart LR
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, optional OTLP trace export, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio, OTLP to external collectors |
| External systems | Git data, Copilot account/model access, telemetry collectors, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
@@ -68,7 +70,7 @@ Aryx runs as a multi-process desktop application:
1. The **renderer** displays the workspace and captures user intent.
2. The **preload bridge** exposes a small, typed API into the browser context.
3. The **main process** validates and mutates application state, persists it, and manages native integrations.
4. The **sidecar** executes Copilot-backed turns and streams structured execution events back.
4. The **sidecar** executes Copilot-backed turns, can export OpenTelemetry traces to an external collector, and streams structured execution events back.
The sidecar is intentionally separate from the Electron main process so that AI runtime concerns stay isolated from UI and persistence concerns.
+9
View File
@@ -92,12 +92,21 @@ bun run test # typecheck + unit tests
bun run sidecar:test # backend tests
bun run build # full build (electron + sidecar)
bun run aspire # start the standalone Aspire Dashboard in Docker
bun run dev:otel # start Aspire + Aryx dev with OTLP export enabled
bun run package # package for current platform → release/
bun run installer # create installable artifact
bun run publish-release # publish to GitHub Releases
bun run release # bump patch version, commit, tag, and push
bun run release minor # bump minor version instead (use major for breaking releases)
```
For local observability, `bun run dev:otel` starts the Aspire Dashboard UI at `http://localhost:18888`
and configures the sidecar to export OpenTelemetry traces to `http://localhost:4317` over OTLP/gRPC.
This dev workflow requires Docker.
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags.
## Trademarks
+4 -1
View File
@@ -1,16 +1,19 @@
{
"name": "aryx",
"version": "0.0.26",
"version": "0.0.29",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:otel": "bun run scripts/dev-with-otel.ts",
"aspire": "bun run scripts/launch-aspire-dashboard.ts",
"build:electron": "electron-vite build",
"build": "bun run build:electron && bun run sidecar:build",
"package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never",
"installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never",
"publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always",
"release": "bun run scripts/release.ts",
"preview": "electron-vite preview",
"lsp:typescript": "typescript-language-server --stdio",
"typecheck": "tsc --noEmit -p tsconfig.json",
+233
View File
@@ -0,0 +1,233 @@
import { spawn } from 'node:child_process';
import net from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
export const aspireDashboardContainerName = 'aryx-aspire-dashboard';
export const aspireDashboardImage = 'mcr.microsoft.com/dotnet/aspire-dashboard:latest';
export const aspireDashboardUrls = {
dashboard: 'http://localhost:18888',
otlpGrpc: 'http://localhost:4317',
otlpHttp: 'http://localhost:4318',
} as const;
type ContainerState = 'missing' | 'running' | 'stopped';
export interface AspireDashboardHandle {
readonly dashboardUrl: string;
readonly otlpGrpcEndpoint: string;
readonly otlpHttpEndpoint: string;
readonly startedByScript: boolean;
}
export function createAspireDashboardRunArgs(
containerName = aspireDashboardContainerName,
image = aspireDashboardImage,
): string[] {
return [
'run',
'--rm',
'-d',
'--name',
containerName,
'-p',
'18888:18888',
'-p',
'4317:18889',
'-p',
'4318:18890',
'-e',
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
image,
];
}
export function createOpenTelemetryEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
endpoint: string = aspireDashboardUrls.otlpGrpc,
): NodeJS.ProcessEnv {
return {
...baseEnvironment,
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
};
}
export async function startAspireDashboard(): Promise<AspireDashboardHandle> {
await ensureDockerAvailable();
let startedByScript = false;
const containerState = await getContainerState(aspireDashboardContainerName);
if (containerState !== 'running') {
if (containerState === 'stopped') {
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
}
await runCommand('docker', createAspireDashboardRunArgs(), 'pipe');
startedByScript = true;
}
await waitForTcpPort(18888);
await waitForTcpPort(4317);
return {
dashboardUrl: aspireDashboardUrls.dashboard,
otlpGrpcEndpoint: aspireDashboardUrls.otlpGrpc,
otlpHttpEndpoint: aspireDashboardUrls.otlpHttp,
startedByScript,
};
}
export async function stopAspireDashboard(): Promise<void> {
const containerState = await getContainerState(aspireDashboardContainerName);
if (containerState === 'missing') {
return;
}
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
}
async function ensureDockerAvailable(): Promise<void> {
const cliVersion = await runCommandCapture('docker', ['--version']);
if (cliVersion.exitCode !== 0) {
throw new Error(
'Docker is required to run the standalone Aspire Dashboard. Install Docker Desktop and ensure `docker` is available on PATH.',
);
}
if (await isDockerDaemonAvailable()) {
return;
}
if (await hasDockerDesktopCli()) {
console.log('Docker Desktop is not running. Starting it now...');
await runCommand('docker', ['desktop', 'start', '--detach'], 'pipe');
await waitForDockerDaemon();
return;
}
throw new Error(
'Docker is installed but the daemon is not running. Start Docker Desktop and rerun the Aspire command.',
);
}
async function getContainerState(containerName: string): Promise<ContainerState> {
const result = await runCommandCapture('docker', [
'container',
'inspect',
'--format',
'{{.State.Status}}',
containerName,
]);
if (result.exitCode !== 0) {
return 'missing';
}
return result.stdout.trim() === 'running' ? 'running' : 'stopped';
}
async function waitForTcpPort(port: number, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await canConnect(port)) {
return;
}
await delay(250);
}
throw new Error(`Timed out waiting for localhost:${port} to accept connections.`);
}
async function isDockerDaemonAvailable(): Promise<boolean> {
const result = await runCommandCapture('docker', ['info', '--format', '{{.ServerVersion}}']);
return result.exitCode === 0;
}
async function hasDockerDesktopCli(): Promise<boolean> {
const result = await runCommandCapture('docker', ['desktop', 'version']);
return result.exitCode === 0;
}
async function waitForDockerDaemon(timeoutMs = 120_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isDockerDaemonAvailable()) {
return;
}
await delay(1_000);
}
throw new Error('Timed out waiting for Docker Desktop to start and accept API connections.');
}
async function canConnect(port: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
const finalize = (connected: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(connected);
};
socket.once('connect', () => finalize(true));
socket.once('error', () => finalize(false));
});
}
async function runCommand(command: string, args: string[], stdio: 'inherit' | 'pipe'): Promise<void> {
const result = await spawnProcess(command, args, stdio);
if (result.exitCode === 0) {
return;
}
if (result.stderr.trim().length > 0) {
throw new Error(result.stderr.trim());
}
throw new Error(`${command} exited with code ${result.exitCode}.`);
}
async function runCommandCapture(command: string, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return spawnProcess(command, args, 'pipe');
}
async function spawnProcess(
command: string,
args: string[],
stdio: 'inherit' | 'pipe',
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
if (stdio === 'pipe') {
child.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
}
child.on('error', reject);
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`${command} exited because of signal ${signal}.`));
return;
}
resolve({
exitCode: code ?? 1,
stdout,
stderr,
});
});
});
}
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createOpenTelemetryEnvironment,
startAspireDashboard,
stopAspireDashboard,
} from './aspireDashboard';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const signalExitCodes: Partial<Record<NodeJS.Signals, number>> = {
SIGINT: 130,
SIGTERM: 143,
};
const dashboard = await startAspireDashboard();
console.log(`Aspire Dashboard: ${dashboard.dashboardUrl}`);
console.log(`Aryx sidecar OTLP endpoint: ${dashboard.otlpGrpcEndpoint}`);
const child = spawn(process.execPath, ['run', 'dev'], {
cwd: repositoryRoot,
env: createOpenTelemetryEnvironment(process.env, dashboard.otlpGrpcEndpoint),
stdio: 'inherit',
windowsHide: true,
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill(signal);
}
});
}
let exitResult: { code: number | null; signal: NodeJS.Signals | null };
try {
exitResult = await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', (code, signal) => {
resolve({ code, signal });
});
});
} finally {
if (dashboard.startedByScript) {
await stopAspireDashboard();
}
}
if (exitResult.signal) {
process.exitCode = signalExitCodes[exitResult.signal] ?? 1;
} else {
process.exitCode = exitResult.code ?? 1;
}
+9
View File
@@ -0,0 +1,9 @@
import { startAspireDashboard } from './aspireDashboard';
const dashboard = await startAspireDashboard();
console.log(
`${dashboard.startedByScript ? 'Started' : 'Reusing'} Aspire Dashboard at ${dashboard.dashboardUrl}`,
);
console.log(`OTLP/gRPC receiver: ${dashboard.otlpGrpcEndpoint}`);
console.log(`OTLP/HTTP receiver: ${dashboard.otlpHttpEndpoint}`);
+313
View File
@@ -0,0 +1,313 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const packageJsonPath = resolve(repositoryRoot, 'package.json');
export type ReleaseBump = 'patch' | 'minor' | 'major';
interface PackageJsonShape {
version?: unknown;
[key: string]: unknown;
}
export interface GitCommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export type GitRunner = (args: string[]) => Promise<GitCommandResult>;
export interface ReleaseDependencies {
readText(path: string): Promise<string>;
writeText(path: string, content: string): Promise<void>;
runGit: GitRunner;
log(message: string): void;
}
export interface ReleaseWorkflowOptions {
repositoryRoot: string;
packageJsonPath: string;
bumpArg?: string;
}
export interface ReleaseResult {
bump: ReleaseBump;
currentVersion: string;
nextVersion: string;
tagName: string;
localBranch: string;
remote: string;
upstreamBranch: string;
}
interface UpstreamTarget {
remote: string;
branch: string;
}
function createGitRunner(cwd: string): GitRunner {
return async (args) =>
new Promise((resolvePromise, rejectPromise) => {
const child = spawn('git', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.on('error', rejectPromise);
child.on('close', (code, signal) => {
if (signal) {
rejectPromise(new Error(`git ${args.join(' ')} exited because of signal ${signal}.`));
return;
}
resolvePromise({
exitCode: code ?? 1,
stdout,
stderr,
});
});
});
}
function createReleaseDependencies(cwd: string): ReleaseDependencies {
return {
readText: (path) => readFile(path, 'utf8'),
writeText: (path, content) => writeFile(path, content, 'utf8'),
runGit: createGitRunner(cwd),
log: console.log,
};
}
function parsePackageJson(packageJsonText: string): PackageJsonShape {
try {
return JSON.parse(packageJsonText) as PackageJsonShape;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not parse package.json: ${message}`);
}
}
export function parseReleaseBump(value?: string): ReleaseBump {
if (value === undefined) {
return 'patch';
}
if (value === 'patch' || value === 'minor' || value === 'major') {
return value;
}
throw new Error(`Unsupported release bump "${value}". Use patch, minor, or major.`);
}
export function readPackageVersion(packageJsonText: string): string {
const packageJson = parsePackageJson(packageJsonText);
if (typeof packageJson.version !== 'string') {
throw new Error('package.json is missing a string version field.');
}
return packageJson.version;
}
function parseSemver(version: string): [number, number, number] {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
if (!match) {
throw new Error(`Unsupported version "${version}". Expected a simple x.y.z semantic version.`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function incrementVersion(version: string, bump: ReleaseBump): string {
const [major, minor, patch] = parseSemver(version);
switch (bump) {
case 'major':
return `${major + 1}.0.0`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'patch':
return `${major}.${minor}.${patch + 1}`;
}
}
function detectNewline(packageJsonText: string): '\r\n' | '\n' {
return packageJsonText.includes('\r\n') ? '\r\n' : '\n';
}
export function updatePackageJsonVersion(packageJsonText: string, nextVersion: string): string {
const newline = detectNewline(packageJsonText);
const packageJson = parsePackageJson(packageJsonText);
if (typeof packageJson.version !== 'string') {
throw new Error('package.json is missing a string version field.');
}
packageJson.version = nextVersion;
return `${JSON.stringify(packageJson, null, 2).replace(/\n/g, newline)}${newline}`;
}
function formatGitFailure(args: string[], result: GitCommandResult): string {
const detail = result.stderr.trim() || result.stdout.trim();
if (detail.length > 0) {
return `git ${args.join(' ')} failed: ${detail}`;
}
return `git ${args.join(' ')} failed with exit code ${result.exitCode}.`;
}
async function runGitOrThrow(runGit: GitRunner, args: string[], context: string): Promise<GitCommandResult> {
const result = await runGit(args);
if (result.exitCode !== 0) {
throw new Error(`${context} ${formatGitFailure(args, result)}`);
}
return result;
}
async function ensureCleanWorktree(runGit: GitRunner): Promise<void> {
const result = await runGitOrThrow(runGit, ['status', '--porcelain'], 'Could not inspect the current git worktree.');
if (result.stdout.trim().length > 0) {
throw new Error('Release requires a clean git worktree. Commit, stash, or discard changes first.');
}
}
async function resolveLocalBranch(runGit: GitRunner): Promise<string> {
const result = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
if (result.exitCode !== 0) {
throw new Error('Release requires a checked-out branch. Detached HEAD is not supported.');
}
const branch = result.stdout.trim();
if (branch.length === 0) {
throw new Error('Release could not determine the current branch name.');
}
return branch;
}
function normalizeUpstreamBranch(mergeRef: string): string {
const prefix = 'refs/heads/';
if (!mergeRef.startsWith(prefix)) {
throw new Error(`Release expected an upstream branch ref, received "${mergeRef}".`);
}
return mergeRef.slice(prefix.length);
}
async function resolveUpstreamTarget(runGit: GitRunner, localBranch: string): Promise<UpstreamTarget> {
const remoteResult = await runGit(['config', '--get', `branch.${localBranch}.remote`]);
const mergeResult = await runGit(['config', '--get', `branch.${localBranch}.merge`]);
if (remoteResult.exitCode !== 0 || mergeResult.exitCode !== 0) {
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
}
const remote = remoteResult.stdout.trim();
const mergeRef = mergeResult.stdout.trim();
if (remote.length === 0 || mergeRef.length === 0) {
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
}
return {
remote,
branch: normalizeUpstreamBranch(mergeRef),
};
}
async function ensureTagDoesNotExist(runGit: GitRunner, tagName: string): Promise<void> {
const result = await runGitOrThrow(runGit, ['tag', '--list', tagName], 'Could not inspect existing git tags.');
if (result.stdout.trim().length > 0) {
throw new Error(`Tag ${tagName} already exists. Choose a different release version.`);
}
}
export async function runReleaseWorkflow(
options: ReleaseWorkflowOptions,
dependencies: ReleaseDependencies,
): Promise<ReleaseResult> {
const bump = parseReleaseBump(options.bumpArg);
const packageJsonText = await dependencies.readText(options.packageJsonPath);
const currentVersion = readPackageVersion(packageJsonText);
const nextVersion = incrementVersion(currentVersion, bump);
const tagName = `v${nextVersion}`;
const packageJsonFile = relative(options.repositoryRoot, options.packageJsonPath);
await ensureCleanWorktree(dependencies.runGit);
const localBranch = await resolveLocalBranch(dependencies.runGit);
const upstreamTarget = await resolveUpstreamTarget(dependencies.runGit, localBranch);
await ensureTagDoesNotExist(dependencies.runGit, tagName);
const updatedPackageJson = updatePackageJsonVersion(packageJsonText, nextVersion);
const commitMessage = `chore: release ${tagName}`;
const tagMessage = `Release ${tagName}`;
await dependencies.writeText(options.packageJsonPath, updatedPackageJson);
await runGitOrThrow(dependencies.runGit, ['add', packageJsonFile], `Could not stage ${packageJsonFile}.`);
await runGitOrThrow(dependencies.runGit, ['commit', '-m', commitMessage], 'Could not create the release commit.');
await runGitOrThrow(dependencies.runGit, ['tag', '-a', tagName, '-m', tagMessage], 'Could not create the annotated release tag.');
await runGitOrThrow(
dependencies.runGit,
['push', '--follow-tags', upstreamTarget.remote, `HEAD:${upstreamTarget.branch}`],
'Could not push the release commit and tag.',
);
dependencies.log(`Released ${tagName} from ${localBranch} to ${upstreamTarget.remote}/${upstreamTarget.branch}.`);
return {
bump,
currentVersion,
nextVersion,
tagName,
localBranch,
remote: upstreamTarget.remote,
upstreamBranch: upstreamTarget.branch,
};
}
export async function main(argv: string[]): Promise<void> {
await runReleaseWorkflow(
{
repositoryRoot,
packageJsonPath,
bumpArg: argv[0],
},
createReleaseDependencies(repositoryRoot),
);
}
if (import.meta.main) {
await main(process.argv.slice(2)).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exitCode = 1;
});
}
@@ -13,6 +13,8 @@
<PackageReference Include="Microsoft.Agents.AI" Version="1.1.0" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.1.0-preview.260410.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.1.0" />
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
</ItemGroup>
</Project>
+3
View File
@@ -1,4 +1,5 @@
using Aryx.AgentHost.Services;
using OpenTelemetry.Trace;
if (!args.Contains("--stdio", StringComparer.Ordinal))
{
@@ -6,5 +7,7 @@ if (!args.Contains("--stdio", StringComparer.Ordinal))
return;
}
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProviderFromEnvironment();
SidecarProtocolHost host = new();
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
@@ -0,0 +1,137 @@
using System.Collections;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Aryx.AgentHost.Services;
internal static class OpenTelemetrySetup
{
private const string ServiceName = "Aryx.AgentHost";
private const string EndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT";
private const string ProtocolEnvironmentVariableName = "OTEL_EXPORTER_OTLP_PROTOCOL";
private static readonly string[] ActivitySourceNames =
[
"Experimental.Microsoft.Agents.AI",
"Microsoft.Agents.AI.Workflows",
];
public static TracerProvider? CreateTracerProviderFromEnvironment(TextWriter? diagnosticsWriter = null)
{
return CreateTracerProvider(ReadEnvironmentVariables(), diagnosticsWriter ?? Console.Error);
}
internal static TracerProvider? CreateTracerProvider(
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
TextWriter diagnosticsWriter)
{
ArgumentNullException.ThrowIfNull(environmentVariables);
ArgumentNullException.ThrowIfNull(diagnosticsWriter);
OpenTelemetryTracingConfiguration? configuration = ResolveTracingConfiguration(environmentVariables);
if (configuration is null)
{
return null;
}
diagnosticsWriter.WriteLine(
$"Aryx.AgentHost OpenTelemetry tracing enabled ({configuration.ProtocolLabel}) -> {configuration.Endpoint}.");
return Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(
ResourceBuilder.CreateDefault()
.AddService(ServiceName, serviceVersion: ResolveServiceVersion()))
.AddSource(configuration.ActivitySourceNames.ToArray())
.AddOtlpExporter(options =>
{
options.Endpoint = configuration.Endpoint;
options.Protocol = configuration.Protocol;
})
.Build();
}
internal static OpenTelemetryTracingConfiguration? ResolveTracingConfiguration(
IEnumerable<KeyValuePair<string, string?>> environmentVariables)
{
ArgumentNullException.ThrowIfNull(environmentVariables);
string? endpointValue = ReadSetting(environmentVariables, EndpointEnvironmentVariableName);
if (string.IsNullOrWhiteSpace(endpointValue))
{
return null;
}
if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out Uri? endpoint)
|| (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"{EndpointEnvironmentVariableName} must be an absolute http or https URL. Received '{endpointValue}'.");
}
OtlpExportProtocol protocol = ResolveProtocol(ReadSetting(environmentVariables, ProtocolEnvironmentVariableName));
return new OpenTelemetryTracingConfiguration(endpoint, protocol, ActivitySourceNames);
}
private static string? ResolveServiceVersion()
{
return typeof(OpenTelemetrySetup).Assembly.GetName().Version?.ToString();
}
private static IEnumerable<KeyValuePair<string, string?>> ReadEnvironmentVariables()
{
return Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.Select(entry => new KeyValuePair<string, string?>(
entry.Key?.ToString() ?? string.Empty,
entry.Value?.ToString()));
}
private static string? ReadSetting(
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
string name)
{
foreach (KeyValuePair<string, string?> entry in environmentVariables)
{
if (!string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
{
continue;
}
return string.IsNullOrWhiteSpace(entry.Value)
? null
: entry.Value.Trim();
}
return null;
}
private static OtlpExportProtocol ResolveProtocol(string? protocolValue)
{
if (string.IsNullOrWhiteSpace(protocolValue))
{
return OtlpExportProtocol.Grpc;
}
return protocolValue.Trim().ToLowerInvariant() switch
{
"grpc" => OtlpExportProtocol.Grpc,
"http/protobuf" => OtlpExportProtocol.HttpProtobuf,
_ => throw new InvalidOperationException(
$"{ProtocolEnvironmentVariableName} must be 'grpc' or 'http/protobuf'. Received '{protocolValue}'."),
};
}
}
internal sealed record OpenTelemetryTracingConfiguration(
Uri Endpoint,
OtlpExportProtocol Protocol,
IReadOnlyList<string> ActivitySourceNames)
{
public string ProtocolLabel => Protocol switch
{
OtlpExportProtocol.HttpProtobuf => "http/protobuf",
_ => "grpc",
};
}
@@ -87,8 +87,13 @@ internal sealed class CopilotAgentBundle : ProviderAgentBundle
name: definition.GetAgentName(),
description: NormalizeOptionalString(definition.Config.Description));
agents.Add(agent);
AIAgent instrumentedAgent = new AIAgentBuilder(agent).UseOpenTelemetry().Build();
agents.Add(instrumentedAgent);
disposables.Add(agent);
if (instrumentedAgent is IDisposable instrumentedDisposable)
{
disposables.Add(new SyncDisposableAdapter(instrumentedDisposable));
}
}
// The bundle owns the shared client — disposed after all agents.
@@ -328,4 +333,17 @@ internal sealed class CopilotAgentBundle : ProviderAgentBundle
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// Adapts a synchronous <see cref="IDisposable"/> to <see cref="IAsyncDisposable"/>
/// so it can be tracked in the async disposal pipeline.
/// </summary>
private sealed class SyncDisposableAdapter(IDisposable inner) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
inner.Dispose();
return ValueTask.CompletedTask;
}
}
}
@@ -1,5 +1,3 @@
using System.Text.RegularExpressions;
namespace Aryx.AgentHost.Services;
internal static partial class StreamingTextMerger
@@ -7,7 +5,6 @@ internal static partial class StreamingTextMerger
private const double SnapshotReplacementMinLengthRatio = 0.6;
private const int SnapshotReplacementMinTokenCount = 3;
private const double SnapshotReplacementSharedTokenRatio = 0.5;
private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`";
public static string Merge(string current, string incoming)
{
@@ -32,51 +29,7 @@ internal static partial class StreamingTextMerger
return incoming;
}
return current + ResolveBoundarySeparator(current, incoming) + incoming;
}
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
{
if (incoming.StartsWith(current, StringComparison.Ordinal)
|| incoming.Contains(current, StringComparison.Ordinal))
{
merged = incoming;
return true;
}
if (current.Contains(incoming, StringComparison.Ordinal))
{
merged = current;
return true;
}
merged = string.Empty;
return false;
}
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
{
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
if (overlapLength == 0)
{
merged = string.Empty;
return false;
}
merged = current + incoming[overlapLength..];
return true;
}
private static string ResolveBoundarySeparator(string current, string incoming)
{
if (ShouldInsertNewlineBoundary(current, incoming))
{
return "\n";
}
return ShouldInsertSpaceBoundary(current, incoming)
? " "
: string.Empty;
return current + incoming;
}
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
@@ -125,54 +78,6 @@ internal static partial class StreamingTextMerger
&& incomingTokens.Count >= SnapshotReplacementMinTokenCount;
}
private static bool ShouldInsertNewlineBoundary(string current, string incoming)
{
return !current.EndsWith('\n')
&& MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
}
private static bool ShouldInsertSpaceBoundary(string current, string incoming)
{
char lastCharacter = current[^1];
char firstCharacter = incoming[0];
if (HasExistingBoundary(lastCharacter, firstCharacter)
|| CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
{
return false;
}
if (ClosingPunctuationRegex().IsMatch(incoming))
{
return false;
}
return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|| LooksLikeWordBoundary(current, incoming);
}
private static bool HasExistingBoundary(char lastCharacter, char firstCharacter)
{
return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter);
}
private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming)
{
return MarkdownInlinePrefixRegex().IsMatch(incoming)
|| char.IsUpper(firstCharacter)
|| char.IsDigit(firstCharacter);
}
private static bool LooksLikeWordBoundary(string current, string incoming)
{
string[] currentTokens = Tokenize(current).ToArray();
string[] incomingTokens = Tokenize(incoming).ToArray();
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
return currentTokens.Length >= 2
&& incomingTokens.Length >= 2
&& firstIncomingToken.Length >= 2;
}
private static IEnumerable<string> Tokenize(string value)
{
return TokenRegex()
@@ -181,15 +86,38 @@ internal static partial class StreamingTextMerger
.Where(token => token.Length > 0);
}
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
private static partial Regex TokenRegex();
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
{
if (incoming.StartsWith(current, StringComparison.Ordinal)
|| incoming.Contains(current, StringComparison.Ordinal))
{
merged = incoming;
return true;
}
[GeneratedRegex(@"^[.,!?;:%)\]}]")]
private static partial Regex ClosingPunctuationRegex();
if (current.Contains(incoming, StringComparison.Ordinal))
{
merged = current;
return true;
}
[GeneratedRegex(@"^[*_`~\[]")]
private static partial Regex MarkdownInlinePrefixRegex();
merged = string.Empty;
return false;
}
[GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)]
private static partial Regex MarkdownBlockPrefixRegex();
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
{
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
if (overlapLength == 0)
{
merged = string.Empty;
return false;
}
merged = current + incoming[overlapLength..];
return true;
}
[System.Text.RegularExpressions.GeneratedRegex("[a-z0-9]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex TokenRegex();
}
@@ -112,7 +112,7 @@ internal sealed class WorkflowRunner
builder = builder.WithName(workflowDefinition.Name);
}
return builder.WithOutputFrom(routes[endNode.Id].Exit).Build();
return builder.WithOutputFrom(routes[endNode.Id].Exit).WithOpenTelemetry().Build();
}
private WorkflowNodeRoute CreateNodeRoute(
@@ -0,0 +1,87 @@
using Aryx.AgentHost.Services;
using OpenTelemetry.Exporter;
using OpenTelemetry.Trace;
namespace Aryx.AgentHost.Tests;
public sealed class OpenTelemetrySetupTests
{
[Fact]
public void ResolveTracingConfiguration_ReturnsNullWhenEndpointMissing()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration([]);
Assert.Null(configuration);
}
[Fact]
public void ResolveTracingConfiguration_UsesGrpcByDefault()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
]);
Assert.NotNull(configuration);
Assert.Equal(new Uri("http://localhost:4317"), configuration.Endpoint);
Assert.Equal(OtlpExportProtocol.Grpc, configuration.Protocol);
Assert.Collection(
configuration.ActivitySourceNames,
source => Assert.Equal("Experimental.Microsoft.Agents.AI", source),
source => Assert.Equal("Microsoft.Agents.AI.Workflows", source));
}
[Fact]
public void ResolveTracingConfiguration_UsesHttpProtobufWhenRequested()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
]);
Assert.NotNull(configuration);
Assert.Equal(new Uri("http://localhost:4318"), configuration.Endpoint);
Assert.Equal(OtlpExportProtocol.HttpProtobuf, configuration.Protocol);
}
[Fact]
public void ResolveTracingConfiguration_ThrowsWhenEndpointInvalid()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"),
]));
Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", error.Message, StringComparison.Ordinal);
}
[Fact]
public void ResolveTracingConfiguration_ThrowsWhenProtocolUnsupported()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"),
]));
Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL", error.Message, StringComparison.Ordinal);
}
[Fact]
public void CreateTracerProvider_CreatesProviderAndWritesDiagnosticsWhenConfigured()
{
StringWriter diagnosticsWriter = new();
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProvider(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
],
diagnosticsWriter);
Assert.NotNull(tracerProvider);
Assert.Contains("Aryx.AgentHost OpenTelemetry tracing enabled", diagnosticsWriter.ToString(), StringComparison.Ordinal);
}
}
@@ -37,22 +37,36 @@ public sealed class StreamingTextMergerTests
Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming));
}
[Fact]
public void Merge_InsertsWhitespaceWhenSnapshotLikeUpdatesWouldOtherwiseGlueWordsTogether()
[Theory]
[InlineData("requires all wr", "itable fields", "requires all writable fields")]
[InlineData("becomes frag", "ile for clients", "becomes fragile for clients")]
[InlineData("Endpoint (domain) uniqu", "eness across tenants", "Endpoint (domain) uniqueness across tenants")]
[InlineData("The doc says \"wildc", "ards are allowed\"", "The doc says \"wildcards are allowed\"")]
[InlineData("What wildcard syntax supported (*.cont", "oso.com? contoso.* ?)", "What wildcard syntax supported (*.contoso.com? contoso.* ?)")]
[InlineData("How does Pur", "view match traffic", "How does Purview match traffic")]
[InlineData("more M", "DA properties", "more MDA properties")]
[InlineData("does UA", "G normalize them?", "does UAG normalize them?")]
public void Merge_DoesNotInjectSpacesIntoSplitWords(string current, string incoming, string expected)
{
Assert.Equal(
"How about The **Ashen Crown** feels",
StreamingTextMerger.Merge("How about", "The **Ashen Crown** feels"));
Assert.Equal(
"The **Ashen Crown** feels classic and timeless.",
StreamingTextMerger.Merge("The **Ashen Crown** feels", "classic and timeless."));
Assert.Equal(expected, StreamingTextMerger.Merge(current, incoming));
}
[Fact]
public void Merge_InsertsNewlineBeforeStreamedMarkdownBlockMarkers()
public void Merge_PreservesWhitespaceAlreadyPresentInDelta()
{
Assert.Equal(
"How about The **Ashen Crown** feels",
StreamingTextMerger.Merge("How about", " The **Ashen Crown** feels"));
Assert.Equal(
"The **Ashen Crown** feels classic and timeless.",
StreamingTextMerger.Merge("The **Ashen Crown** feels", " classic and timeless."));
}
[Fact]
public void Merge_PreservesNewlineAlreadyPresentInDelta()
{
Assert.Equal(
"If you want, I can also give you\n- darker titles",
StreamingTextMerger.Merge("If you want, I can also give you", "- darker titles"));
StreamingTextMerger.Merge("If you want, I can also give you", "\n- darker titles"));
}
}
+9
View File
@@ -140,6 +140,7 @@ import {
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type OpenTelemetrySettings,
type QuickPromptSettings,
type SessionToolingSelection,
type WorkspaceToolingSettings,
@@ -469,6 +470,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
async loadWorkspace(): Promise<WorkspaceState> {
if (!this.workspace) {
this.workspace = await this.workspaceRepository.load();
this.sidecar.setOpenTelemetrySettings(this.workspace.settings.openTelemetry);
const selectedProjectId = this.workspace.selectedProjectId;
const selectedProject = selectedProjectId
? this.workspace.projects.find((project) => project.id === selectedProjectId)
@@ -830,6 +832,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setOpenTelemetry(settings: OpenTelemetrySettings): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.settings.openTelemetry = settings;
this.sidecar.setOpenTelemetrySettings(settings);
return this.persistAndBroadcast(workspace);
}
getQuickPromptSettings(): QuickPromptSettings {
return this.workspace?.settings.quickPrompt ?? createDefaultQuickPromptSettings();
}
+16 -4
View File
@@ -16,6 +16,12 @@ import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
const { app, BrowserWindow } = electron;
// Enforce single instance — quit immediately if another instance already holds the lock.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
}
let mainWindow: BrowserWindowType | undefined;
let quickPromptWindow: BrowserWindowType | undefined;
let appService: AryxAppService | undefined;
@@ -63,9 +69,9 @@ async function bootstrap(): Promise<void> {
applyTitleBarTheme(mainWindow, workspace.settings.theme);
systemTray = new SystemTray({
onShowWindow: showAndFocusWindow,
onShowWindow: () => showAndFocusWindow(mainWindow!),
onCreateScratchpad: () => {
showAndFocusWindow();
showAndFocusWindow(mainWindow!);
mainWindow?.webContents.send('tray:create-scratchpad');
},
onQuit: () => app.quit(),
@@ -109,6 +115,12 @@ async function bootstrap(): Promise<void> {
autoUpdateService.start();
}
app.on('second-instance', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
});
app.whenReady().then(bootstrap);
app.on('window-all-closed', () => {
@@ -127,8 +139,8 @@ app.on('window-all-closed', () => {
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await bootstrap();
} else {
showAndFocusWindow();
} else if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
});
+5 -1
View File
@@ -56,7 +56,7 @@ import type {
QuickPromptSendInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme, QuickPromptSettings } from '@shared/domain/tooling';
import type { AppearanceTheme, OpenTelemetrySettings, QuickPromptSettings } from '@shared/domain/tooling';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
@@ -155,6 +155,10 @@ export function registerIpcHandlers(
ipcChannels.setGitAutoRefreshEnabled,
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
);
ipcMain.handle(
ipcChannels.setOpenTelemetry,
(_event, settings: OpenTelemetrySettings) => service.setOpenTelemetry(settings),
);
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
ipcMain.handle(ipcChannels.installUpdate, () => {
autoUpdateService.installUpdate();
+3 -5
View File
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import type { WorkspaceState } from '@shared/domain/workspace';
const { app, Menu, Tray, nativeImage, BrowserWindow } = electron;
const { app, Menu, Tray, nativeImage } = electron;
type TrayType = InstanceType<typeof Tray>;
type NativeImageType = ReturnType<typeof nativeImage.createFromPath>;
@@ -123,10 +123,8 @@ export function setupCloseToTray(
/**
* Show and focus the main window, restoring from tray if hidden.
*/
export function showAndFocusWindow(): void {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows[0];
if (!mainWindow) return;
export function showAndFocusWindow(mainWindow: Electron.BrowserWindow): void {
if (mainWindow.isDestroyed()) return;
// On macOS, show the dock icon again
if (process.platform === 'darwin') {
+10 -1
View File
@@ -1,6 +1,11 @@
import type { OpenTelemetrySettings } from '@shared/domain/tooling';
const blockedEnvironmentPrefixes = ['BUN_', 'COPILOT_', 'ELECTRON_', 'NODE_', 'NPM_'];
export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
export function createSidecarEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
openTelemetry?: OpenTelemetrySettings,
): NodeJS.ProcessEnv {
const sanitizedEnvironment: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnvironment)) {
@@ -13,5 +18,9 @@ export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): No
sanitizedEnvironment[name] = value;
}
if (openTelemetry?.enabled && openTelemetry.endpoint) {
sanitizedEnvironment.OTEL_EXPORTER_OTLP_ENDPOINT = openTelemetry.endpoint;
}
return sanitizedEnvironment;
}
+7 -1
View File
@@ -21,6 +21,7 @@ import type {
} from '@shared/contracts/sidecar';
import type { ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { OpenTelemetrySettings } from '@shared/domain/tooling';
import { createSidecarEnvironment } from '@main/sidecar/sidecarEnvironment';
import {
markRunTurnPendingErrored,
@@ -109,6 +110,11 @@ export class SidecarClient {
private processState?: ManagedSidecarProcess;
private nextProcessId = 0;
private readonly pending = new Map<string, PendingCommand>();
private openTelemetrySettings?: OpenTelemetrySettings;
setOpenTelemetrySettings(settings?: OpenTelemetrySettings): void {
this.openTelemetrySettings = settings;
}
async describeCapabilities(): Promise<SidecarCapabilities> {
const command = await this.dispatch<SidecarCapabilities>({
@@ -240,7 +246,7 @@ export class SidecarClient {
});
const childProcess = spawn(sidecar.command, sidecar.args, {
cwd: sidecar.cwd,
env: createSidecarEnvironment(process.env),
env: createSidecarEnvironment(process.env, this.openTelemetrySettings),
stdio: 'pipe',
windowsHide: true,
});
+1
View File
@@ -36,6 +36,7 @@ const api: ElectronApi = {
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled),
setOpenTelemetry: (settings) => ipcRenderer.invoke(ipcChannels.setOpenTelemetry, settings),
getQuickPromptSettings: () => ipcRenderer.invoke(ipcChannels.quickPromptGetSettings),
setQuickPromptSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings),
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
+2
View File
@@ -873,6 +873,8 @@ export default function App() {
setQuickPromptSettings(updated);
void api.setQuickPromptSettings(patch);
}}
openTelemetry={workspace.settings.openTelemetry}
onSetOpenTelemetry={(settings) => void api.setOpenTelemetry(settings)}
/>
</Suspense>
) : null;
+77 -3
View File
@@ -1,9 +1,9 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench, Activity } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { HotkeyRecorder, ToggleSwitch } from '@renderer/components/ui';
import { HotkeyRecorder, TextInput, ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
@@ -18,9 +18,11 @@ import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shar
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
DEFAULT_OTEL_ENDPOINT,
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type OpenTelemetrySettings,
type QuickPromptSettings,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
@@ -65,9 +67,11 @@ interface SettingsPanelProps {
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
quickPromptSettings?: QuickPromptSettings;
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => void;
openTelemetry?: OpenTelemetrySettings;
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
}
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting';
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'telemetry' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -86,6 +90,7 @@ const navGroups: NavGroup[] = [
items: [
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
{ id: 'telemetry', label: 'Telemetry', icon: <Activity className="size-3.5" /> },
],
},
{
@@ -155,6 +160,8 @@ export function SettingsPanel({
onCreateWorkflowFromTemplate,
quickPromptSettings,
onSetQuickPromptSettings,
openTelemetry,
onSetOpenTelemetry,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
@@ -394,6 +401,12 @@ export function SettingsPanel({
onUpdate={onSetQuickPromptSettings}
/>
)}
{activeSection === 'telemetry' && (
<TelemetrySection
openTelemetry={openTelemetry}
onSetOpenTelemetry={onSetOpenTelemetry}
/>
)}
{activeSection === 'troubleshooting' && (
<TroubleshootingSection
onOpenAppDataFolder={onOpenAppDataFolder}
@@ -548,6 +561,67 @@ function AppearanceSection({
);
}
function TelemetrySection({
openTelemetry,
onSetOpenTelemetry,
}: {
openTelemetry?: OpenTelemetrySettings;
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
}) {
const enabled = openTelemetry?.enabled ?? false;
const endpoint = openTelemetry?.endpoint ?? DEFAULT_OTEL_ENDPOINT;
const handleToggle = () => {
onSetOpenTelemetry?.({ enabled: !enabled, endpoint });
};
const handleEndpointChange = (value: string) => {
onSetOpenTelemetry?.({ enabled, endpoint: value });
};
return (
<div>
<div className="mb-1">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">OpenTelemetry</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Export traces from the sidecar to an OTLP-compatible collector
</p>
</div>
<button
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
onClick={handleToggle}
type="button"
>
<div>
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Enable OTLP export
</span>
<p className="text-[12px] text-[var(--color-text-muted)]">
Send traces to the configured OTLP endpoint when a new sidecar session starts
</p>
</div>
<ToggleSwitch enabled={enabled} />
</button>
<div className="mt-5">
<label className="mb-1.5 block text-[12px] font-medium text-[var(--color-text-secondary)]">
OTLP endpoint
</label>
<TextInput
value={endpoint}
onChange={handleEndpointChange}
placeholder={DEFAULT_OTEL_ENDPOINT}
/>
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">
The URL of any OTLP-compatible collector (e.g. Jaeger, Aspire Dashboard, Grafana Alloy).
Changes take effect on the next session.
</p>
</div>
</div>
);
}
function ConnectionSection({
connection,
modelCount,
+1
View File
@@ -25,6 +25,7 @@ export const ipcChannels = {
setNotificationsEnabled: 'settings:set-notifications-enabled',
setMinimizeToTray: 'settings:set-minimize-to-tray',
setGitAutoRefreshEnabled: 'settings:set-git-auto-refresh-enabled',
setOpenTelemetry: 'settings:set-opentelemetry',
checkForUpdates: 'app:check-for-updates',
installUpdate: 'app:install-update',
saveMcpServer: 'tooling:mcp:save',
+2
View File
@@ -20,6 +20,7 @@ import type {
SessionToolingSelection,
AppearanceTheme,
QuickPromptSettings,
OpenTelemetrySettings,
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
@@ -364,6 +365,7 @@ export interface ElectronApi {
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
setMinimizeToTray(enabled: boolean): Promise<WorkspaceState>;
setGitAutoRefreshEnabled(enabled: boolean): Promise<WorkspaceState>;
setOpenTelemetry(settings: OpenTelemetrySettings): Promise<WorkspaceState>;
checkForUpdates(): Promise<UpdateStatus>;
installUpdate(): Promise<void>;
describeTerminal(): Promise<TerminalSnapshot | undefined>;
+18
View File
@@ -68,6 +68,13 @@ export interface QuickPromptSettings {
defaultReasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
}
export interface OpenTelemetrySettings {
enabled: boolean;
endpoint: string;
}
export const DEFAULT_OTEL_ENDPOINT = 'http://localhost:4317';
export function createDefaultQuickPromptSettings(): QuickPromptSettings {
return {
enabled: true,
@@ -85,6 +92,7 @@ export interface WorkspaceSettings {
minimizeToTray?: boolean;
gitAutoRefreshEnabled?: boolean;
quickPrompt?: QuickPromptSettings;
openTelemetry?: OpenTelemetrySettings;
}
export interface SessionToolingSelection {
@@ -231,6 +239,16 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
...(settings?.quickPrompt !== undefined ? { quickPrompt: settings.quickPrompt } : {}),
...(settings?.openTelemetry !== undefined ? { openTelemetry: normalizeOpenTelemetrySettings(settings.openTelemetry) } : {}),
};
}
export function normalizeOpenTelemetrySettings(settings?: Partial<OpenTelemetrySettings>): OpenTelemetrySettings {
return {
enabled: settings?.enabled === true,
endpoint: typeof settings?.endpoint === 'string' && settings.endpoint.trim() !== ''
? settings.endpoint.trim()
: DEFAULT_OTEL_ENDPOINT,
};
}
+1 -47
View File
@@ -5,52 +5,6 @@ function tokenize(value: string): string[] {
.filter((token) => token.length > 0);
}
function shouldInsertNewlineBoundary(current: string, incoming: string): boolean {
if (current.endsWith('\n')) {
return false;
}
return /^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)/.test(incoming.trimStart());
}
function shouldInsertSpaceBoundary(current: string, incoming: string): boolean {
const lastChar = current.at(-1);
const firstChar = incoming[0];
if (!lastChar || !firstChar) {
return false;
}
if (/\s/.test(lastChar) || /\s/.test(firstChar) || /[([{/"'`]/.test(lastChar)) {
return false;
}
if (/^[.,!?;:%)\]}]/.test(firstChar)) {
return false;
}
if (/^[*_`~\[]/.test(firstChar) || /^[A-Z0-9]/.test(firstChar)) {
return true;
}
const currentTokens = tokenize(current);
const incomingTokens = tokenize(incoming);
const firstIncomingToken = incomingTokens[0] ?? '';
return currentTokens.length >= 2 && incomingTokens.length >= 2 && firstIncomingToken.length >= 2;
}
function appendWithNaturalBoundary(current: string, incoming: string): string {
if (shouldInsertNewlineBoundary(current, incoming)) {
return `${current}\n${incoming}`;
}
if (shouldInsertSpaceBoundary(current, incoming)) {
return `${current} ${incoming}`;
}
return current + incoming;
}
function computeSuffixPrefixOverlap(current: string, incoming: string): number {
const maxOverlap = Math.min(current.length, incoming.length);
for (let length = maxOverlap; length > 0; length -= 1) {
@@ -109,5 +63,5 @@ export function mergeStreamingText(current: string, incoming: string): string {
return incoming;
}
return appendWithNaturalBoundary(current, incoming);
return current + incoming;
}
+42
View File
@@ -37,4 +37,46 @@ describe('createSidecarEnvironment', () => {
HTTPS_PROXY: 'http://proxy.local:8080',
});
});
test('injects OTEL_EXPORTER_OTLP_ENDPOINT when OpenTelemetry is enabled', () => {
expect(
createSidecarEnvironment(
{ PATH: 'C:\\tools' },
{ enabled: true, endpoint: 'http://localhost:4317' },
),
).toEqual({
PATH: 'C:\\tools',
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4317',
});
});
test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when OpenTelemetry is disabled', () => {
expect(
createSidecarEnvironment(
{ PATH: 'C:\\tools' },
{ enabled: false, endpoint: 'http://localhost:4317' },
),
).toEqual({
PATH: 'C:\\tools',
});
});
test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when settings are undefined', () => {
expect(
createSidecarEnvironment({ PATH: 'C:\\tools' }),
).toEqual({
PATH: 'C:\\tools',
});
});
test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when endpoint is empty', () => {
expect(
createSidecarEnvironment(
{ PATH: 'C:\\tools' },
{ enabled: true, endpoint: '' },
),
).toEqual({
PATH: 'C:\\tools',
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test';
import {
aspireDashboardContainerName,
aspireDashboardImage,
aspireDashboardUrls,
createAspireDashboardRunArgs,
createOpenTelemetryEnvironment,
} from '../../scripts/aspireDashboard';
describe('aspire dashboard helpers', () => {
test('creates the official standalone Docker command with mapped OTLP ports', () => {
expect(createAspireDashboardRunArgs()).toEqual([
'run',
'--rm',
'-d',
'--name',
aspireDashboardContainerName,
'-p',
'18888:18888',
'-p',
'4317:18889',
'-p',
'4318:18890',
'-e',
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
aspireDashboardImage,
]);
});
test('injects OTLP exporter settings for local Aspire development', () => {
expect(
createOpenTelemetryEnvironment(
{
PATH: 'C:\\tools',
},
aspireDashboardUrls.otlpGrpc,
),
).toEqual({
PATH: 'C:\\tools',
OTEL_EXPORTER_OTLP_ENDPOINT: aspireDashboardUrls.otlpGrpc,
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
});
});
});
+226
View File
@@ -0,0 +1,226 @@
import { join, resolve } from 'node:path';
import { describe, expect, test } from 'bun:test';
import {
incrementVersion,
parseReleaseBump,
runReleaseWorkflow,
type GitCommandResult,
type ReleaseDependencies,
} from '../../scripts/release';
const testRepositoryRoot = resolve('/test-workspace/aryx');
const testPackageJsonPath = join(testRepositoryRoot, 'package.json');
function gitSuccess(stdout = '', stderr = ''): GitCommandResult {
return {
exitCode: 0,
stdout,
stderr,
};
}
function gitFailure(exitCode = 1, stdout = '', stderr = ''): GitCommandResult {
return {
exitCode,
stdout,
stderr,
};
}
function createReleaseDependencies(options?: {
packageJsonText?: string;
gitResults?: GitCommandResult[];
}) {
let packageJsonText =
options?.packageJsonText ??
`{
"name": "aryx",
"version": "0.0.26"
}
`;
const gitResults = [...(options?.gitResults ?? [])];
const commands: string[][] = [];
const writes: Array<{ path: string; content: string }> = [];
const logs: string[] = [];
const dependencies: ReleaseDependencies = {
readText: async () => packageJsonText,
writeText: async (path, content) => {
packageJsonText = content;
writes.push({ path, content });
},
runGit: async (args) => {
commands.push(args);
const result = gitResults.shift();
if (!result) {
throw new Error(`Unexpected git command: ${args.join(' ')}`);
}
return result;
},
log: (message) => {
logs.push(message);
},
};
return {
dependencies,
commands,
writes,
logs,
getPackageJsonText: () => packageJsonText,
};
}
describe('release bump parsing', () => {
test('defaults to patch when no bump argument is provided', () => {
expect(parseReleaseBump()).toBe('patch');
});
test('rejects unsupported bump arguments', () => {
expect(() => parseReleaseBump('prerelease')).toThrow(
'Unsupported release bump "prerelease". Use patch, minor, or major.',
);
});
});
describe('version incrementing', () => {
test('supports patch, minor, and major bumps', () => {
expect(incrementVersion('0.0.26', 'patch')).toBe('0.0.27');
expect(incrementVersion('0.0.26', 'minor')).toBe('0.1.0');
expect(incrementVersion('0.0.26', 'major')).toBe('1.0.0');
});
});
describe('release workflow', () => {
test('bumps package.json, commits, tags, and pushes the tracked branch', async () => {
const { dependencies, commands, writes, logs, getPackageJsonText } = createReleaseDependencies({
gitResults: [
gitSuccess(),
gitSuccess('main\n'),
gitSuccess('origin\n'),
gitSuccess('refs/heads/main\n'),
gitSuccess(),
gitSuccess(),
gitSuccess(),
gitSuccess(),
gitSuccess(),
],
});
const result = await runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
},
dependencies,
);
expect(result).toMatchObject({
bump: 'patch',
currentVersion: '0.0.26',
nextVersion: '0.0.27',
tagName: 'v0.0.27',
localBranch: 'main',
remote: 'origin',
upstreamBranch: 'main',
});
expect(writes).toHaveLength(1);
expect(getPackageJsonText()).toContain('"version": "0.0.27"');
expect(commands).toEqual([
['status', '--porcelain'],
['symbolic-ref', '--quiet', '--short', 'HEAD'],
['config', '--get', 'branch.main.remote'],
['config', '--get', 'branch.main.merge'],
['tag', '--list', 'v0.0.27'],
['add', 'package.json'],
['commit', '-m', 'chore: release v0.0.27'],
['tag', '-a', 'v0.0.27', '-m', 'Release v0.0.27'],
['push', '--follow-tags', 'origin', 'HEAD:main'],
]);
expect(logs).toEqual(['Released v0.0.27 from main to origin/main.']);
});
test('fails before writing package.json when the worktree is dirty', async () => {
const { dependencies, commands, writes } = createReleaseDependencies({
gitResults: [gitSuccess(' M README.md\n')],
});
await expect(
runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
},
dependencies,
),
).rejects.toThrow('Release requires a clean git worktree. Commit, stash, or discard changes first.');
expect(writes).toHaveLength(0);
expect(commands).toEqual([['status', '--porcelain']]);
});
test('fails before writing package.json when the next release tag already exists', async () => {
const { dependencies, commands, writes } = createReleaseDependencies({
gitResults: [
gitSuccess(),
gitSuccess('main\n'),
gitSuccess('origin\n'),
gitSuccess('refs/heads/main\n'),
gitSuccess('v0.1.0\n'),
],
});
await expect(
runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
bumpArg: 'minor',
},
dependencies,
),
).rejects.toThrow('Tag v0.1.0 already exists. Choose a different release version.');
expect(writes).toHaveLength(0);
expect(commands).toEqual([
['status', '--porcelain'],
['symbolic-ref', '--quiet', '--short', 'HEAD'],
['config', '--get', 'branch.main.remote'],
['config', '--get', 'branch.main.merge'],
['tag', '--list', 'v0.1.0'],
]);
});
test('fails when the current branch does not have an upstream', async () => {
const { dependencies, commands, writes } = createReleaseDependencies({
gitResults: [
gitSuccess(),
gitSuccess('release\n'),
gitFailure(),
gitFailure(),
],
});
await expect(
runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
},
dependencies,
),
).rejects.toThrow('Release requires an upstream branch for release. Configure tracking before running the release helper.');
expect(writes).toHaveLength(0);
expect(commands).toEqual([
['status', '--porcelain'],
['symbolic-ref', '--quiet', '--short', 'HEAD'],
['config', '--get', 'branch.release.remote'],
['config', '--get', 'branch.release.merge'],
]);
});
});
+22 -5
View File
@@ -26,17 +26,34 @@ describe('streaming text merge', () => {
expect(mergeStreamingText(current, incoming)).toBe(incoming);
});
test('inserts whitespace when snapshot-like updates would otherwise glue words together', () => {
expect(mergeStreamingText('How about', 'The **Ashen Crown** feels')).toBe(
test.each([
['requires all wr', 'itable fields', 'requires all writable fields'],
['becomes frag', 'ile for clients', 'becomes fragile for clients'],
['Endpoint (domain) uniqu', 'eness across tenants', 'Endpoint (domain) uniqueness across tenants'],
['The doc says "wildc', 'ards are allowed"', 'The doc says "wildcards are allowed"'],
[
'What wildcard syntax supported (*.cont',
'oso.com? contoso.* ?)',
'What wildcard syntax supported (*.contoso.com? contoso.* ?)',
],
['How does Pur', 'view match traffic', 'How does Purview match traffic'],
['more M', 'DA properties', 'more MDA properties'],
['does UA', 'G normalize them?', 'does UAG normalize them?'],
])('does not inject spaces into split words: %s + %s', (current, incoming, expected) => {
expect(mergeStreamingText(current, incoming)).toBe(expected);
});
test('preserves whitespace already present in delta', () => {
expect(mergeStreamingText('How about', ' The **Ashen Crown** feels')).toBe(
'How about The **Ashen Crown** feels',
);
expect(mergeStreamingText('The **Ashen Crown** feels', 'classic and timeless.')).toBe(
expect(mergeStreamingText('The **Ashen Crown** feels', ' classic and timeless.')).toBe(
'The **Ashen Crown** feels classic and timeless.',
);
});
test('inserts a newline before streamed markdown block markers', () => {
expect(mergeStreamingText('If you want, I can also give you', '- darker titles')).toBe(
test('preserves newline already present in delta', () => {
expect(mergeStreamingText('If you want, I can also give you', '\n- darker titles')).toBe(
'If you want, I can also give you\n- darker titles',
);
});
+36
View File
@@ -6,12 +6,14 @@ import {
groupApprovalToolsByProvider,
listApprovalToolDefinitions,
listApprovalToolNames,
normalizeOpenTelemetrySettings,
normalizeWorkspaceSettings,
resolveProjectToolingSettings,
resolveToolLabel,
resolveWorkspaceToolingSettings,
validateLspProfileDefinition,
validateMcpServerDefinition,
DEFAULT_OTEL_ENDPOINT,
type LspProfileDefinition,
type McpServerDefinition,
type WorkspaceToolingSettings,
@@ -118,6 +120,40 @@ describe('tooling settings helpers', () => {
expect(normalizeWorkspaceSettings({ terminalHeight: Number.NaN }).terminalHeight).toBeUndefined();
});
test('normalizes OpenTelemetry settings with defaults', () => {
const otel = normalizeOpenTelemetrySettings();
expect(otel.enabled).toBe(false);
expect(otel.endpoint).toBe(DEFAULT_OTEL_ENDPOINT);
});
test('preserves valid OpenTelemetry settings', () => {
const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: 'http://custom:4317' });
expect(otel.enabled).toBe(true);
expect(otel.endpoint).toBe('http://custom:4317');
});
test('trims whitespace from OpenTelemetry endpoint', () => {
const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: ' http://localhost:4317 ' });
expect(otel.endpoint).toBe('http://localhost:4317');
});
test('falls back to default endpoint when blank', () => {
const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: ' ' });
expect(otel.endpoint).toBe(DEFAULT_OTEL_ENDPOINT);
});
test('normalizeWorkspaceSettings preserves OpenTelemetry when present', () => {
const settings = normalizeWorkspaceSettings({
openTelemetry: { enabled: true, endpoint: 'http://jaeger:4317' },
});
expect(settings.openTelemetry).toEqual({ enabled: true, endpoint: 'http://jaeger:4317' });
});
test('normalizeWorkspaceSettings omits OpenTelemetry when absent', () => {
const settings = normalizeWorkspaceSettings({});
expect(settings.openTelemetry).toBeUndefined();
});
test('validates required MCP transport settings', () => {
const localServer: McpServerDefinition = {
id: 'mcp-local',