feat: add realtime pod activity streams
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
Collision,
|
||||
EngineerContext,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
PodActivityEvent,
|
||||
} from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
|
||||
interface EngineerStateDoc {
|
||||
_id: string;
|
||||
podId: string;
|
||||
name: string;
|
||||
changedFiles?: string[];
|
||||
diffStat?: string | null;
|
||||
recentCommit?: string | null;
|
||||
branch?: string | null;
|
||||
gitUpdatedAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | undefined): string {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value) return new Date(value).toISOString();
|
||||
return new Date(0).toISOString();
|
||||
}
|
||||
|
||||
function clean(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function shortFiles(files: string[] | undefined): string {
|
||||
if (!files?.length) return 'clean working tree';
|
||||
const sample = files.slice(0, 3).join(', ');
|
||||
return files.length > 3 ? `${sample}, +${files.length - 3} more` : sample;
|
||||
}
|
||||
|
||||
function observationEvent(doc: EngineerContext): PodActivityEvent {
|
||||
const file = clean(doc.currentFile);
|
||||
const symbol = clean(doc.currentSymbol);
|
||||
return {
|
||||
id: `observation:${doc.engineerId}:${doc.observedAt}`,
|
||||
podId: doc.podId,
|
||||
kind: 'observation',
|
||||
source: 'vision',
|
||||
actor: doc.engineerId,
|
||||
actors: [doc.engineerId],
|
||||
file,
|
||||
title: file ? `Working in ${file}` : 'Screen context updated',
|
||||
detail: [
|
||||
symbol ? `symbol ${symbol}` : undefined,
|
||||
clean(doc.activity),
|
||||
doc.hasUnpushedChanges ? 'unpushed changes visible' : undefined,
|
||||
`confidence ${Math.round(doc.confidence * 100)}%`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
severity: doc.hasUnpushedChanges ? 'warn' : 'info',
|
||||
at: doc.observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function gitEvent(doc: EngineerStateDoc): PodActivityEvent {
|
||||
const changedFiles = doc.changedFiles ?? [];
|
||||
return {
|
||||
id: `git:${doc._id}:${toIso(doc.gitUpdatedAt ?? doc.updatedAt)}`,
|
||||
podId: doc.podId,
|
||||
kind: 'git',
|
||||
source: 'git',
|
||||
actor: doc.name,
|
||||
actors: [doc.name],
|
||||
title: changedFiles.length ? `${changedFiles.length} local file changes` : 'Git state is clean',
|
||||
detail: [
|
||||
doc.branch ? `branch ${doc.branch}` : undefined,
|
||||
shortFiles(changedFiles),
|
||||
doc.recentCommit ? `head ${doc.recentCommit}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
severity: changedFiles.length ? 'warn' : 'info',
|
||||
at: toIso(doc.gitUpdatedAt ?? doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function collisionEvent(doc: Collision): PodActivityEvent {
|
||||
return {
|
||||
id: `collision:${doc.id}`,
|
||||
podId: doc.podId,
|
||||
kind: 'collision',
|
||||
source: 'memory',
|
||||
actor: doc.engineers[0],
|
||||
actors: doc.engineers,
|
||||
file: doc.file,
|
||||
title: `${doc.engineers.join(' + ')} conflict on ${doc.file}`,
|
||||
detail: [
|
||||
doc.symbol ? `symbol ${doc.symbol}` : undefined,
|
||||
doc.githubState?.unpushed ? 'unpushed local changes involved' : undefined,
|
||||
doc.githubState?.openPrs?.length
|
||||
? `open PRs ${doc.githubState.openPrs.join(', ')}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
severity: doc.severity,
|
||||
at: doc.detectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function interventionEvent(doc: Intervention): PodActivityEvent {
|
||||
return {
|
||||
id: `intervention:${doc.id}`,
|
||||
podId: doc.podId,
|
||||
kind: 'intervention',
|
||||
source: 'hermes',
|
||||
title: `Hermes ${doc.status} ${doc.suggestedAction.kind.replaceAll('_', ' ')}`,
|
||||
detail: doc.message,
|
||||
severity: doc.status === 'accepted' ? 'success' : doc.status === 'dismissed' ? 'info' : 'warn',
|
||||
at: doc.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeEvent(doc: InterventionOutcome): PodActivityEvent {
|
||||
return {
|
||||
id: `outcome:${doc.interventionId}:${doc.recordedAt}`,
|
||||
podId: doc.podId,
|
||||
kind: 'outcome',
|
||||
source: 'policy',
|
||||
title: doc.accepted ? 'Intervention accepted' : 'Intervention dismissed',
|
||||
detail: doc.wasRealCollision ? 'confirmed real collision' : 'marked as false positive',
|
||||
severity: doc.accepted ? 'success' : 'info',
|
||||
at: doc.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPodActivity(podId: string, limit = 80): Promise<PodActivityEvent[]> {
|
||||
const db = await getDb();
|
||||
const [observations, gitStates, collisions, interventions, outcomes] = await Promise.all([
|
||||
db
|
||||
.collection<EngineerContext>('observations')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<EngineerStateDoc>('engineer_states')
|
||||
.find(
|
||||
{ podId },
|
||||
{
|
||||
projection: {
|
||||
_id: 1,
|
||||
podId: 1,
|
||||
name: 1,
|
||||
changedFiles: 1,
|
||||
diffStat: 1,
|
||||
recentCommit: 1,
|
||||
branch: 1,
|
||||
gitUpdatedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
.sort({ gitUpdatedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<Collision>('collisions')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ detectedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<Intervention>('interventions')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<InterventionOutcome>('outcomes')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
]);
|
||||
|
||||
return [
|
||||
...observations.map(observationEvent),
|
||||
...gitStates.map(gitEvent),
|
||||
...collisions.map(collisionEvent),
|
||||
...interventions.map(interventionEvent),
|
||||
...outcomes.map(outcomeEvent),
|
||||
]
|
||||
.filter((event) => event.at !== new Date(0).toISOString())
|
||||
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './pods/store.js';
|
||||
import { getPresence, closeRoom } from './livekit/rooms.js';
|
||||
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||
import { listPodActivity } from './activity/store.js';
|
||||
import type { InterventionOutcome } from '@podman/shared';
|
||||
|
||||
const app = express();
|
||||
@@ -159,6 +160,51 @@ app.get('/api/pods/:id/graph/reach/:node', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/activity', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit ?? 80) || 80, 200);
|
||||
res.json(await listPodActivity(req.params.id, limit));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/activity/stream', async (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
|
||||
let closed = false;
|
||||
let lastPayload = '';
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const events = await listPodActivity(req.params.id, 80);
|
||||
const payload = JSON.stringify(events);
|
||||
if (payload !== lastPayload) {
|
||||
lastPayload = payload;
|
||||
res.write(`event: snapshot\n`);
|
||||
res.write(`data: ${payload}\n\n`);
|
||||
} else {
|
||||
res.write(`: keepalive ${Date.now()}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.write(`event: error\n`);
|
||||
res.write(`data: ${JSON.stringify({ error: (e as Error).message })}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 1500);
|
||||
|
||||
req.on('close', () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
const http = createServer(app);
|
||||
|
||||
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
||||
|
||||
@@ -4,18 +4,22 @@ import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
CircleDotIcon,
|
||||
GitBranchIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
MessageSquareIcon,
|
||||
MonitorUpIcon,
|
||||
RadioTowerIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
Volume2Icon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client';
|
||||
import type { Pod } from '@podman/shared';
|
||||
import type { Pod, PodActivityEvent, PodActivityKind } from '@podman/shared';
|
||||
import { startBeat, type BeatHandle } from '../lib/beat.js';
|
||||
import { useInterventions } from '../livekit/useInterventions.js';
|
||||
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
||||
import LiveWaveform from '@/components/ruixen/live-waveform';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -83,6 +87,7 @@ export function PodView({
|
||||
const [playingBeat, setPlayingBeat] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room);
|
||||
const activity = usePodActivity(team.id, me);
|
||||
|
||||
const audioRef = useRef<HTMLDivElement>(null);
|
||||
const beatRef = useRef<BeatHandle | null>(null);
|
||||
@@ -285,6 +290,26 @@ export function PodView({
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<section className="grid min-h-[420px] gap-5 xl:grid-cols-2">
|
||||
<ActivityStream
|
||||
title="My stream"
|
||||
description={`${me}'s live screen, git, intervention, and conflict log.`}
|
||||
events={activity.mine}
|
||||
connected={activity.connected}
|
||||
emptyTitle="No personal signal yet"
|
||||
emptyDescription="Start the local git watcher or share your IDE screen to populate this lane."
|
||||
/>
|
||||
<ActivityStream
|
||||
title="Team stream"
|
||||
description="Everyone else in this pod, merged into one realtime feed."
|
||||
events={activity.team}
|
||||
connected={activity.connected}
|
||||
emptyTitle="No teammate signal yet"
|
||||
emptyDescription="Waiting for other members' screen, git, or collision events."
|
||||
/>
|
||||
</section>
|
||||
{activity.error && <p className="text-xs text-muted-foreground">{activity.error}</p>}
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-5">
|
||||
@@ -450,6 +475,145 @@ function StatusLine({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityStream({
|
||||
title,
|
||||
description,
|
||||
events,
|
||||
connected,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
events: PodActivityEvent[];
|
||||
connected: boolean;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant={connected ? 'default' : 'secondary'} className="rounded-md">
|
||||
{connected ? 'streaming' : 'syncing'}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{events.length ? (
|
||||
<div className="max-h-[460px] overflow-y-auto pr-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
{events.map((event) => (
|
||||
<ActivityItem key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="min-h-72 border-0 p-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<RadioTowerIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityItem({ event }: { event: PodActivityEvent }) {
|
||||
const Icon = activityIcon(event.kind);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-20 grid-cols-[2.25rem_minmax(0,1fr)] gap-3 rounded-lg border bg-card px-3 py-3 shadow-sm',
|
||||
event.severity === 'critical' && 'border-destructive/45 bg-destructive/5',
|
||||
event.severity === 'warn' && 'border-chart-3/45 bg-chart-3/5',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-md border bg-muted text-muted-foreground',
|
||||
event.severity === 'critical' && 'border-destructive/35 text-destructive',
|
||||
event.severity === 'success' && 'border-chart-2/35 text-chart-2',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<p className="min-w-0 truncate text-sm font-medium">{event.title}</p>
|
||||
<time className="shrink-0 text-xs text-muted-foreground">{timeLabel(event.at)}</time>
|
||||
</div>
|
||||
{event.detail && (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{event.detail}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{event.actors?.length ? (
|
||||
event.actors.map((actor) => (
|
||||
<Badge
|
||||
key={actor}
|
||||
variant="secondary"
|
||||
className="rounded-md px-1.5 py-0 text-[0.68rem]"
|
||||
>
|
||||
{actor}
|
||||
</Badge>
|
||||
))
|
||||
) : event.actor ? (
|
||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[0.68rem]">
|
||||
{event.actor}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" className="rounded-md px-1.5 py-0 text-[0.68rem]">
|
||||
{event.source}
|
||||
</Badge>
|
||||
{event.file && (
|
||||
<Badge variant="outline" className="max-w-full rounded-md px-1.5 py-0 text-[0.68rem]">
|
||||
<span className="truncate">{event.file}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function activityIcon(kind: PodActivityKind) {
|
||||
switch (kind) {
|
||||
case 'git':
|
||||
return GitBranchIcon;
|
||||
case 'collision':
|
||||
return TriangleAlertIcon;
|
||||
case 'intervention':
|
||||
return MessageSquareIcon;
|
||||
case 'outcome':
|
||||
return CheckIcon;
|
||||
case 'observation':
|
||||
default:
|
||||
return FileTextIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function timeLabel(value: string): string {
|
||||
const then = Date.parse(value);
|
||||
if (!Number.isFinite(then)) return '--';
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (seconds < 10) return 'now';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { PodActivityEvent } from '@podman/shared';
|
||||
import { getPodActivity, podActivityStreamUrl } from '../lib/api';
|
||||
|
||||
export function usePodActivity(podId: string | null, me: string) {
|
||||
const [events, setEvents] = useState<PodActivityEvent[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!podId) return;
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const snapshot = await getPodActivity(podId);
|
||||
if (alive) {
|
||||
setEvents(snapshot);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
const source = new EventSource(podActivityStreamUrl(podId));
|
||||
source.addEventListener('open', () => {
|
||||
if (alive) setConnected(true);
|
||||
});
|
||||
source.addEventListener('snapshot', (event) => {
|
||||
if (!alive) return;
|
||||
setEvents(JSON.parse((event as MessageEvent<string>).data) as PodActivityEvent[]);
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
if (alive) {
|
||||
setConnected(false);
|
||||
setError('Realtime activity stream reconnecting');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
source.close();
|
||||
};
|
||||
}, [podId]);
|
||||
|
||||
return useMemo(() => {
|
||||
const mine = events.filter((event) => belongsTo(event, me));
|
||||
const team = events.filter((event) => !belongsTo(event, me));
|
||||
return { events, mine, team, connected, error };
|
||||
}, [connected, error, events, me]);
|
||||
}
|
||||
|
||||
function belongsTo(event: PodActivityEvent, me: string): boolean {
|
||||
const normalized = me.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
const names = [event.actor, ...(event.actors ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((name) => name!.trim().toLowerCase());
|
||||
return names.includes(normalized);
|
||||
}
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import type { InterventionOutcome, Pod, PodInput } from '@podman/shared';
|
||||
import type { InterventionOutcome, Pod, PodActivityEvent, PodInput } from '@podman/shared';
|
||||
|
||||
const BACKEND_URL =
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
@@ -75,6 +75,16 @@ export async function getMemoryStats(): Promise<MemoryStats> {
|
||||
return json(await fetch(`${BACKEND_URL}/api/memory/stats`));
|
||||
}
|
||||
|
||||
export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
|
||||
);
|
||||
}
|
||||
|
||||
export function podActivityStreamUrl(id: string): string {
|
||||
return `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity/stream`;
|
||||
}
|
||||
|
||||
export async function createPod(input: PodInput): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods`, {
|
||||
|
||||
+20
-13
@@ -22,6 +22,9 @@ const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({
|
||||
}));
|
||||
const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url));
|
||||
const { Room } = backendRequire('@livekit/rtc-node');
|
||||
const pods = await fetchJson('/api/pods');
|
||||
const verifyPod = pods.find((pod) => pod.id === 'frontend-pod') ?? pods[0];
|
||||
if (!verifyPod) throw new Error('no pods available for frontend verification');
|
||||
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
@@ -89,7 +92,7 @@ async function publishIntervention(room, podId) {
|
||||
podId,
|
||||
kind: 'card',
|
||||
message: 'Verification collision: two engineers are editing frontend/src/App.tsx.',
|
||||
suggestedAction: { kind: 'sync_before_push' },
|
||||
suggestedAction: { kind: 'open_sync_pr' },
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
};
|
||||
@@ -227,7 +230,9 @@ page.on('console', (msg) => {
|
||||
});
|
||||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||
page.on('requestfailed', (req) => {
|
||||
failedRequests.push(`${req.url()} ${req.failure()?.errorText ?? ''}`.trim());
|
||||
const failure = req.failure()?.errorText ?? '';
|
||||
if (req.url().includes('/activity/stream') && failure.includes('ERR_ABORTED')) return;
|
||||
failedRequests.push(`${req.url()} ${failure}`.trim());
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -235,8 +240,7 @@ try {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
const hasPodCards =
|
||||
(await page.locator('text=/Frontend Pod|Backend Pod|graph pod/i').count()) > 0;
|
||||
const hasPodCards = (await page.getByText(verifyPod.name, { exact: true }).count()) > 0;
|
||||
const hasOverlay = (await page.locator('vite-error-overlay, .vite-error-overlay').count()) > 0;
|
||||
|
||||
if (bodyText.length < 100) throw new Error('frontend rendered too little text');
|
||||
@@ -252,12 +256,14 @@ try {
|
||||
await page.getByRole('button', { name: 'Whole graph' }).click();
|
||||
await page.getByRole('button', { name: /Pods/i }).click();
|
||||
|
||||
const frontendPodCard = page
|
||||
.getByText('Frontend Pod', { exact: true })
|
||||
const podCard = page
|
||||
.getByText(verifyPod.name, { exact: true })
|
||||
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
|
||||
await frontendPodCard.getByPlaceholder('Your name').fill(verifyMember);
|
||||
await frontendPodCard.getByRole('button', { name: 'Add and join' }).click();
|
||||
await podCard.getByPlaceholder('Your name').fill(verifyMember);
|
||||
await podCard.getByRole('button', { name: 'Add and join' }).click();
|
||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByText('My stream').waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Team stream').waitFor({ timeout: 15_000 });
|
||||
|
||||
const joinedText = await page.locator('body').innerText();
|
||||
const hasPodView =
|
||||
@@ -272,18 +278,18 @@ try {
|
||||
await page.getByRole('button', { name: 'Share screen' }).click();
|
||||
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByText(/Screen\s*published/i).waitFor({ timeout: 15_000 });
|
||||
await waitForPublishedScreenShare('frontend-pod');
|
||||
await waitForPublishedScreenShare(verifyPod.id);
|
||||
await page.getByRole('button', { name: 'Stop sharing' }).click();
|
||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||
|
||||
const publisher = await connectPublisher('frontend-pod');
|
||||
const publisher = await connectPublisher(verifyPod.id);
|
||||
try {
|
||||
const intervention = await waitForInterventionCard(page, publisher, 'frontend-pod');
|
||||
const intervention = await waitForInterventionCard(page, publisher, verifyPod.id);
|
||||
await publishDataMessage(publisher, {
|
||||
type: 'HERMES_MESSAGE',
|
||||
message: {
|
||||
id: `hermes-${process.pid}`,
|
||||
podId: 'frontend-pod',
|
||||
podId: verifyPod.id,
|
||||
interventionId: intervention.id,
|
||||
recipients: ['Verify'],
|
||||
text: 'Hermes verification message routed to the team.',
|
||||
@@ -326,6 +332,7 @@ try {
|
||||
joined: true,
|
||||
screenShare: 'livekit-published',
|
||||
intervention: 'collision-hermes-voice',
|
||||
podId: verifyPod.id,
|
||||
member: verifyMember,
|
||||
},
|
||||
null,
|
||||
@@ -333,7 +340,7 @@ try {
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, {
|
||||
await doFetch(`${apiBase}/api/pods/${verifyPod.id}/members/${encodeURIComponent(verifyMember)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type PodActivityKind = 'observation' | 'git' | 'collision' | 'intervention' | 'outcome';
|
||||
|
||||
export type PodActivitySource = 'vision' | 'git' | 'memory' | 'hermes' | 'policy';
|
||||
|
||||
export type PodActivitySeverity = 'info' | 'warn' | 'critical' | 'success';
|
||||
|
||||
export interface PodActivityEvent {
|
||||
id: string;
|
||||
podId: string;
|
||||
kind: PodActivityKind;
|
||||
source: PodActivitySource;
|
||||
title: string;
|
||||
detail?: string;
|
||||
actor?: string;
|
||||
actors?: string[];
|
||||
file?: string;
|
||||
severity: PodActivitySeverity;
|
||||
at: string;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export type { Pod, PodInput, Engineer } from './pod.js';
|
||||
export type { EngineerContext } from './engineer.js';
|
||||
export type {
|
||||
PodActivityEvent,
|
||||
PodActivityKind,
|
||||
PodActivitySeverity,
|
||||
PodActivitySource,
|
||||
} from './activity.js';
|
||||
export type { Collision, CollisionSeverity, GithubStateSnapshot } from './collision.js';
|
||||
export type {
|
||||
Intervention,
|
||||
|
||||
Reference in New Issue
Block a user