Skip to content
Closed
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
42 changes: 0 additions & 42 deletions .github/workflows/agentex-ui-lint-typecheck.yml

This file was deleted.

49 changes: 47 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
agentex: ${{ steps.filter.outputs.agentex }}
agentex-ui: ${{ steps.filter.outputs.agentex-ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -28,6 +29,10 @@ jobs:
filters: |
agentex:
- 'agentex/**'
- '.github/workflows/ci.yml'
agentex-ui:
- 'agentex-ui/**'
Comment thread
mohammadatallah-scale marked this conversation as resolved.
- '.github/workflows/ci.yml'

test:
name: "Run Unit and Integration Tests"
Expand Down Expand Up @@ -207,11 +212,43 @@ jobs:
echo "✅ openapi.yaml is up to date"

# This job is used as a required status check for branch protection
ui:
name: "Agentex UI Typecheck, Lint and Tests"
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.agentex-ui == 'true'
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './agentex-ui/package-lock.json'

- name: Install dependencies
run: npm ci
working-directory: ./agentex-ui

- name: Run typecheck
run: npm run typecheck
working-directory: ./agentex-ui

- name: Run lint
run: npm run lint
working-directory: ./agentex-ui

- name: Run unit tests
run: npm run test:run
working-directory: ./agentex-ui

# It will pass if the conditional jobs either passed or were skipped
ci-status:
name: "CI Status Check"
runs-on: ubuntu-latest
needs: [changes, test, docs, openapi-spec]
needs: [changes, test, docs, openapi-spec, ui]
if: always()
steps:
- name: Check CI status
Expand Down Expand Up @@ -239,5 +276,13 @@ jobs:
echo "✅ All checks passed"
else
echo "No agentex changes detected - skipping tests and docs"
echo "✅ CI status check passed"
fi

if [ "${{ needs.changes.outputs.agentex-ui }}" == "true" ]; then
if [ "${{ needs.ui.result }}" != "success" ]; then
echo "❌ Agentex UI typecheck, lint or tests failed"
exit 1
fi
fi

echo "✅ CI status check passed"
5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ The backend (`agentex/src/`) follows a clean architecture with strict layer sepa
```
src/
├── api/ # FastAPI routes, middleware, request/response schemas
│ ├── routes/ # API endpoints (agents, tasks, messages, spans, etc.)
│ ├── routes/ # API endpoints (agents, tasks, messages, states, etc.)
│ ├── schemas/ # Pydantic request/response models
│ ├── authentication_middleware.py
│ └── app.py # FastAPI application setup
Expand Down Expand Up @@ -285,7 +285,6 @@ Tests are organized by type and use different strategies:
- **Agents**: Autonomous entities that execute tasks, managed via ACP protocol
- **Tasks**: Work units with lifecycle states (pending → running → completed/failed). Identified by a UUID `id`; the human-readable `name` is **optional** (nullable) and, when set, globally unique. `task/create` is get-or-create keyed on `name`, so reusing an existing name returns that task with its prior history instead of creating a new one — omit `name` (or make it unique) whenever each call should produce a fresh task.
- **Messages**: Communication between system and agents (stored in MongoDB)
- **Spans**: Execution traces for observability (OpenTelemetry-style)
- **Events**: Domain events for async communication
- **States**: Key-value state storage for agents
- **Deployment History**: Track agent deployment versions and changes
Expand Down Expand Up @@ -347,7 +346,7 @@ For any migration that adds a backfilled column with an FK and an index on a lar
| Step | What | Why |
|---|---|---|
| **M1 (Alembic)** | `ADD COLUMN` (nullable) + `ADD CONSTRAINT ... NOT VALID` + `CREATE INDEX CONCURRENTLY` (in `autocommit_block()`) | Schema-only, all metadata-cheap or non-blocking. Each operation is idempotent (`IF NOT EXISTS` / `pg_constraint` guard) so the migration is safe to re-run on environments that already ran a previous (broken) version. |
| **Out-of-band runbook** | Chunked backfill script with `lock_timeout`, small batches, `COMMIT` between batches, `pg_sleep` between batches | Operator-driven; runs during a low-traffic window, can be cancelled cleanly, doesn't block pod startup. Pattern: `agentex/docs/runbooks/spans-task-id-backfill.md`. |
| **Out-of-band runbook** | Chunked backfill script with `lock_timeout`, small batches, `COMMIT` between batches, `pg_sleep` between batches | Operator-driven; runs during a low-traffic window, can be cancelled cleanly, doesn't block pod startup. |
| **M2 (Alembic)** | `ALTER TABLE ... VALIDATE CONSTRAINT` (only if a fully validated FK state is actually needed) | Runs after the backfill so the scan finds no violations. `ShareUpdateExclusiveLock` is non-blocking against reads/writes but still scans the table — usually optional. |

The application should also tolerate the partially-backfilled state at read time (e.g. ORing the new column against the legacy column where they overlap) so deployment of M1 is decoupled from the backfill's completion.
Expand Down
4 changes: 2 additions & 2 deletions agentex-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ A modern web interface for building, testing, and monitoring intelligent agents.

### Observability

- **Execution Traces** - View OpenTelemetry-style spans for task execution
- **Execution Traces** - View a task's spans from Scale GenAI Platform (needs `SGP_API_URL` or `NEXT_PUBLIC_SGP_APP_URL`)
- **Span Visualization** - Hierarchical view of execution flow
- **Performance Metrics** - Timing and duration information for each execution step
- **Error Tracking** - Detailed error information when tasks fail
Expand Down Expand Up @@ -178,7 +178,7 @@ For Docker-related commands, see the Docker section in `build.ps1 help`.
- `hooks/use-tasks.ts` - Task list with infinite scroll pagination
- `hooks/use-task-messages.ts` - Message fetching and sending with message streaming for sync agents
- `hooks/use-task-subscription.ts` - Real-time task updates via WebSocket for async agents
- `hooks/use-spans.ts` - Execution trace data
- `hooks/use-spans.ts` - Execution trace data (via `/api/traces`)

**Components:**

Expand Down
178 changes: 178 additions & 0 deletions agentex-ui/app/api/traces/[traceId]/spans/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { GET } from './route';

const bff = vi.hoisted(() => ({
baseURL: 'https://sgp.example/api' as string | undefined,
applyBffCredentials: vi.fn(async (_req: Request, headers: Headers) => {
headers.set('authorization', 'Bearer server-side');
}),
}));

vi.mock('@/app/api/_lib/bff', () => ({
get SGP_BASE_URL() {
return bff.baseURL;
},
applyBffCredentials: bff.applyBffCredentials,
}));

function call(traceId: string, init?: RequestInit, search = '') {
return GET(
new Request(`http://ui.local/api/traces/${traceId}/spans${search}`, init),
{ params: Promise.resolve({ traceId }) }
);
}

