Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8a5d529
docs: add agent architecture implementation plan
kwannoel Apr 29, 2026
2cba1c8
feat: add daemon gateway path handling
kwannoel Apr 29, 2026
1e113da
fix: harden daemon gateway path handling
kwannoel Apr 30, 2026
65c2263
feat: route daemon client through controller gateway
kwannoel Apr 30, 2026
bace83f
fix: remove stale frontend daemon token copy
kwannoel Apr 30, 2026
5af30e1
feat: proxy daemon http over unix socket
kwannoel Apr 30, 2026
2e7cca0
fix: remove daemon token gateway route
kwannoel Apr 30, 2026
35ca65e
test: cover daemon gateway route forwarding
kwannoel Apr 30, 2026
08ec38b
test: gate daemon reachable e2e harness
kwannoel Apr 30, 2026
489b519
fix: align controller and daemon e2e state dirs
kwannoel Apr 30, 2026
b114df1
test: require absolute daemon e2e state dirs
kwannoel Apr 30, 2026
8fdc4c4
fix: remove permissive cors from daemon gateway
kwannoel Apr 30, 2026
4d2a2e4
feat: proxy daemon streams through controller
kwannoel Apr 30, 2026
9c8cb55
fix: validate daemon stream origins
kwannoel Apr 30, 2026
1b1e17c
fix: preserve daemon stream proxy host
kwannoel Apr 30, 2026
c7802df
fix: trust only local daemon stream origins
kwannoel Apr 30, 2026
a997ace
feat: build controller workspace snapshots
kwannoel Apr 30, 2026
b68b3db
fix: clean up created daemon chats
kwannoel Apr 30, 2026
0491532
feat: model daemon chats and profiles in frontend
kwannoel Apr 30, 2026
430dd37
fix: align daemon frontend profile and trace types
kwannoel Apr 30, 2026
1d04418
fix: preserve daemon chat token span payloads
kwannoel Apr 30, 2026
c172458
feat: add agent creation workspace
kwannoel Apr 30, 2026
6d6bd68
fix: filter archived agent profiles
kwannoel Apr 30, 2026
4a6a5e1
fix: preserve agent profile save payloads
kwannoel Apr 30, 2026
f37c10f
feat: create chats from composer flow
kwannoel Apr 30, 2026
1902133
fix: harden composer chat creation flow
kwannoel Apr 30, 2026
e729dc1
feat: add chat agent routing controls
kwannoel Apr 30, 2026
3aae283
fix: complete chat routing controls
kwannoel Apr 30, 2026
97e57a1
fix: harden chat routing summaries
kwannoel Apr 30, 2026
86c4e99
fix: hydrate chat routing links
kwannoel Apr 30, 2026
6856099
fix: stabilize chat route retries
kwannoel Apr 30, 2026
7e70b88
feat: add agent observability views
kwannoel Apr 30, 2026
1aa322f
fix: complete observability detail views
kwannoel Apr 30, 2026
e0bf838
fix: harden chat metrics rendering
kwannoel Apr 30, 2026
40696b7
fix: filter chat metrics fallback traces
kwannoel Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,368 changes: 2,368 additions & 0 deletions docs/plans/2026-04-29-agent-architecture-implementation.md

Large diffs are not rendered by default.

53 changes: 29 additions & 24 deletions e2e/specs/chat-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { test, expect } from "@playwright/test";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process";
import { isAbsolute } from "node:path";

// User story:
// Actor: A user wanting to use the new "chat" workspace mode backed by the
Expand All @@ -13,11 +12,12 @@ import { spawn, type ChildProcess } from "node:child_process";
// Outcome: Empty state is shown when daemon is unreachable. After the daemon
// is started, retrying transitions out of the empty state.
//
// NOTE: The daemon-reachable leg of this test is skipped by default because
// it requires the daemon & fake_agent binaries to be built at the canonical
// paths below. The axum server (server/src/main.rs) exposes
// `/api/read_daemon_token` so the browser harness can load the daemon token
// when the daemon is running.
// NOTE: The daemon-reachable leg is skipped by default. It may run only when
// the daemon binaries exist and the caller opts into a shared harness with
// TCD_E2E_DAEMON_REACHABLE=1, THE_CONTROLLER_STATE_DIR=<absolute-shared-dir>,
// and TCD_STATE_DIR=<same-absolute-shared-dir>. Playwright starts the
// Controller before this spec runs, so the Controller and daemon must inherit
// the same absolute state dir for `/api/daemon/...` to target the same UDS.

