diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f6256f203 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +## Agent Orchestrator (ao) Session + +You are running inside an Agent Orchestrator managed workspace. +Session metadata is updated automatically via shell wrappers. + +If automatic updates fail, you can manually update metadata: +```bash +~/.ao/bin/ao-metadata-helper.sh # sourced automatically +# Then call: update_ao_metadata +``` diff --git a/packages/cli/__tests__/scripts/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts index af99048ef..18ce671da 100644 --- a/packages/cli/__tests__/scripts/doctor-script.test.ts +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -98,7 +98,7 @@ describe("scripts/ao-doctor.sh", () => { const result = spawnSync("bash", [scriptPath], { env: { ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}:/usr/bin:/bin`, AO_REPO_ROOT: fakeRepo, AO_CONFIG_PATH: configPath, }, @@ -149,7 +149,7 @@ describe("scripts/ao-doctor.sh", () => { const result = spawnSync("bash", [scriptPath, "--fix"], { env: { ...process.env, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}:/usr/bin:/bin`, AO_REPO_ROOT: fakeRepo, AO_CONFIG_PATH: configPath, AO_DOCTOR_TMP_ROOT: tmpRoot, diff --git a/packages/plugins/tracker-gitlab/vitest.config.ts b/packages/plugins/tracker-gitlab/vitest.config.ts new file mode 100644 index 000000000..aef0a9e87 --- /dev/null +++ b/packages/plugins/tracker-gitlab/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@composio/ao-plugin-scm-gitlab/glab-utils": resolve( + __dirname, + "../scm-gitlab/src/glab-utils.ts", + ), + }, + }, +}); diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts index 8d0650e5c..378d60dff 100644 --- a/packages/web/src/app/api/sessions/[id]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/route.ts @@ -1,5 +1,6 @@ import { type NextRequest } from "next/server"; import { getServices, getSCM } from "@/lib/services"; +import { resolveSessionsDir } from "@/lib/session-metadata"; import { sessionToDashboard, resolveProject, @@ -25,18 +26,22 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ // Enrich metadata (issue labels, agent summaries, issue titles) await enrichSessionsMetadata([coreSession], [dashboardSession], config, registry); - // Enrich PR — serve cache immediately, refresh in background if stale + // Enrich PR — always fetch fresh data for the detail endpoint (no cache) if (coreSession.pr) { const project = resolveProject(coreSession, config.projects); const scm = getSCM(registry, project); - if (scm) { - const cached = await enrichSessionPR(dashboardSession, scm, coreSession.pr, { - cacheOnly: true, + if (scm && project) { + const sessionsDir = resolveSessionsDir(config.configPath, project.path); + await enrichSessionPR(dashboardSession, scm, coreSession.pr, { + bypassCache: true, + metadata: sessionsDir + ? { + sessionsDir, + sessionId: coreSession.id, + currentStatus: coreSession.status, + } + : undefined, }); - if (!cached) { - // Nothing cached yet — block once to populate, then future calls use cache - await enrichSessionPR(dashboardSession, scm, coreSession.pr); - } } } diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index e18a0e0f6..d2dc13d3f 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,5 +1,6 @@ import { ACTIVITY_STATE, isOrchestratorSession } from "@composio/ao-core"; import { getServices, getSCM } from "@/lib/services"; +import { resolveSessionsDir } from "@/lib/session-metadata"; import { sessionToDashboard, resolveProject, @@ -78,11 +79,23 @@ export async function GET(request: Request) { if (remainingMs <= 0) break; const project = resolveProject(core, config.projects); + if (!project) continue; + const scm = getSCM(registry, project); if (!scm) continue; + const sessionsDir = resolveSessionsDir(config.configPath, project.path); + await settlesWithin( - enrichSessionPR(dashboardSessions[i], scm, core.pr), + enrichSessionPR(dashboardSessions[i], scm, core.pr, { + metadata: sessionsDir + ? { + sessionsDir, + sessionId: core.id, + currentStatus: core.status, + } + : undefined, + }), Math.min(remainingMs, PER_PR_ENRICH_TIMEOUT_MS), ); } diff --git a/packages/web/src/components/PRStatus.tsx b/packages/web/src/components/PRStatus.tsx index 3d0a38de3..e594c310f 100644 --- a/packages/web/src/components/PRStatus.tsx +++ b/packages/web/src/components/PRStatus.tsx @@ -35,6 +35,11 @@ export function PRStatus({ pr }: PRStatusProps) { +{pr.additions} -{pr.deletions} {sizeLabel} )} + {rateLimited && ( + + stale + + )} {/* Merged badge */} {pr.state === "merged" && ( diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 3e3e33a92..b96297dd5 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -2,7 +2,12 @@ import { useState, useEffect, useRef } from "react"; import { useSearchParams } from "next/navigation"; -import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types"; +import { + type DashboardSession, + type DashboardPR, + isPRMergeReady, + isPRRateLimited, +} from "@/lib/types"; import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; import { CICheckList } from "./CIBadge"; @@ -517,6 +522,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { }; const allGreen = isPRMergeReady(pr); + const rateLimited = isPRRateLimited(pr); const failedChecks = pr.ciChecks.filter((c) => c.status === "failed"); @@ -539,6 +545,11 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { PR #{pr.number}: {pr.title}
+ {rateLimited && ( + + data may be stale + + )} +{pr.additions}{" "} -{pr.deletions} @@ -587,7 +598,12 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { {/* CI Checks */} {pr.ciChecks.length > 0 && ( -
+
0 ? "expanded" : "inline"} @@ -678,6 +694,25 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { // ── Issues list (pre-merge blockers) ───────────────────────────────── function IssuesList({ pr }: { pr: DashboardPR }) { + const rateLimited = isPRRateLimited(pr); + if (rateLimited) { + return ( +
+

+ Blockers +

+
+ + ● + + + GitHub API rate limited, PR checks may be stale + +
+
+ ); + } + const issues: Array<{ icon: string; color: string; text: string }> = []; if (pr.ciStatus === CI_STATUS.FAILING) { diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 7742aaf1e..e03f60843 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect, beforeEach, vi } from "vitest"; +import * as core from "@composio/ao-core"; import type { Session, PRInfo, @@ -432,6 +433,50 @@ describe("enrichSessionPR", () => { expect(scm.getPRState).toHaveBeenCalled(); expect(dashboard.pr?.state).toBe("open"); }); + + it("should not overwrite terminal status when disk metadata changed after request start", async () => { + const pr = createPRInfo(); + const coreSession = createCoreSession({ status: "working", pr }); + const dashboard = sessionToDashboard(coreSession); + const scm = createMockSCM(); + const readMetadataSpy = vi.spyOn(core, "readMetadata").mockReturnValue({ + worktree: "/tmp", + branch: "feat/test", + status: "killed", + } as core.SessionMetadata); + const updateMetadataSpy = vi.spyOn(core, "updateMetadata").mockImplementation(() => {}); + + await enrichSessionPR(dashboard, scm, pr, { + metadata: { + sessionsDir: "/tmp/sessions", + sessionId: "test-1", + currentStatus: "working", + }, + }); + + expect(readMetadataSpy).toHaveBeenCalledWith("/tmp/sessions", "test-1"); + expect(updateMetadataSpy).not.toHaveBeenCalled(); + expect(dashboard.status).toBe("working"); + }); + + it("should not downgrade mergeable status to approved during enrichment", async () => { + const pr = createPRInfo(); + const coreSession = createCoreSession({ status: "mergeable", pr }); + const dashboard = sessionToDashboard(coreSession); + const scm = createMockSCM(); + const updateMetadataSpy = vi.spyOn(core, "updateMetadata").mockImplementation(() => {}); + + await enrichSessionPR(dashboard, scm, pr, { + metadata: { + sessionsDir: "/tmp/sessions", + sessionId: "test-1", + currentStatus: "mergeable", + }, + }); + + expect(updateMetadataSpy).not.toHaveBeenCalled(); + expect(dashboard.status).toBe("mergeable"); + }); }); describe("enrichSessionAgentSummary", () => { diff --git a/packages/web/src/lib/cache.ts b/packages/web/src/lib/cache.ts index 18fc469de..15a1acedd 100644 --- a/packages/web/src/lib/cache.ts +++ b/packages/web/src/lib/cache.ts @@ -10,7 +10,9 @@ interface CacheEntry { expiresAt: number; } -const DEFAULT_TTL_MS = 5 * 60_000; // 5 minutes +export const PR_CACHE_TTL_SUCCESS_MS = 5 * 60_000; // 5 minutes +export const PR_CACHE_TTL_RATE_LIMIT_MS = 30_000; // 30 seconds +const DEFAULT_TTL_MS = PR_CACHE_TTL_SUCCESS_MS; /** * Simple TTL cache backed by a Map. diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index d8668cb49..7af935937 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -6,26 +6,40 @@ */ import { + SESSION_STATUS, + TERMINAL_STATUSES, + readMetadata, + updateMetadata, isOrchestratorSession, type Session, type Agent, type SCM, type PRInfo, + type SessionStatus, type Tracker, type ProjectConfig, type OrchestratorConfig, type PluginRegistry, } from "@composio/ao-core"; -import type { - DashboardSession, - DashboardPR, - DashboardStats, - DashboardOrchestratorLink, -} from "./types.js"; -import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache"; +import { + isPRRateLimited, + type DashboardSession, + type DashboardPR, + type DashboardStats, + type DashboardOrchestratorLink, +} from "./types"; +import { + TTLCache, + prCache, + prCacheKey, + PR_CACHE_TTL_SUCCESS_MS, + PR_CACHE_TTL_RATE_LIMIT_MS, + type PREnrichmentData, +} from "./cache"; /** Cache for issue titles (5 min TTL — issue titles rarely change) */ const issueTitleCache = new TTLCache(300_000); +const VALID_SESSION_STATUSES = new Set(Object.values(SESSION_STATUS)); /** Resolve which project a session belongs to. */ export function resolveProject( @@ -124,14 +138,22 @@ export async function enrichSessionPR( dashboard: DashboardSession, scm: SCM, pr: PRInfo, - opts?: { cacheOnly?: boolean }, + opts?: { + cacheOnly?: boolean; + bypassCache?: boolean; + metadata?: { + sessionsDir: string; + sessionId: string; + currentStatus: SessionStatus; + }; + }, ): Promise { if (!dashboard.pr) return false; const cacheKey = prCacheKey(pr.owner, pr.repo, pr.number); // Check cache first - const cached = prCache.get(cacheKey); + const cached = opts?.bypassCache ? null : prCache.get(cacheKey); if (cached && dashboard.pr) { dashboard.pr.state = cached.state; dashboard.pr.title = cached.title; @@ -143,6 +165,11 @@ export async function enrichSessionPR( dashboard.pr.mergeability = cached.mergeability; dashboard.pr.unresolvedThreads = cached.unresolvedThreads; dashboard.pr.unresolvedComments = cached.unresolvedComments; + maybeWriteSessionStatusTransition( + dashboard, + opts?.metadata, + isPRRateLimited(dashboard.pr), + ); return true; } @@ -250,7 +277,10 @@ export async function enrichSessionPR( unresolvedThreads: dashboard.pr.unresolvedThreads, unresolvedComments: dashboard.pr.unresolvedComments, }; - prCache.set(cacheKey, rateLimitedData, 60 * 60_000); // 60 min — GitHub rate limit resets hourly + if (!opts?.bypassCache) { + prCache.set(cacheKey, rateLimitedData, PR_CACHE_TTL_RATE_LIMIT_MS); + } + maybeWriteSessionStatusTransition(dashboard, opts?.metadata, true); return true; } @@ -266,10 +296,82 @@ export async function enrichSessionPR( unresolvedThreads: dashboard.pr.unresolvedThreads, unresolvedComments: dashboard.pr.unresolvedComments, }; - prCache.set(cacheKey, cacheData); + if (!opts?.bypassCache) { + prCache.set(cacheKey, cacheData, PR_CACHE_TTL_SUCCESS_MS); + } + maybeWriteSessionStatusTransition(dashboard, opts?.metadata, false); return true; } +function deriveSessionStatusTransition( + currentStatus: SessionStatus, + pr: DashboardPR, + rateLimited: boolean, +): SessionStatus | null { + // Never revive terminal sessions via dashboard polling. + if (TERMINAL_STATUSES.has(currentStatus)) return null; + + if (pr.state === "merged") return SESSION_STATUS.MERGED; + if (pr.state === "closed") return SESSION_STATUS.DONE; + if (currentStatus === SESSION_STATUS.MERGEABLE) return null; + + // During rate limiting, CI/review data can be stale defaults. + if (rateLimited) return null; + + if (currentStatus === SESSION_STATUS.CI_FAILED) { + // Keep ci_failed authoritative until CI recovers. + if (pr.ciStatus === "passing") { + return SESSION_STATUS.PR_OPEN; + } + return null; + } + if (pr.reviewDecision === "approved") { + return SESSION_STATUS.APPROVED; + } + if (pr.reviewDecision === "changes_requested") { + return SESSION_STATUS.CHANGES_REQUESTED; + } + + return null; +} + +function maybeWriteSessionStatusTransition( + dashboard: DashboardSession, + metadata: + | { + sessionsDir: string; + sessionId: string; + currentStatus: SessionStatus; + } + | undefined, + rateLimited: boolean, +): void { + if (!dashboard.pr || !metadata) return; + + let currentStatus = metadata.currentStatus; + try { + const diskStatus = readMetadata(metadata.sessionsDir, metadata.sessionId)?.status; + if (diskStatus && VALID_SESSION_STATUSES.has(diskStatus as SessionStatus)) { + currentStatus = diskStatus as SessionStatus; + } + } catch { + // Best effort read; fall back to request-time status. + } + + const nextStatus = deriveSessionStatusTransition(currentStatus, dashboard.pr, rateLimited); + if (!nextStatus || nextStatus === currentStatus) return; + + try { + updateMetadata(metadata.sessionsDir, metadata.sessionId, { status: nextStatus }); + dashboard.status = nextStatus; + } catch (error) { + console.warn( + `[enrichSessionPR] failed to update metadata for session ${metadata.sessionId}:`, + error, + ); + } +} + /** Enrich a DashboardSession's issue label using the tracker plugin. */ export function enrichSessionIssue( dashboard: DashboardSession, diff --git a/packages/web/src/lib/session-metadata.ts b/packages/web/src/lib/session-metadata.ts new file mode 100644 index 000000000..45b1ceaab --- /dev/null +++ b/packages/web/src/lib/session-metadata.ts @@ -0,0 +1,16 @@ +import { getSessionsDir } from "@composio/ao-core"; + +/** + * Resolve sessions metadata directory from trusted config. + * Returns null when configPath is unavailable/invalid. + */ +export function resolveSessionsDir( + configPath: string, + projectPath: string, +): string | null { + try { + return getSessionsDir(configPath, projectPath); + } catch { + return null; + } +}