function upstreamURL(fetchMock: ReturnType<typeof vi.fn>) {
return new URL(fetchMock.mock.calls[0]![0] as string);
}

describe('GET /api/traces/[traceId]/spans', () => {
afterEach(() => {
vi.unstubAllGlobals();
bff.baseURL = 'https://sgp.example/api';
});

it('searches the platform for the trace with server-attached credentials', async () => {
const page = { items: [{ id: 's1', trace_id: 't1' }], has_more: false };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(page), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

const res = await call('t1');

expect(res.status).toBe(200);
expect(await res.json()).toEqual(page);
expect(bff.applyBffCredentials).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe(
'https://sgp.example/api/v5/spans/search?limit=100&sort_by=start_timestamp&sort_order=asc&allow_short_pages=true'
);
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ trace_ids: ['t1'] });
expect(new Headers(init.headers).get('authorization')).toBe(
'Bearer server-side'
);
expect(init.signal).toBeInstanceOf(AbortSignal);
});

it('answers 499 when the browser aborts before the platform replies', async () => {
const controller = new AbortController();
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init: RequestInit) => {
const signal = init.signal as AbortSignal;
return new Promise<Response>((_resolve, reject) => {
if (signal.aborted) reject(signal.reason);
signal.addEventListener('abort', () => reject(signal.reason));
});
})
);

const pending = call('t1', { signal: controller.signal });
controller.abort();
const res = await pending;

expect(res.status).toBe(499);
});

it('starts the search window at the task creation time and leaves it open-ended', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('{"items":[]}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const from = '2026-01-01T00:00:00.000Z';

await call('t1', undefined, `?from=${encodeURIComponent(from)}`);

const params = upstreamURL(fetchMock).searchParams;
expect(Date.parse(params.get('from_ts')!)).toBe(
Date.parse(from) - 5 * 60 * 1000
);
expect(params.has('to_ts')).toBe(false);
});

it('passes the platform truncation of a window wider than 90 days through', async () => {
const from = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString();
const effectiveFrom = new Date(
Date.now() - 90 * 24 * 60 * 60 * 1000
).toISOString();
const page = {
items: [{ id: 's1', trace_id: 't1' }],
has_more: false,
window_truncated: true,
effective_from_ts: effectiveFrom,
};
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify(page), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
);

const res = await call(
't1',
undefined,
`?from=${encodeURIComponent(from)}`
);

expect(await res.json()).toEqual(page);
});

it('sends no window without a creation time', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('{"items":[]}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

await call('t1');

const params = upstreamURL(fetchMock).searchParams;
expect(params.has('from_ts')).toBe(false);
expect(params.has('to_ts')).toBe(false);
});

it('rejects a creation time that is not a timestamp', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const res = await call('t1', undefined, '?from=yesterday');

expect(res.status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});

it('passes the upstream status through', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ detail: 'forbidden' }), { status: 403 })
)
);

const res = await call('t1');

expect(res.status).toBe(403);
expect(await res.json()).toEqual({ detail: 'forbidden' });
});

it('returns 503 when the platform API is not configured', async () => {
bff.baseURL = undefined;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const res = await call('t1');

expect(res.status).toBe(503);
expect(fetchMock).not.toHaveBeenCalled();
});
});
Loading
Loading