const DAEMON_REPO = "/Users/noelkwan/projects/the-controller-daemon";
const DAEMON_BIN = `${DAEMON_REPO}/target/release/the-controller-daemon`;
Expand All @@ -39,8 +39,8 @@ test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ pag
await switchToChatMode(page);

// Core assertion: the DaemonEmptyState component renders its heading when
// the daemon cannot be reached. The Axum server exposes `read_daemon_token`,
// but this test does not start the daemon, so bootstrap fails deterministically.
// the daemon cannot be reached. This test does not start the daemon, so
// bootstrap fails deterministically through the same-origin gateway.
await expect(page.getByRole("heading", { name: "Daemon not running" })).toBeVisible({
timeout: 5_000,
});
Expand All @@ -58,26 +58,38 @@ test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ pag
});

// ---------------------------------------------------------------------------
// Daemon-reachable leg — SKIPPED unless the daemon binaries are built.
// Daemon-reachable leg — SKIPPED unless an explicit shared harness is present.
// ---------------------------------------------------------------------------
const daemonBinariesPresent = existsSync(DAEMON_BIN) && existsSync(FAKE_AGENT_BIN);
const daemonReachableHarnessEnabled = process.env.TCD_E2E_DAEMON_REACHABLE === "1";
const controllerStateDir = process.env.THE_CONTROLLER_STATE_DIR;
const daemonStateDir = process.env.TCD_STATE_DIR;
const sharedStateDir =
controllerStateDir &&
daemonStateDir &&
isAbsolute(controllerStateDir) &&
isAbsolute(daemonStateDir) &&
controllerStateDir === daemonStateDir
? daemonStateDir
: undefined;
const daemonReachableEnabled =
daemonBinariesPresent && daemonReachableHarnessEnabled && Boolean(sharedStateDir);

