feat(web,core): "Launch Orchestrator (clean context)" button (#1904)
* feat(web,core): "Launch Orchestrator (clean context)" button
Adds a dashboard action that replaces the project's canonical
orchestrator with a fresh one — killing any existing orchestrator,
deleting its metadata, and spawning a new session with no carryover.
Why: users had no way to start an orchestrator with a clean slate from
the dashboard. Previous orchestrator context (conversation history,
stale state) silently carried over via the existing "Open Orchestrator"
flow, which only worked for first-time spawn anyway.
- core: new SessionManager.relaunchOrchestrator(config) that kills +
deletes existing metadata then calls spawnOrchestrator. Ignores
project.orchestratorSessionStrategy — replacement is the whole point.
Coalesces concurrent calls via a dedicated relaunchOrchestratorPromises
map (separate from ensureOrchestratorPromises since the semantics
differ — a relaunch behind an ensure must not return the existing
session).
- web: POST /api/orchestrators accepts { clean: true } to route to the
new method. OrchestratorSelector renders a "Launch Orchestrator
(clean context)" button that uses window.confirm() before discarding
an existing orchestrator; no confirm when none exists.
Closes #1900, closes #1080.
* fix(core,web): address PR #1904 review
- core: cross-map race between ensureOrchestrator and relaunchOrchestrator.
Each now awaits the other's in-flight promise (keyed by sessionId) before
proceeding. Prevents (a) relaunch skipping the kill while ensure's
spawnOrchestrator is mid-reservation, and (b) ensure returning a session
that relaunch is about to kill. Adds two race regression tests.
- web: align handleSpawnNew with handleRelaunchClean via the void expression
form; add "Launching..." in-progress label to the clean-context button and
a test that asserts it renders during POST.
* refactor(web): rip out Orchestrator Selector page; relocate clean-launch action
There is only ever one orchestrator per project, so the /orchestrators
selector page is meaningless. Delete it along with its component, tests,
and the unused mapSessionsToOrchestrators util. Drop GET /api/orchestrators
(only consumer was the deleted page). Remove /orchestrators from project
revalidate lists.
The "Launch Orchestrator (clean context)" action that previously lived on
the deleted page now appears in two places:
- Dashboard header: a "Relaunch (clean)" button renders alongside the
Orchestrator link whenever a project orchestrator exists. Uses
window.confirm before discarding state.
- Orchestrator session page: a "Relaunch (clean)" button in the
SessionDetailHeader for live orchestrator sessions, calling
POST /api/orchestrators with clean:true and reloading the session view.
* refactor(web): remove Relaunch (clean) action from the Dashboard
Keep the clean-launch action only on the orchestrator session page —
that's where the user has the context to decide on a destructive
restart. The Dashboard header just links to the orchestrator (or shows
the existing Spawn Orchestrator button when none exists).
* fix(web): surface relaunch failures with an inline error banner
After confirm + POST /api/orchestrators with clean:true, the previous
implementation only logged failures to console.error — leaving the user
on a stale page with no signal that the destructive action partially
executed. relaunchOrchestrator kills before respawning, so a failed
respawn means the server has no orchestrator while the client still
renders the old session view.
Add local relaunchError state, set it on catch (parsed from the JSON
error response when available), and render a dismissible error banner
above the terminal area. The banner explicitly warns the user that the
previous orchestrator may already be terminated and points them at the
project dashboard to retry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web,core): address PR #1904 review from @i-trytoohard
- web: navigate to the new orchestrator's session path (from POST
response) instead of window.location.reload(). Orchestrator session
IDs are fixed per project so the path is the same in practice, but
reading from the response is the right contract and a hard nav forces
the terminal WebSocket to reconnect cleanly against the new tmux.
- web: remove the `!terminalEnded` gate on the Relaunch (clean) button
in SessionDetailHeader. Terminated orchestrators are exactly when the
user wants to relaunch — hiding the button there was wrong.
- core: log a warning instead of silently swallowing when an in-flight
cross-map promise (ensure waiting on relaunch, or relaunch waiting on
ensure) rejects before its caller proceeds. The catch-and-continue
semantics are correct (the caller will re-check state anyway) but
invisible failures were a debugging hazard.
Adds a regression test that the button stays visible on terminated
orchestrator sessions and that successful relaunch navigates via
window.location.href.
This commit is contained in:
parent
ce6b47adf8
commit
94981dc0fd
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-web": minor
|
||||
---
|
||||
|
||||
feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
|
||||
|
||||
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
readMetadata,
|
||||
readMetadataRaw,
|
||||
} from "../../metadata.js";
|
||||
import { getProjectWorktreesDir } from "../../paths.js";
|
||||
import { getProjectDir, getProjectWorktreesDir } from "../../paths.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
|
|
@ -2489,4 +2489,183 @@ describe("spawn", () => {
|
|||
});
|
||||
|
||||
});
|
||||
|
||||
describe("relaunchOrchestrator", () => {
|
||||
it("spawns a fresh orchestrator when none exists", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(session.status).toBe("working");
|
||||
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("kills and replaces an existing orchestrator regardless of strategy", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
|
||||
expect(mockWorkspace.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: "app-orchestrator" }),
|
||||
);
|
||||
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
|
||||
expect(meta?.["status"]).toBe("working");
|
||||
expect(meta?.["runtimeHandle"]).not.toContain("old-rt");
|
||||
});
|
||||
|
||||
it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => {
|
||||
const configWithIgnore: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"]!,
|
||||
orchestratorSessionStrategy: "ignore",
|
||||
},
|
||||
},
|
||||
};
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry });
|
||||
|
||||
await sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
|
||||
expect(mockWorkspace.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces concurrent relaunch calls into a single replacement", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const [s1, s2] = await Promise.all([
|
||||
sm.relaunchOrchestrator({ projectId: "my-app" }),
|
||||
sm.relaunchOrchestrator({ projectId: "my-app" }),
|
||||
]);
|
||||
|
||||
expect(s1.id).toBe("app-orchestrator");
|
||||
expect(s2.id).toBe("app-orchestrator");
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
let releaseWorkspace: () => void = () => {};
|
||||
const blockingWorkspace = new Promise<void>((resolve) => {
|
||||
releaseWorkspace = resolve;
|
||||
});
|
||||
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
|
||||
await blockingWorkspace;
|
||||
return {
|
||||
path: join(tmpDir, "ws-relaunch"),
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
await Promise.resolve();
|
||||
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
|
||||
|
||||
releaseWorkspace();
|
||||
|
||||
const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]);
|
||||
expect(relaunched.id).toBe("app-orchestrator");
|
||||
expect(ensured.id).toBe("app-orchestrator");
|
||||
// Both should resolve to the *post-relaunch* session, not the killed one.
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
|
||||
});
|
||||
|
||||
it("waits for an in-flight ensureOrchestrator before replacing", async () => {
|
||||
let releaseWorkspace: () => void = () => {};
|
||||
const blockingWorkspace = new Promise<void>((resolve) => {
|
||||
releaseWorkspace = resolve;
|
||||
});
|
||||
const ensureWorkspacePath = join(tmpDir, "ws-ensure");
|
||||
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
|
||||
await blockingWorkspace;
|
||||
return {
|
||||
path: ensureWorkspacePath,
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
|
||||
// Yield so ensure can register itself in the in-flight map.
|
||||
await Promise.resolve();
|
||||
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
releaseWorkspace();
|
||||
|
||||
const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]);
|
||||
|
||||
expect(ensured.id).toBe("app-orchestrator");
|
||||
expect(relaunched.id).toBe("app-orchestrator");
|
||||
// Relaunch's kill must run, destroying the runtime ensure just spawned.
|
||||
expect(mockRuntime.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites the orchestrator-prompt file with the new system prompt", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
await sm.relaunchOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "FRESH ORCHESTRATOR PROMPT",
|
||||
});
|
||||
|
||||
const promptPath = join(
|
||||
getProjectDir("my-app"),
|
||||
"orchestrator-prompt-app-orchestrator.md",
|
||||
);
|
||||
expect(existsSync(promptPath)).toBe(true);
|
||||
expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager {
|
|||
.mockResolvedValue(
|
||||
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
|
||||
),
|
||||
relaunchOrchestrator: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
|
||||
),
|
||||
restore: vi.fn().mockResolvedValue(makeSession()),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
listCached: vi.fn().mockResolvedValue([]),
|
||||
|
|
|
|||
|
|
@ -538,6 +538,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
expiresAt: number;
|
||||
} | null = null;
|
||||
const ensureOrchestratorPromises = new Map<string, Promise<Session>>();
|
||||
const relaunchOrchestratorPromises = new Map<string, Promise<Session>>();
|
||||
|
||||
function invalidateCache(): void {
|
||||
sessionCache = null;
|
||||
|
|
@ -1815,6 +1816,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
const sessionId = getOrchestratorSessionId(project);
|
||||
|
||||
// If a relaunch is mid-flight for this sessionId, wait it out — otherwise
|
||||
// we could return a session that relaunch is about to kill, or race the
|
||||
// relaunch's spawnOrchestrator on the same reservation.
|
||||
const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId);
|
||||
if (pendingRelaunch) {
|
||||
await pendingRelaunch.catch((err) => {
|
||||
console.warn(
|
||||
`[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await get(sessionId);
|
||||
if (existing) {
|
||||
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
|
||||
|
|
@ -1876,6 +1891,61 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
return promise;
|
||||
}
|
||||
|
||||
async function relaunchOrchestratorInternal(
|
||||
orchestratorConfig: OrchestratorSpawnConfig,
|
||||
): Promise<Session> {
|
||||
const project = config.projects[orchestratorConfig.projectId];
|
||||
if (!project) {
|
||||
throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
|
||||
}
|
||||
const sessionId = getOrchestratorSessionId(project);
|
||||
const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId);
|
||||
|
||||
// If ensureOrchestrator is mid-flight for this sessionId, wait it out.
|
||||
// Otherwise get() would return null (metadata not yet written) and we'd
|
||||
// skip the kill, then race the in-flight spawnOrchestrator on the same
|
||||
// reservation — surfacing "session already exists" instead of replacing.
|
||||
const pendingEnsure = ensureOrchestratorPromises.get(sessionId);
|
||||
if (pendingEnsure) {
|
||||
await pendingEnsure.catch((err) => {
|
||||
console.warn(
|
||||
`[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await get(sessionId);
|
||||
if (existing) {
|
||||
const existingAgent = resolveSelectionForSession(
|
||||
project,
|
||||
sessionId,
|
||||
readMetadataRaw(sessionsDir, sessionId) ?? {},
|
||||
).agentName;
|
||||
await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" });
|
||||
deleteMetadata(sessionsDir, sessionId);
|
||||
}
|
||||
return spawnOrchestrator(orchestratorConfig);
|
||||
}
|
||||
|
||||
async function relaunchOrchestrator(
|
||||
orchestratorConfig: OrchestratorSpawnConfig,
|
||||
): Promise<Session> {
|
||||
const project = config.projects[orchestratorConfig.projectId];
|
||||
if (!project) {
|
||||
throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
|
||||
}
|
||||
const sessionId = getOrchestratorSessionId(project);
|
||||
const existingPromise = relaunchOrchestratorPromises.get(sessionId);
|
||||
if (existingPromise) return existingPromise;
|
||||
|
||||
const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => {
|
||||
relaunchOrchestratorPromises.delete(sessionId);
|
||||
});
|
||||
relaunchOrchestratorPromises.set(sessionId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function list(projectId?: string): Promise<Session[]> {
|
||||
const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => {
|
||||
if (projectId && entryProjectId !== projectId) return [];
|
||||
|
|
@ -3054,6 +3124,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
spawn,
|
||||
spawnOrchestrator,
|
||||
ensureOrchestrator,
|
||||
relaunchOrchestrator,
|
||||
restore,
|
||||
list,
|
||||
listCached,
|
||||
|
|
|
|||
|
|
@ -1845,6 +1845,13 @@ export interface SessionManager {
|
|||
spawn(config: SessionSpawnConfig): Promise<Session>;
|
||||
spawnOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
|
||||
ensureOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
|
||||
/**
|
||||
* Replace the canonical orchestrator with a fresh one. If an orchestrator
|
||||
* already exists for the project, it is killed, its metadata deleted, and a
|
||||
* new orchestrator spawned with no carryover state. Ignores
|
||||
* `orchestratorSessionStrategy` — replacement is the whole point.
|
||||
*/
|
||||
relaunchOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>;
|
||||
restore(sessionId: SessionId): Promise<Session>;
|
||||
list(projectId?: string): Promise<Session[]>;
|
||||
get(sessionId: SessionId): Promise<Session | null>;
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ const mockSessionManager: SessionManager = {
|
|||
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
ensureOrchestrator: vi.fn(),
|
||||
relaunchOrchestrator: vi.fn(),
|
||||
remap: vi.fn(async () => "ses_mock"),
|
||||
restore: vi.fn(async (id: string) => {
|
||||
const session = testSessions.find((s) => s.id === id);
|
||||
|
|
@ -232,7 +233,7 @@ import {
|
|||
GET as sessionDetailGET,
|
||||
PATCH as sessionDetailPATCH,
|
||||
} from "@/app/api/sessions/[id]/route";
|
||||
import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
|
||||
import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route";
|
||||
import { POST as spawnPOST } from "@/app/api/spawn/route";
|
||||
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
|
||||
import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route";
|
||||
|
|
@ -1173,53 +1174,49 @@ describe("API Routes", () => {
|
|||
expect(data.recovery).toBe("reuse-or-recreate-workspace");
|
||||
expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/orchestrators", () => {
|
||||
it("returns orchestrators for a project", async () => {
|
||||
const orchestrator = makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
it("calls relaunchOrchestrator instead of spawnOrchestrator when clean is true", async () => {
|
||||
(mockSessionManager.relaunchOrchestrator as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
);
|
||||
|
||||
const req = makeRequest("/api/orchestrators", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: "my-app", clean: true }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce([orchestrator]);
|
||||
const res = await orchestratorsPOST(req);
|
||||
|
||||
const res = await orchestratorsGET(
|
||||
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.orchestrators).toHaveLength(1);
|
||||
expect(data.orchestrators[0].id).toBe("my-app-orchestrator");
|
||||
expect(data.projectName).toBe("My App");
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockSessionManager.relaunchOrchestrator).toHaveBeenCalledWith({
|
||||
projectId: "my-app",
|
||||
systemPrompt: expect.stringContaining("# My App Orchestrator"),
|
||||
});
|
||||
expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when project parameter is missing", async () => {
|
||||
const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators"));
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/Missing project query parameter/);
|
||||
});
|
||||
it("uses spawnOrchestrator when clean is false or omitted", async () => {
|
||||
(mockSessionManager.spawnOrchestrator as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
);
|
||||
|
||||
it("returns 404 for unknown project", async () => {
|
||||
const res = await orchestratorsGET(
|
||||
makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/Unknown project/);
|
||||
});
|
||||
const req = makeRequest("/api/orchestrators", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: "my-app", clean: false }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
await orchestratorsPOST(req);
|
||||
|
||||
it("returns 500 when list fails", async () => {
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("boom"),
|
||||
);
|
||||
const res = await orchestratorsGET(
|
||||
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("boom");
|
||||
expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalled();
|
||||
expect(mockSessionManager.relaunchOrchestrator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import OrchestratorsRoute from "@/app/orchestrators/page";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { getAllProjects } from "@/lib/project-name";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services", () => ({
|
||||
getServices: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project-name", () => ({
|
||||
getAllProjects: vi.fn(),
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Orchestrators Page (OrchestratorsRoute)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the page with searchParams and listed orchestrators", async () => {
|
||||
const mockSessionManager = {
|
||||
list: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "app-orchestrator",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
metadata: { role: "orchestrator" },
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
(getServices as any).mockResolvedValue({
|
||||
config: {
|
||||
projects: {
|
||||
"my-app": { name: "My App", sessionPrefix: "app" },
|
||||
},
|
||||
},
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
(getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]);
|
||||
|
||||
const searchParams = Promise.resolve({ project: "my-app" });
|
||||
const jsx = await OrchestratorsRoute({ searchParams });
|
||||
render(jsx);
|
||||
|
||||
expect(screen.getByText("My App")).toBeInTheDocument();
|
||||
expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error when project is missing in searchParams", async () => {
|
||||
const searchParams = Promise.resolve({});
|
||||
const jsx = await OrchestratorsRoute({ searchParams });
|
||||
render(jsx);
|
||||
|
||||
expect(screen.getByText("Missing Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error when project is not found in config", async () => {
|
||||
(getServices as any).mockResolvedValue({
|
||||
config: { projects: {} },
|
||||
sessionManager: { list: vi.fn() },
|
||||
});
|
||||
(getAllProjects as any).mockReturnValue([]);
|
||||
|
||||
const searchParams = Promise.resolve({ project: "ghost" });
|
||||
const jsx = await OrchestratorsRoute({ searchParams });
|
||||
render(jsx);
|
||||
|
||||
expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles service errors gracefully", async () => {
|
||||
(getServices as any).mockRejectedValue(new Error("Database down"));
|
||||
|
||||
const searchParams = Promise.resolve({ project: "my-app" });
|
||||
const jsx = await OrchestratorsRoute({ searchParams });
|
||||
render(jsx);
|
||||
|
||||
expect(screen.getByText("Database down")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { generateOrchestratorPrompt, generateSessionPrefix } from "@aoagents/ao-core";
|
||||
import { generateOrchestratorPrompt } from "@aoagents/ao-core";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { validateIdentifier, validateConfiguredProject } from "@/lib/validation";
|
||||
import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils";
|
||||
|
||||
function classifySpawnError(projectId: string, error: unknown): {
|
||||
status: number;
|
||||
|
|
@ -35,46 +34,6 @@ function classifySpawnError(projectId: string, error: unknown): {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/orchestrators?project=<projectId>
|
||||
* List existing orchestrator sessions for a project.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const projectId = request.nextUrl.searchParams.get("project");
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const projectErr = validateIdentifier(projectId, "projectId");
|
||||
if (projectErr) {
|
||||
return NextResponse.json({ error: projectErr }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { config, sessionManager } = await getServices();
|
||||
const configProjectErr = validateConfiguredProject(config.projects, projectId);
|
||||
if (configProjectErr) {
|
||||
return NextResponse.json({ error: configProjectErr }, { status: 404 });
|
||||
}
|
||||
const project = config.projects[projectId];
|
||||
const sessionPrefix = project.sessionPrefix ?? projectId;
|
||||
|
||||
const allSessions = await sessionManager.list(projectId);
|
||||
const allSessionPrefixes = Object.entries(config.projects).map(
|
||||
([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
|
||||
);
|
||||
const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes);
|
||||
|
||||
return NextResponse.json({ orchestrators, projectName: project.name });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to list orchestrators" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!body) {
|
||||
|
|
@ -86,6 +45,8 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: projectErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const clean = body.clean === true;
|
||||
|
||||
try {
|
||||
const { config, sessionManager } = await getServices();
|
||||
const projectId = body.projectId as string;
|
||||
|
|
@ -96,7 +57,9 @@ export async function POST(request: NextRequest) {
|
|||
const project = config.projects[projectId];
|
||||
|
||||
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
|
||||
const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt });
|
||||
const session = clean
|
||||
? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt })
|
||||
: await sessionManager.spawnOrchestrator({ projectId, systemPrompt });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function sanitizeString(value: unknown): string | undefined {
|
|||
}
|
||||
|
||||
function revalidateProjectPaths(projectId: string): void {
|
||||
for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) {
|
||||
for (const route of ["/", "/prs", `/projects/${projectId}`]) {
|
||||
try {
|
||||
revalidatePath(route);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function isGitRepository(projectPath: string): boolean {
|
|||
}
|
||||
|
||||
function revalidatePortfolioPaths(projectId: string): void {
|
||||
for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) {
|
||||
for (const route of ["/", "/prs", `/projects/${projectId}`]) {
|
||||
try {
|
||||
revalidatePath(route);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { getAllProjects } from "@/lib/project-name";
|
||||
import { generateSessionPrefix } from "@aoagents/ao-core";
|
||||
import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
searchParams: Promise<{ project?: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const searchParams = await props.searchParams;
|
||||
const projectId = searchParams.project;
|
||||
let projectName = "Orchestrator";
|
||||
if (projectId) {
|
||||
const projects = getAllProjects();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (project) {
|
||||
projectName = project.name;
|
||||
}
|
||||
}
|
||||
return { title: { absolute: `ao | ${projectName} - Orchestrator` } };
|
||||
}
|
||||
|
||||
export default async function OrchestratorsRoute(props: {
|
||||
searchParams: Promise<{ project?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const projectId = searchParams.project;
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[var(--color-bg-base)]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
Missing Project
|
||||
</h1>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">
|
||||
No project specified. Please provide a project parameter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let orchestrators: Orchestrator[] = [];
|
||||
let projectName = projectId;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
const { config, sessionManager } = await getServices();
|
||||
const project = config.projects[projectId];
|
||||
|
||||
if (!project) {
|
||||
error = `Project "${projectId}" not found`;
|
||||
} else {
|
||||
projectName = project.name;
|
||||
const sessionPrefix = project.sessionPrefix ?? projectId;
|
||||
const allSessions = await sessionManager.list(projectId);
|
||||
const allSessionPrefixes = Object.entries(config.projects).map(
|
||||
([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
|
||||
);
|
||||
orchestrators = mapSessionsToOrchestrators(
|
||||
allSessions,
|
||||
sessionPrefix,
|
||||
project.name,
|
||||
allSessionPrefixes,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load orchestrators";
|
||||
}
|
||||
|
||||
return (
|
||||
<OrchestratorSelector
|
||||
orchestrators={orchestrators}
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
|
||||
|
||||
export interface Orchestrator {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
status: string;
|
||||
activity: string | null;
|
||||
createdAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
}
|
||||
|
||||
interface OrchestratorSelectorProps {
|
||||
orchestrators: Orchestrator[];
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoDate: string | null): string {
|
||||
if (!isoDate) return "Unknown";
|
||||
const date = new Date(isoDate);
|
||||
const timestamp = date.getTime();
|
||||
// Guard against invalid dates (NaN) and future timestamps
|
||||
if (!Number.isFinite(timestamp)) return "Unknown";
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - timestamp;
|
||||
// Handle future timestamps
|
||||
if (diffMs < 0) return "Just now";
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "working":
|
||||
return "var(--color-status-working)";
|
||||
case "spawning":
|
||||
return "var(--color-status-attention)";
|
||||
case "pr_open":
|
||||
case "review_pending":
|
||||
case "approved":
|
||||
case "mergeable":
|
||||
return "var(--color-status-ready)";
|
||||
case "ci_failed":
|
||||
case "changes_requested":
|
||||
return "var(--color-status-error)";
|
||||
case "merged":
|
||||
case "done":
|
||||
case "killed":
|
||||
case "terminated":
|
||||
return "var(--color-text-tertiary)";
|
||||
default:
|
||||
return "var(--color-text-secondary)";
|
||||
}
|
||||
}
|
||||
|
||||
function getActivityLabel(activity: string | null): string {
|
||||
if (!activity) return "";
|
||||
switch (activity) {
|
||||
case "active":
|
||||
return "Active";
|
||||
case "ready":
|
||||
return "Ready";
|
||||
case "idle":
|
||||
return "Idle";
|
||||
case "waiting_input":
|
||||
return "Waiting";
|
||||
case "blocked":
|
||||
return "Blocked";
|
||||
case "exited":
|
||||
return "Exited";
|
||||
default:
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
export function OrchestratorSelector({
|
||||
orchestrators,
|
||||
projectId,
|
||||
projectName,
|
||||
error,
|
||||
}: OrchestratorSelectorProps) {
|
||||
const router = useRouter();
|
||||
const [isSpawning, setIsSpawning] = useState(false);
|
||||
const [spawnError, setSpawnError] = useState<string | null>(null);
|
||||
const spawnLockRef = useRef(false);
|
||||
|
||||
const handleSpawnNew = async () => {
|
||||
// Synchronous re-entrancy guard: React state updates are async,
|
||||
// so two clicks before rerender would fire two POSTs without this.
|
||||
if (spawnLockRef.current) return;
|
||||
spawnLockRef.current = true;
|
||||
setIsSpawning(true);
|
||||
setSpawnError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/orchestrators", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to spawn orchestrator");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
router.push(projectSessionPath(projectId, data.orchestrator.id));
|
||||
} catch (err) {
|
||||
setSpawnError(err instanceof Error ? err.message : "Failed to spawn orchestrator");
|
||||
} finally {
|
||||
setIsSpawning(false);
|
||||
spawnLockRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[var(--color-bg-base)]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Error</h1>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">{error}</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="orchestrator-btn mt-4 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[var(--color-bg-base)]">
|
||||
{/* Header */}
|
||||
<header className="nav-glass sticky top-0 z-10 border-b border-[var(--color-border-subtle)] px-6 py-4">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{projectName}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">Project orchestrator</p>
|
||||
</div>
|
||||
<Link
|
||||
href={projectDashboardPath(projectId)}
|
||||
className="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Info banner */}
|
||||
<div className="mb-6 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||
AO keeps one main orchestrator per project. Opening or spawning here reuses that
|
||||
canonical session instead of creating another duplicate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Existing orchestrators */}
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Main Session
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{orchestrators.map((orch) => (
|
||||
<Link
|
||||
key={orch.id}
|
||||
href={projectSessionPath(orch.projectId, orch.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4",
|
||||
"transition-all hover:border-[var(--color-border-default)] hover:shadow-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: getStatusColor(orch.status) }}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-[var(--color-text-primary)]">{orch.id}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<span className="capitalize">{orch.status.replace(/_/g, " ")}</span>
|
||||
{orch.activity && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{getActivityLabel(orch.activity)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-[var(--color-text-tertiary)]">
|
||||
<div>Created {formatRelativeTime(orch.createdAt)}</div>
|
||||
{orch.lastActivityAt && (
|
||||
<div>Active {formatRelativeTime(orch.lastActivityAt)}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start new section */}
|
||||
<div className="border-t border-[var(--color-border-subtle)] pt-6">
|
||||
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Open Orchestrator
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSpawnNew}
|
||||
disabled={isSpawning}
|
||||
className={cn(
|
||||
"orchestrator-btn w-full px-4 py-3 text-sm font-medium",
|
||||
"disabled:cursor-wait disabled:opacity-70",
|
||||
)}
|
||||
>
|
||||
{isSpawning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Opening orchestrator...
|
||||
</span>
|
||||
) : (
|
||||
"Open Orchestrator"
|
||||
)}
|
||||
</button>
|
||||
{spawnError && (
|
||||
<p className="mt-2 text-sm text-[var(--color-status-error)]">{spawnError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ export function SessionDetail({
|
|||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
const [relaunchError, setRelaunchError] = useState<string | null>(null);
|
||||
const pr = session.pr;
|
||||
const terminalEnded = isDashboardSessionTerminal(session);
|
||||
const isRestorable = isDashboardSessionRestorable(session);
|
||||
|
|
@ -119,6 +120,47 @@ export function SessionDetail({
|
|||
}
|
||||
}, [session.id]);
|
||||
|
||||
const handleRelaunchClean = useCallback(async () => {
|
||||
const confirmed = window.confirm(
|
||||
"This will discard the current orchestrator's conversation and state. Continue?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setRelaunchError(null);
|
||||
try {
|
||||
const res = await fetch("/api/orchestrators", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: session.projectId, clean: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Surface server-side errors. Note: a failure here may indicate the
|
||||
// existing orchestrator was killed but respawn failed — the banner
|
||||
// tells the user the previous session was terminated so they don't
|
||||
// assume the page they're looking at is still live.
|
||||
let message = "";
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
message = data.error ?? "";
|
||||
} catch {
|
||||
message = await res.text().catch(() => "");
|
||||
}
|
||||
throw new Error(message || `HTTP ${res.status}`);
|
||||
}
|
||||
// Hard-navigate to the freshly spawned orchestrator's session page.
|
||||
// Orchestrator session IDs are fixed per project, so this is the same
|
||||
// path in practice — but reading from the response keeps us correct if
|
||||
// the contract ever changes (and a hard nav forces the terminal
|
||||
// WebSocket to reconnect against the new tmux session).
|
||||
const data = (await res.json()) as { orchestrator?: { id: string } };
|
||||
const newId = data.orchestrator?.id ?? session.id;
|
||||
window.location.href = projectSessionPath(session.projectId, newId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator";
|
||||
console.error("Failed to relaunch orchestrator:", err);
|
||||
setRelaunchError(message);
|
||||
}
|
||||
}, [session.id, session.projectId]);
|
||||
|
||||
const orchestratorHref = useMemo(() => {
|
||||
if (isOrchestrator) return projectSessionPath(session.projectId, session.id);
|
||||
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
|
||||
|
|
@ -158,6 +200,7 @@ export function SessionDetail({
|
|||
onToggleSidebar={handleToggleSidebar}
|
||||
onRestore={handleRestore}
|
||||
onKill={handleKill}
|
||||
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -192,6 +235,31 @@ export function SessionDetail({
|
|||
|
||||
<div className="dashboard-main dashboard-main--desktop">
|
||||
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
|
||||
{relaunchError ? (
|
||||
<div
|
||||
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
|
||||
<div className="mt-1 text-[var(--color-text-secondary)]">
|
||||
The previous orchestrator may already be terminated. Try again from the
|
||||
project dashboard.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRelaunchError(null)}
|
||||
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!showTerminal ? (
|
||||
<div className="session-detail-terminal-placeholder h-full" />
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface SessionDetailHeaderProps {
|
|||
onToggleSidebar: () => void;
|
||||
onRestore: () => void;
|
||||
onKill: () => void;
|
||||
onRelaunchClean?: () => void;
|
||||
}
|
||||
|
||||
function normalizeActivityLabelForClass(activityLabel: string): string {
|
||||
|
|
@ -80,6 +81,7 @@ export function SessionDetailHeader({
|
|||
onToggleSidebar,
|
||||
onRestore,
|
||||
onKill,
|
||||
onRelaunchClean,
|
||||
}: SessionDetailHeaderProps) {
|
||||
const pr = session.pr;
|
||||
const allGreen = pr ? isPRMergeReady(pr) : false;
|
||||
|
|
@ -302,6 +304,30 @@ export function SessionDetailHeader({
|
|||
</button>
|
||||
) : null}
|
||||
|
||||
{isOrchestrator && onRelaunchClean ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-app-btn dashboard-app-btn--amber"
|
||||
onClick={onRelaunchClean}
|
||||
aria-label="Launch Orchestrator (clean context)"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
|
||||
<path d="M4 5v4h4" />
|
||||
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
|
||||
<path d="M20 19v-4h-4" />
|
||||
</svg>
|
||||
<span className="topbar-btn-label">Relaunch (clean)</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{orchestratorHref ? (
|
||||
<a
|
||||
href={orchestratorHref}
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OrchestratorSelector } from "../OrchestratorSelector";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
const mockOrchestrators = [
|
||||
{
|
||||
id: "app-orchestrator",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
||||
lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
orchestrators: mockOrchestrators,
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe("OrchestratorSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPush.mockClear();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("renders orchestrator list", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays project name in header", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("My Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project orchestrator")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute(
|
||||
"href",
|
||||
"/projects/my-project",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains that orchestrator opening reuses the canonical session", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error state", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} orchestrators={[]} error="Project not found" />);
|
||||
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows open orchestrator button", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("spawns new orchestrator on button click and navigates", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
orchestrator: { id: "app-orchestrator" },
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /open orchestrator/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while spawning", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /open orchestrator/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when spawn fails", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "Failed to spawn" }),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /open orchestrator/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to spawn")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("links to orchestrator session page", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: /app-orchestrator/i });
|
||||
expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator");
|
||||
});
|
||||
|
||||
it("displays status and activity for each orchestrator", () => {
|
||||
render(<OrchestratorSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("working")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("covers relative time for days and status colors/labels", () => {
|
||||
const wideOrchestrators = [
|
||||
{
|
||||
id: "orch-2",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "ci_failed",
|
||||
activity: "waiting_input",
|
||||
createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago
|
||||
lastActivityAt: null,
|
||||
},
|
||||
{
|
||||
id: "orch-3",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "killed",
|
||||
activity: "ready",
|
||||
createdAt: new Date(Date.now() - 1000).toISOString(), // Just now
|
||||
lastActivityAt: null,
|
||||
},
|
||||
{
|
||||
id: "orch-4",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "unknown",
|
||||
activity: "blocked",
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: null,
|
||||
},
|
||||
{
|
||||
id: "orch-5",
|
||||
projectId: "my-project",
|
||||
projectName: "My Project",
|
||||
status: "mergeable",
|
||||
activity: "exited",
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OrchestratorSelector {...defaultProps} orchestrators={wideOrchestrators} />);
|
||||
|
||||
expect(screen.getByText(/2d ago/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Waiting/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ready/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Blocked/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Exited/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ci failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("formatRelativeTime edge cases", () => {
|
||||
it("shows Unknown for invalid date strings", () => {
|
||||
const orchestratorsWithInvalidDate = [
|
||||
{
|
||||
...mockOrchestrators[0],
|
||||
createdAt: "not-a-valid-date",
|
||||
lastActivityAt: null,
|
||||
},
|
||||
];
|
||||
render(
|
||||
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithInvalidDate} />,
|
||||
);
|
||||
|
||||
// The "Created Unknown" text should appear for invalid dates
|
||||
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Just now for future timestamps", () => {
|
||||
const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future
|
||||
const orchestratorsWithFutureDate = [
|
||||
{
|
||||
...mockOrchestrators[0],
|
||||
createdAt: futureDate,
|
||||
lastActivityAt: null,
|
||||
},
|
||||
];
|
||||
render(
|
||||
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithFutureDate} />,
|
||||
);
|
||||
|
||||
// Future timestamps should show "Just now" instead of negative values
|
||||
expect(screen.getByText(/Created Just now/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Unknown for null dates", () => {
|
||||
const orchestratorsWithNullDate = [
|
||||
{
|
||||
...mockOrchestrators[0],
|
||||
createdAt: null,
|
||||
lastActivityAt: null,
|
||||
},
|
||||
];
|
||||
render(<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithNullDate} />);
|
||||
|
||||
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -304,6 +304,161 @@ describe("SessionDetail desktop layout", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const hrefSetter = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
...window.location,
|
||||
set href(value: string) {
|
||||
hrefSetter(value);
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/orchestrators") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
orchestrator: { id: "my-app-orchestrator", projectId: "my-app" },
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: true, json: async () => ({}), text: async () => "" } as Response;
|
||||
});
|
||||
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
summary: "Project orchestrator",
|
||||
})}
|
||||
isOrchestrator
|
||||
orchestratorZones={{
|
||||
merge: 0,
|
||||
respond: 0,
|
||||
review: 0,
|
||||
pending: 0,
|
||||
working: 0,
|
||||
done: 0,
|
||||
}}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", {
|
||||
name: /launch orchestrator \(clean context\)/i,
|
||||
});
|
||||
fireEvent.click(relaunchBtn);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
await act(async () => {});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: "my-app", clean: true }),
|
||||
});
|
||||
expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
status: "terminated",
|
||||
activity: "exited",
|
||||
summary: "Project orchestrator",
|
||||
pr: null,
|
||||
})}
|
||||
isOrchestrator
|
||||
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("button", {
|
||||
name: /launch orchestrator \(clean context\)/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a relaunch error banner when POST fails after confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/orchestrators") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: "kill+respawn failed" }),
|
||||
text: async () => "kill+respawn failed",
|
||||
} as Response;
|
||||
}
|
||||
return { ok: true, json: async () => ({}), text: async () => "" } as Response;
|
||||
});
|
||||
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "my-app-orchestrator",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
summary: "Project orchestrator",
|
||||
})}
|
||||
isOrchestrator
|
||||
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("banner")).getByRole("button", {
|
||||
name: /launch orchestrator \(clean context\)/i,
|
||||
}),
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent(/kill\+respawn failed/i);
|
||||
expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i);
|
||||
|
||||
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not render Relaunch (clean) on worker sessions", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
})}
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /launch orchestrator \(clean context\)/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores without using router refresh on the client-only session page", async () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import {
|
||||
isOrchestratorSession,
|
||||
isTerminalSession,
|
||||
type Session,
|
||||
} from "@aoagents/ao-core/types";
|
||||
import type { Orchestrator } from "@/components/OrchestratorSelector";
|
||||
|
||||
export function getOrchestratorSessionId(project: { sessionPrefix: string }): string {
|
||||
return `${project.sessionPrefix}-orchestrator`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and map sessions to orchestrator DTOs.
|
||||
* Shared between page.tsx and API route to ensure consistent orchestrator listing.
|
||||
*/
|
||||
export function mapSessionsToOrchestrators(
|
||||
sessions: Session[],
|
||||
sessionPrefix: string,
|
||||
projectName: string,
|
||||
allSessionPrefixes?: string[],
|
||||
): Orchestrator[] {
|
||||
const canonicalId = getOrchestratorSessionId({ sessionPrefix });
|
||||
return sessions
|
||||
.filter((s) => isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s))
|
||||
.sort((a, b) => {
|
||||
if (a.id === canonicalId) return -1;
|
||||
if (b.id === canonicalId) return 1;
|
||||
return (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0);
|
||||
})
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
projectId: s.projectId,
|
||||
projectName,
|
||||
status: s.status,
|
||||
activity: s.activity,
|
||||
createdAt: s.createdAt?.toISOString() ?? null,
|
||||
lastActivityAt: s.lastActivityAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
Loading…
Reference in New Issue