test.describe("chat mode with daemon reachable", () => {
test.skip(
!daemonBinariesPresent,
`daemon binaries not built at ${DAEMON_BIN} / ${FAKE_AGENT_BIN}. ` +
"Run scripts/chat-integration-daemon.sh build to produce them.",
!daemonReachableEnabled,
"daemon reachable e2e requires binaries plus " +
"TCD_E2E_DAEMON_REACHABLE=1 with matching absolute THE_CONTROLLER_STATE_DIR and TCD_STATE_DIR. " +
"Task 21 owns the full daemon harness.",
);

let daemon: ChildProcess | null = null;
let stateDir: string | null = null;

test.beforeAll(async () => {
stateDir = mkdtempSync(join(tmpdir(), "tcd-e2e-"));
daemon = spawn(DAEMON_BIN, [], {
env: {
...process.env,
TCD_STATE_DIR: stateDir,
TCD_STATE_DIR: sharedStateDir,
TCD_AGENT_CLAUDE_BINARY: FAKE_AGENT_BIN,
},
stdio: "pipe",
Expand All @@ -92,13 +104,6 @@ test.describe("chat mode with daemon reachable", () => {
await new Promise((r) => setTimeout(r, 300));
if (!daemon.killed) daemon.kill("SIGKILL");
}
if (stateDir) {
try {
rmSync(stateDir, { recursive: true, force: true });
} catch {
// best effort
}
}
});

test("workspace renders out of the empty state after Retry", async ({ page }) => {
Expand Down
22 changes: 22 additions & 0 deletions e2e/specs/observability.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";

// User story:
// Actor: A developer navigating workspace modes from the keyboard.
// Action: Opens the workspace picker and chooses Observe.
// Outcome: The agent trace workspace appears with a clear empty state.

test("observe mode opens the agent trace workspace", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 });

await page.keyboard.press("Space");
await expect(page.locator(".picker")).toBeVisible({ timeout: 3_000 });
await page.keyboard.press("o");

await expect(page.locator(".sidebar-header h2")).toHaveText("Observe", {
timeout: 3_000,
});
await expect(page.getByText("Select an agent to inspect.")).toBeVisible({
timeout: 10_000,
});
});
2 changes: 1 addition & 1 deletion server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ libc = "0.2"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
tokio-tungstenite = "0.28"

[[bin]]
name = "the-controller-server"
Expand Down
88 changes: 86 additions & 2 deletions server/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ use std::path::{Path, PathBuf};
use uuid::Uuid;

use crate::config;
use crate::models::{AutoWorkerQueueIssue, CommitInfo, GithubIssue, Project, SessionConfig};
use crate::models::{
AutoWorkerQueueIssue, ChatWorkspaceSnapshot, CommitInfo, GithubIssue, Project, SessionConfig,
};
use crate::state::AppState;
use crate::token_usage::{self, TokenDataPoint};
use crate::worktree::WorktreeManager;

pub mod daemon;
pub mod github;
pub mod kanban;

Expand Down Expand Up @@ -52,6 +53,61 @@ pub(crate) fn next_session_label(sessions: &[SessionConfig]) -> String {
format!("session-{}-{}", max_num + 1, short_id)
}

pub fn chat_workspace_snapshot(
project: &Project,
session: &SessionConfig,
focused: bool,
) -> ChatWorkspaceSnapshot {
ChatWorkspaceSnapshot {
project_id: project.id.to_string(),
workspace_id: session.id.to_string(),
path: session
.worktree_path
.clone()
.unwrap_or_else(|| project.repo_path.clone()),
label: session.label.clone(),
branch: session.worktree_branch.clone(),
focused,
}
}

pub fn daemon_chat_cleanup_path(chat_id: &str, created_by_handler: bool) -> Option<String> {
created_by_handler.then(|| format!("/api/daemon/chats/{chat_id}"))
}

pub fn validate_chat_workspace_project_impl(
state: &AppState,
project_id: &str,
) -> Result<(), String> {
let project_uuid = Uuid::parse_str(project_id).map_err(|e| e.to_string())?;
let storage = state.storage.lock().map_err(|e| e.to_string())?;
storage
.load_project(project_uuid)
.map(|_| ())
.map_err(|e| e.to_string())
}

pub fn load_chat_workspace_snapshot_impl(
state: &AppState,
project_id: &str,
session_id: &str,
focused: bool,
) -> Result<ChatWorkspaceSnapshot, String> {
let project_uuid = Uuid::parse_str(project_id).map_err(|e| e.to_string())?;
let session_uuid = Uuid::parse_str(session_id).map_err(|e| e.to_string())?;
let storage = state.storage.lock().map_err(|e| e.to_string())?;
let project = storage
.load_project(project_uuid)
.map_err(|e| e.to_string())?;
let session = project
.sessions
.iter()
.find(|session| session.id == session_uuid)
.ok_or_else(|| format!("session not found: {session_id}"))?;

Ok(chat_workspace_snapshot(&project, session, focused))
}

fn update_project_with_rollback<T, C, M, R, A>(
state: &AppState,
project_id: Uuid,
Expand Down Expand Up @@ -587,6 +643,34 @@ pub fn create_session_impl(
Ok(session_id.to_string())
}

#[allow(clippy::too_many_arguments)]
pub fn create_session_workspace_snapshot_impl(
state: &AppState,
project_id: String,
kind: Option<String>,
github_issue: Option<crate::models::GithubIssue>,
background: Option<bool>,
initial_prompt: Option<String>,
focused: bool,
) -> Result<(String, ChatWorkspaceSnapshot), String> {
let session_id = create_session_impl(
state,
project_id.clone(),
kind,
github_issue,
background,
initial_prompt,
)?;

match load_chat_workspace_snapshot_impl(state, &project_id, &session_id, focused) {
Ok(snapshot) => Ok((session_id, snapshot)),
Err(snapshot_err) => match close_session_impl(state, project_id, session_id, true) {
Ok(()) => Err(snapshot_err),
Err(cleanup_err) => Err(format!("{snapshot_err} (cleanup failed: {cleanup_err})")),
},
}
}

pub fn set_initial_prompt_impl(
state: &AppState,
project_id: String,
Expand Down
48 changes: 0 additions & 48 deletions server/src/commands/daemon.rs

This file was deleted.

Loading
Loading