diff --git a/AGENTS.md b/AGENTS.md index 862e40e0..813e56a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,9 +111,11 @@ that page's HTML instead of creating page-specific `.css` or `.js` files. The current references are: -- Dashboard: `design-system/prototypes/dashboard.html` -- Session: `design-system/prototypes/session.html` +- Global Home: `design-system/prototypes/dashboard.html` - Todos: `design-system/prototypes/todos.html` +- Automations: `design-system/prototypes/automations.html` +- Sessions: `design-system/prototypes/sessions.html` +- Session detail: `design-system/prototypes/session.html` When browser QA needs an HTTP origin, serve the prototype root without first changing into that directory: @@ -150,7 +152,7 @@ apps/server/src/ ├── resolve.ts # Request path resolution ├── validation.ts # Hono validator + Zod → BadRequestError thin adapter ├── serve-web.ts # Embedded web asset serving through an explicit asset-map input -├── routes/ # Route modules: setup, auth, dashboard, directories, files, +├── routes/ # Route modules: setup, auth, global-work, directories, files, │ # automations, global-events, hitl, mcp, messages, │ # permissions, projects, questions, sessions, todos └── events/ # global-event-bus.ts @@ -264,6 +266,7 @@ Delegation: `delegate(DelegationRequest)` creates a durable direct child; `resum .archcode/ ├── runtime/ # system-managed authority state │ ├── sessions/{id}/session.json +│ ├── attachments/{sessions|todos}/... │ ├── hitl-queue.json │ ├── permissions.json │ ├── todos/state.json @@ -421,7 +424,7 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in | Category | Tools | Notes | |----------|-------|-------| -| File I/O | file_read✅, file_write❌, file_edit❌ | Guards: workspace, sensitive-file, read-before-edit (edit), file-exists (write). After: read-snapshot (read), edit-error-recovery (edit) | +| File I/O | file_read✅, pdf_read✅, file_write❌, file_edit❌ | Guards: workspace, sensitive-file, read-before-edit (edit), file-exists (write). `pdf_read` extracts native text under the existing exact-path read authorization. After: read-snapshot (read), edit-error-recovery (edit) | | Search / AST | grep✅, glob✅, ast_grep_search✅, ast_grep_replace❌ | Search tools are workspace-scoped. `ast_grep_replace` is destructive and preview-first. | | Git / GitHub | git_status✅, git_diff✅, github_get_pull_request✅, github_list_pull_requests✅, github_get_pull_request_checks✅, github_list_issue_comments✅, github_create_issue_comment❌, github_list_workflow_runs✅, github_get_workflow_run✅, github_rerun_workflow_run❌ | GitHub connectors are registered globally but are not default agent tools. | | Shell | bash❌✅destructive | Permission: finite path-aware Bash analysis, deterministic deny/ask, default allow | @@ -441,7 +444,7 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in ## Session Store -Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and, when present, an immutable `projectTodo` source; an optional `goal` belongs only to a root Lead Session. `projectTodo` is valid only on a user-facing root and records `{ todoId, entry }`, where entry is `discussion`, `work`, or `automation`; strict identity validation requires `discussion` entry ↔ Discussion Agent and `work`/`automation` entry ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. +Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and exactly one immutable `RootSessionSource` on every root: `direct`, `todo { todoId, entry }`, or `automation { automationId, invocationId, todoId }`, where Automation `todoId` is nullable; children never copy a root source. An optional `goal` belongs only to a root Lead Session. Strict identity validation requires Todo `discussion` entry ↔ Discussion Agent and every other root source ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. ## Context Compaction @@ -457,17 +460,17 @@ Project: `.archcode/runtime/memory/`, User: `~/.archcode/memory/` (user-global, ## Project Todos -Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Each Project opens its `/projects/:slug/todos` board by default, while `/projects/:slug` remains the Project Dashboard. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, and the one canonical array order. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update and creates a root Discussion Session for `discussion`, or a root Lead Session for `work` and `automation`. A Todo never points back to Sessions, Plans, or Automations. +Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Global `/` is Home; `/projects/:slug` redirects to the Project's `/projects/:slug/todos` board. Project pages share one `Todos / Automations / Sessions` toolbar, while Sessions remain independently creatable and recoverable execution workbenches. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, the canonical array order, ordered current `attachmentIds`, and narrow durable Run-now receipts. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update, attachment operations, the composed `Run now` command, and root Session creation for `discussion`, `work`, and `automation`. A Todo never stores reverse Session, Plan, or Automation links. -A Todo can have any number of direct root Sessions. Each such root stores its immutable `{ todoId, entry }` source; children never copy it. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. **Generate / Improve Plan** reuses the latest Discussion only when it is idle, then invokes `plan-work` for the unique `.archcode/plans/.md`. If no Discussion exists, the latest one is busy or suspended, it was deleted, or an idle reuse loses the acceptance race, the action creates a new Discussion whose first accepted message is the Plan request; it never races a generic Discussion start with a second command. No Plan existence is stored or exposed through Todo APIs. `work` and `automation` roots may start only from Ready or In Progress. At work creation only, `ProjectTodoService` checks that Plan path: an existing file starts with `execute-plan`, while no file preserves ordinary implementation behavior. Starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. Creating an Automation copies the source `todoId` into the Automation's own optional `projectTodoId`; Automation Invocation Sessions are not direct Todo relations. Todo moves never create, stop, rebind, or delete Sessions or Automations. +A Todo can have any number of root Sessions with immutable `{ kind: "todo", todoId, entry }` source. Each root family resolves the Todo's current attachment set at model and tool boundaries; references are never copied into Session messages or storage. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. **Generate / Improve Plan** reuses the latest Discussion only when it is idle, then invokes `plan-work` for the unique `.archcode/plans/.md`. If no Discussion exists, the latest one is busy or suspended, it was deleted, or an idle reuse loses the acceptance race, the action creates a new Discussion whose first accepted message is the Plan request; it never races a generic Discussion start with a second command. Plan existence is not persisted; the Todo Plan endpoint only performs a fixed-path, bounded Markdown read. `work` and `automation` roots may start only from Ready or In Progress. At work creation only, `ProjectTodoService` checks that Plan path: an existing file starts with `execute-plan`, while no file preserves ordinary implementation behavior. Starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. A Todo-created Automation stores immutable `{ kind: "todo", todoId, sessionId }` origin; every `start_session` Invocation persists `{ kind: "automation", automationId, invocationId, todoId }`. Direct-origin Invocations persist `todoId: null`. Todo moves never create, stop, rebind, or delete Sessions or Automations. ## HITL -HITL is a durable project-scoped approval/question queue backed by `.archcode/runtime/hitl-queue.json`. Server and Web routes expose redacted `displayPayload` data for approval/dashboard views; raw sensitive payloads must not be rendered or persisted in UI state. Deferred permission/question flows resolve safely on timeout, cancellation, or shutdown so long-running agent execution is not left hanging. +HITL is a durable project-scoped approval/question queue backed by `.archcode/runtime/hitl-queue.json`. Server and Web routes expose redacted `displayPayload` data for approval/Home views; raw sensitive payloads must not be rendered or persisted in UI state. Deferred permission/question flows resolve safely on timeout, cancellation, or shutdown so long-running agent execution is not left hanging. ## Automation System -`packages/agent-core/src/automations/` owns schedule calculation, durable Invocation persistence, and dispatch to the ordinary Session API. After the user confirms the creation summary, a root Lead Session calls `automation_create` and commits the Automation through the existing scheduler/state path; Discussion does not expose that capability. When the creating root has a Todo source, the new Automation copies its `todoId` into its own `projectTodoId`; Invocation Sessions do not inherit that source. An Automation has exactly one `once`, `interval`, or `cron + timezone` trigger and one action: create an ordinary Lead Session or send a message to an existing Session. Session execution, Agent behavior, permissions, HITL, Session Goal state, and worktree lifecycle remain outside Automation. +`packages/agent-core/src/automations/` owns schedule calculation, durable Invocation persistence, and dispatch to the ordinary Session API. An Automation has exactly one immutable origin: `direct`, `session { sessionId }`, or `todo { todoId, sessionId }`. Automations UI creation is direct; a root Lead Session may call `automation_create`, with Runtime deriving the Session/Todo origin; Discussion does not expose that capability. An Automation has exactly one `once`, `interval`, or `cron + timezone` trigger and one action: create a root Lead Session with the principal Profile or send a message to an existing Session without changing its identity/source. A `start_session` Invocation persists its origin Todo ID, if any, on the Session so live Todo references remain resolvable even after the Automation is deleted. Session execution, Agent behavior, permissions, HITL, Session Goal state, and worktree lifecycle remain outside Automation. ## LSP Integration @@ -485,7 +488,7 @@ HTTP Streamable only. Built-in: context7, grep.app, exa (hardcoded in `BUILTIN_M ## Key Dependencies -- `@archcode/agent-core`: `ai` v6 + the 24 statically supported official AI SDK language Provider packages (including `@ai-sdk/openai-compatible`), `@modelcontextprotocol/sdk`, `zustand` v5, `zod` v4 (.strict()), `vscode-jsonrpc` + `vscode-languageserver-protocol` (LSP), `jsdom` + `@mozilla/readability` + `turndown` + `@truto/turndown-plugin-gfm` (web_fetch) +- `@archcode/agent-core`: `ai` v6 + the 24 statically supported official AI SDK language Provider packages (including `@ai-sdk/openai-compatible`), `@modelcontextprotocol/sdk`, `zustand` v5, `zod` v4 (.strict()), `vscode-jsonrpc` + `vscode-languageserver-protocol` (LSP), `jsdom` + `@mozilla/readability` + `turndown` + `@truto/turndown-plugin-gfm` (web_fetch), `unpdf` (native PDF text) - `@archcode/server`: `hono` v4 (HTTP/SSE), `zustand` v5, `zod` v4, `fuzzysort` - `@archcode/web`: `react` 19 + `react-dom` + `react-router-dom` v7, `@tanstack/react-query`, `zustand` v5, `@radix-ui/*`, `streamdown`, `eventsource-parser` - `@archcode/protocol`: zero runtime deps diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 8da704cc..cef76075 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -11,7 +11,7 @@ import { createConfigRoutes } from "./routes/config"; import { createAgentsRoutes } from "./routes/agents"; import { createCompressionRoutes } from "./routes/compression"; import { createDirectoriesRoutes } from "./routes/directories"; -import { createDashboardRoutes } from "./routes/dashboard"; +import { createGlobalWorkRoutes } from "./routes/global-work"; import { createFilesRoutes } from "./routes/files"; import { createGlobalEventsRoutes } from "./routes/global-events"; import { createHitlRoutes } from "./routes/hitl"; @@ -60,7 +60,7 @@ export function createRuntimeApp( globalEventBus.emit(snapshot.hitl); }, }); - const dashboard = createDashboardRoutes(serverRuntime); + const globalWork = createGlobalWorkRoutes(serverRuntime); const projectHitl = createHitlRoutes(serverRuntime); const automations = createAutomationsRoutes(serverRuntime); const todos = createTodosRoutes(serverRuntime); @@ -85,7 +85,7 @@ export function createRuntimeApp( const mcp = createMcpRoutes(serverRuntime); const config = createConfigRoutes(serverRuntime.configService); - app.route("/api", dashboard); + app.route("/api", globalWork); app.route("/api/projects", projects); app.route("/api/projects", automations); app.route("/api/projects", todos); diff --git a/apps/server/src/dashboard-projection-service.ts b/apps/server/src/dashboard-projection-service.ts deleted file mode 100644 index 515532f1..00000000 --- a/apps/server/src/dashboard-projection-service.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; -import type { - DashboardAutomation, - DashboardAutomationInvocation, - DashboardExecution, - DashboardProjection, - DashboardProjectError, - DashboardRootSession, - DashboardScope, - SessionExecutionRecord, -} from "@archcode/protocol"; - -/** - * Builds the Dashboard's transient read model from the owning domains. - * - * This service deliberately owns no state: Session, Goal, Automation, and - * Invocation records remain authoritative in their existing stores. - */ -export class DashboardProjectionService { - readonly #runtime: AgentRuntime; - - constructor(runtime: AgentRuntime) { - this.#runtime = runtime; - } - - async read(scope: DashboardScope): Promise { - if (scope.kind === "project") { - const project = await this.#runtime.projectRegistry.get(scope.projectSlug); - if (project === undefined) throw new Error(`Project not found: ${scope.projectSlug}`); - return { - scope, - ...await this.#readProject(project), - errors: [], - }; - } - - const sessions: DashboardRootSession[] = []; - const automations: DashboardAutomation[] = []; - const errors: DashboardProjectError[] = []; - - for (const project of await this.#runtime.projectRegistry.list()) { - try { - const projection = await this.#readProject(project); - sessions.push(...projection.sessions); - automations.push(...projection.automations); - } catch (error) { - errors.push(toProjectError(project, error)); - } - } - - return { scope, sessions, automations, errors }; - } - - async #readProject(project: ProjectInfo): Promise<{ - sessions: DashboardRootSession[]; - automations: DashboardAutomation[]; - }> { - const [summaries, sourceAutomations] = await Promise.all([ - this.#runtime.listSessions(project.workspaceRoot), - this.#runtime.listAutomations(project.workspaceRoot), - ]); - const roots = summaries.filter((session) => session.parentSessionId === undefined); - const [sessions, automations] = await Promise.all([ - Promise.all(roots.map(async (summary) => { - const file = await this.#runtime.getSessionFile(project.workspaceRoot, summary.sessionId); - if (file.parentSessionId !== undefined || file.rootSessionId !== file.sessionId) { - throw new Error(`Dashboard root Session is invalid: ${summary.sessionId}`); - } - return { - projectSlug: project.slug, - projectName: project.name, - rootSessionId: file.sessionId, - sessionTitle: file.title, - createdAt: file.createdAt, - updatedAt: file.updatedAt, - ...(file.goal === undefined ? {} : { goal: file.goal }), - ...(latestExecution(file.executions) === undefined - ? {} - : { latestExecution: toDashboardExecution(latestExecution(file.executions)!) }), - } satisfies DashboardRootSession; - })), - Promise.all(sourceAutomations.map(async (automation) => { - const latest = (await this.#runtime.listAutomationInvocations(project.workspaceRoot, automation.id, 1)).at(-1); - return { - projectSlug: project.slug, - projectName: project.name, - id: automation.id, - name: automation.name, - status: automation.status, - createdAt: automation.createdAt, - updatedAt: automation.updatedAt, - ...(automation.nextFireAt === undefined ? {} : { nextFireAt: automation.nextFireAt }), - ...(latest === undefined ? {} : { latestInvocation: toDashboardInvocation(latest) }), - } satisfies DashboardAutomation; - })), - ]); - - return { sessions, automations }; - } -} - -function latestExecution(executions: readonly SessionExecutionRecord[]): SessionExecutionRecord | undefined { - return executions.at(-1); -} - -function toDashboardExecution(execution: SessionExecutionRecord): DashboardExecution { - return { - id: execution.id, - status: execution.status, - startedAt: execution.startedAt, - ...(execution.endedAt === undefined ? {} : { endedAt: execution.endedAt }), - }; -} - -function toDashboardInvocation(invocation: { - id: string; - status: DashboardAutomationInvocation["status"]; - sessionId?: string; - createdAt: string; - completedAt?: string; -}): DashboardAutomationInvocation { - return { - id: invocation.id, - status: invocation.status, - ...(invocation.sessionId === undefined ? {} : { sessionId: invocation.sessionId }), - createdAt: invocation.createdAt, - ...(invocation.completedAt === undefined ? {} : { completedAt: invocation.completedAt }), - }; -} - -function toProjectError(project: ProjectInfo, error: unknown): DashboardProjectError { - return { - projectSlug: project.slug, - projectName: project.name, - message: error instanceof Error ? error.message : String(error), - }; -} diff --git a/apps/server/src/global-work-read-service.ts b/apps/server/src/global-work-read-service.ts new file mode 100644 index 00000000..ed14183b --- /dev/null +++ b/apps/server/src/global-work-read-service.ts @@ -0,0 +1,350 @@ +import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; +import { + projectTodoContentExcerpt, + type Automation, + type GlobalSSEHitlEntry, + type HomeResponse, + type HomeSummaryItem, + type ProjectAutomationInventoryItem, + type ProjectSessionInventoryItem, + type ProjectTodo, + type RootSessionSummary, + type SessionFamilyActivity, + type SessionSummary, + type WorkbenchProjectReadError, + type WorkSearchResponse, + type WorkSearchResult, +} from "@archcode/protocol"; + +import { + readProjectAutomationInventory, + readProjectSessionInventory, +} from "./project-inventory-read"; + +interface ProjectHomeFacts { + readonly project: ProjectInfo; + readonly sessions: ProjectSessionInventoryItem[]; + readonly automations: ProjectAutomationInventoryItem[]; + readonly todos: readonly ProjectTodo[]; + readonly hitl: readonly GlobalSSEHitlEntry[]; + readonly activities: ReadonlyMap>; +} + +const SEARCH_LIMIT = 100; + +/** Stateless, cross-project Home and global-search composition boundary. */ +export class GlobalWorkReadService { + readonly #runtime: AgentRuntime; + + constructor(runtime: AgentRuntime) { + this.#runtime = runtime; + } + + async readHome(): Promise { + const projects = await this.#runtime.projectRegistry.list(); + const settled = await Promise.allSettled(projects.map(async (project) => await this.#readProjectHome(project))); + const needsYou: HomeSummaryItem[] = []; + const running: HomeSummaryItem[] = []; + const readyToReview: HomeSummaryItem[] = []; + const upcoming: HomeSummaryItem[] = []; + const projectErrors: WorkbenchProjectReadError[] = []; + + settled.forEach((result, index) => { + const project = projects[index]!; + if (result.status === "rejected") { + projectErrors.push(toProjectError(project, result.reason)); + return; + } + const projection = projectHomeProjection(result.value); + needsYou.push(...projection.needsYou); + running.push(...projection.running); + readyToReview.push(...projection.readyToReview); + upcoming.push(...projection.upcoming); + }); + + needsYou.sort(compareNeedsYou); + running.sort(compareNewestFirst); + readyToReview.sort(compareNewestFirst); + upcoming.sort((left, right) => left.sortAt - right.sortAt || left.href.localeCompare(right.href)); + return { needsYou, running, readyToReview, upcoming, projectErrors }; + } + + async search(query: string): Promise { + const normalized = query.trim(); + const projects = await this.#runtime.projectRegistry.list(); + const settled = await Promise.allSettled(projects.map(async (project) => { + const [summaries, automations, context] = await Promise.all([ + this.#runtime.listSessions(project.workspaceRoot), + this.#runtime.listAutomations(project.workspaceRoot), + this.#runtime.contextResolver.resolve(project.workspaceRoot), + ]); + const sessions = summaries + .filter((session) => session.parentSessionId === undefined) + .map(requireRootSummary); + return { project, sessions, automations, todos: await context.todos.listTodos() }; + })); + const matches: WorkSearchResult[] = []; + const projectErrors: WorkbenchProjectReadError[] = []; + + settled.forEach((result, index) => { + const project = projects[index]!; + if (result.status === "rejected") { + projectErrors.push(toProjectError(project, result.reason)); + } else { + matches.push(...searchProject(normalized, result.value)); + } + }); + + return { + results: matches.slice(0, SEARCH_LIMIT), + truncated: matches.length > SEARCH_LIMIT, + projectErrors, + }; + } + + async #readProjectHome(project: ProjectInfo): Promise { + const [sessions, automations, context, controlPlane] = await Promise.all([ + readProjectSessionInventory(this.#runtime, project.workspaceRoot), + readProjectAutomationInventory(this.#runtime, project.workspaceRoot), + this.#runtime.contextResolver.resolve(project.workspaceRoot), + this.#runtime.getProjectControlPlaneSnapshot(project.workspaceRoot, project.slug), + ]); + return { + project, + sessions, + automations, + todos: await context.todos.listTodos(), + hitl: controlPlane.hitl.entries, + activities: new Map(controlPlane.sessionRuntime.families.map((family) => [ + family.rootSessionId, + family.activity as Exclude, + ])), + }; + } +} + +function projectHomeProjection(facts: ProjectHomeFacts): Omit { + const project = { slug: facts.project.slug, name: facts.project.name }; + const sessionsById = new Map(facts.sessions.map((item) => [item.session.sessionId, item])); + const attentionRootIds = new Set(); + const attentionAutomationIds = new Set(); + const hitlRootIds = new Set(facts.hitl.map((entry) => entry.rootSessionId)); + const needsYou: HomeSummaryItem[] = []; + + for (const entry of [...facts.hitl].sort((left, right) => hitlPriority(left) - hitlPriority(right) || Date.parse(left.view.createdAt) - Date.parse(right.view.createdAt))) { + if (attentionRootIds.has(entry.rootSessionId)) continue; + attentionRootIds.add(entry.rootSessionId); + const inspection = entry.view.requiresInspection === true; + needsYou.push({ + kind: "hitl", + project, + entityId: entry.hitlId, + title: inspection ? "Needs manual inspection" : entry.view.displayPayload.title, + status: inspection ? "inspection" : entry.view.source.type === "tool_permission" ? "permission" : "question", + href: sessionHref(facts.project.slug, entry.rootSessionId, hitlSearch(entry)), + sortAt: Date.parse(entry.view.createdAt), + ...(entry.view.displayPayload.summary === undefined ? {} : { context: entry.view.displayPayload.summary }), + }); + } + + for (const item of facts.sessions) { + const goal = item.session.goal; + if (attentionRootIds.has(item.session.sessionId) || (goal?.status !== "blocked" && goal?.status !== "budget_limited")) continue; + attentionRootIds.add(item.session.sessionId); + needsYou.push({ + kind: "session", + project, + entityId: item.session.sessionId, + title: item.session.title ?? "Untitled session", + status: goal.status, + href: sessionHref(facts.project.slug, item.session.sessionId), + sortAt: goal.updatedAt, + context: goal.blockedReason ?? goal.objective, + }); + } + + for (const item of facts.sessions) { + const execution = item.latestExecution; + if (attentionRootIds.has(item.session.sessionId) || execution === null || (execution.status !== "failed" && execution.status !== "timed_out")) continue; + attentionRootIds.add(item.session.sessionId); + needsYou.push({ + kind: "session", + project, + entityId: item.session.sessionId, + title: item.session.title ?? "Untitled session", + status: execution.status, + href: sessionHref(facts.project.slug, item.session.sessionId), + sortAt: execution.endedAt ?? execution.startedAt, + }); + } + + for (const item of facts.automations) { + const invocation = item.latestInvocation; + if (invocation === null || (invocation.status !== "failed" && invocation.status !== "missed")) continue; + attentionAutomationIds.add(item.automation.id); + needsYou.push({ + kind: "automation", + project, + entityId: item.automation.id, + title: item.automation.name, + status: invocation.status, + href: automationHref(facts.project.slug, item.automation.id, invocation.id), + sortAt: Date.parse(invocation.completedAt ?? invocation.createdAt), + }); + } + + const running = [...facts.activities].flatMap(([rootSessionId, activity]): HomeSummaryItem[] => { + if (attentionRootIds.has(rootSessionId)) return []; + const item = sessionsById.get(rootSessionId); + if (item === undefined) return []; + return [{ + kind: "session", + project, + entityId: rootSessionId, + title: item.session.title ?? "Untitled session", + status: activity, + href: sessionHref(facts.project.slug, rootSessionId), + sortAt: item.session.updatedAt, + context: sourceContext(item.session), + }]; + }); + + const readyToReview = facts.todos.flatMap((todo): HomeSummaryItem[] => { + if (todo.archivedAt !== undefined || todo.status !== "in_progress") return []; + const workSessions = facts.sessions + .filter((item) => ( + item.session.source.kind === "todo" + ? item.session.source.todoId === todo.id && item.session.source.entry === "work" + : item.session.source.kind === "automation" && item.session.source.todoId === todo.id + )) + .sort((left, right) => right.session.updatedAt - left.session.updatedAt || right.session.sessionId.localeCompare(left.session.sessionId)); + if (workSessions.length === 0) return []; + if (workSessions.some((item) => facts.activities.has(item.session.sessionId) + || hitlRootIds.has(item.session.sessionId) + || attentionRootIds.has(item.session.sessionId))) return []; + const latest = workSessions[0]!; + if (latest.latestExecution?.status !== "completed") return []; + return [{ + kind: "todo", + project, + entityId: todo.id, + title: projectTodoContentExcerpt(todo.content), + status: "ready_to_review", + href: todoHref(facts.project.slug, todo.id), + sortAt: Math.max(todo.updatedAt, latest.session.updatedAt), + context: latest.session.title ?? "Completed work Session", + }]; + }); + + const upcoming = facts.automations.flatMap((item): HomeSummaryItem[] => { + if (attentionAutomationIds.has(item.automation.id) || item.automation.status !== "active" || item.automation.nextFireAt === undefined) return []; + return [{ + kind: "automation", + project, + entityId: item.automation.id, + title: item.automation.name, + status: "scheduled", + href: automationHref(facts.project.slug, item.automation.id), + sortAt: Date.parse(item.automation.nextFireAt), + context: item.automation.nextFireAt, + }]; + }); + + return { needsYou, running, readyToReview, upcoming }; +} + +function searchProject( + query: string, + facts: { project: ProjectInfo; sessions: readonly RootSessionSummary[]; automations: readonly Automation[]; todos: readonly ProjectTodo[] }, +): WorkSearchResult[] { + const needle = query.toLocaleLowerCase(); + const project = { slug: facts.project.slug, name: facts.project.name }; + const results: WorkSearchResult[] = []; + if (matches(needle, facts.project.name, facts.project.slug)) { + results.push({ kind: "project", project, entityId: facts.project.slug, title: facts.project.name, href: projectHref(facts.project.slug) }); + } + for (const todo of facts.todos) { + if (!matches(needle, todo.id, todo.content)) continue; + results.push({ kind: "todo", project, entityId: todo.id, title: projectTodoContentExcerpt(todo.content), href: todoHref(facts.project.slug, todo.id), context: todo.status }); + } + for (const session of facts.sessions) { + if (!matches(needle, session.sessionId, session.title, session.source.kind, JSON.stringify(session.source))) continue; + results.push({ kind: "session", project, entityId: session.sessionId, title: session.title ?? "Untitled session", href: sessionHref(facts.project.slug, session.sessionId), context: sourceContext(session) }); + } + for (const automation of facts.automations) { + if (!matches(needle, automation.id, automation.name, automation.action.message, JSON.stringify(automation.trigger), JSON.stringify(automation.origin))) continue; + results.push({ kind: "automation", project, entityId: automation.id, title: automation.name, href: automationHref(facts.project.slug, automation.id), context: automation.status }); + } + return results; +} + +function requireRootSummary(summary: SessionSummary): RootSessionSummary { + if (summary.parentSessionId !== undefined || summary.rootSessionId !== summary.sessionId || summary.source === undefined) { + throw new Error(`Invalid root Session summary: ${summary.sessionId}`); + } + return summary as RootSessionSummary; +} + +function sourceContext(session: RootSessionSummary): string { + switch (session.source.kind) { + case "direct": return session.agentName; + case "todo": return `Todo ${session.source.todoId}`; + case "automation": return `Automation ${session.source.automationId}`; + } +} + +function matches(needle: string, ...values: Array): boolean { + return values.some((value) => value?.toLocaleLowerCase().includes(needle) === true); +} + +function compareNeedsYou(left: HomeSummaryItem, right: HomeSummaryItem): number { + const priority = needsYouPriority(left) - needsYouPriority(right); + return priority || compareNewestFirst(left, right); +} + +function needsYouPriority(item: HomeSummaryItem): number { + if (item.kind === "hitl") return item.status === "inspection" ? 0 : 1; + if (item.kind === "session" && (item.status === "blocked" || item.status === "budget_limited")) return 2; + if (item.kind === "session") return 3; + return 4; +} + +function compareNewestFirst(left: HomeSummaryItem, right: HomeSummaryItem): number { + return right.sortAt - left.sortAt || left.href.localeCompare(right.href); +} + +function hitlPriority(entry: GlobalSSEHitlEntry): number { + return entry.view.requiresInspection === true ? 0 : 1; +} + +function hitlSearch(entry: GlobalSSEHitlEntry): URLSearchParams { + const search = new URLSearchParams({ hitl: entry.hitlId }); + if (entry.ownerSessionId !== entry.rootSessionId) search.set("focus", entry.ownerSessionId); + return search; +} + +function projectHref(projectSlug: string): string { + return `/projects/${encodeURIComponent(projectSlug)}/todos`; +} + +function todoHref(projectSlug: string, todoId: string): string { + return `${projectHref(projectSlug)}/${encodeURIComponent(todoId)}`; +} + +function sessionHref(projectSlug: string, sessionId: string, search?: URLSearchParams): string { + const href = `/projects/${encodeURIComponent(projectSlug)}/sessions/${encodeURIComponent(sessionId)}`; + return search === undefined ? href : `${href}?${search.toString()}`; +} + +function automationHref(projectSlug: string, automationId: string, invocationId?: string): string { + const href = `/projects/${encodeURIComponent(projectSlug)}/automations/${encodeURIComponent(automationId)}`; + return invocationId === undefined ? href : `${href}?invocation=${encodeURIComponent(invocationId)}`; +} + +function toProjectError(project: ProjectInfo, error: unknown): WorkbenchProjectReadError { + void error; + return { + project: { slug: project.slug, name: project.name }, + message: "Project work is temporarily unavailable", + }; +} diff --git a/apps/server/src/project-inventory-read.ts b/apps/server/src/project-inventory-read.ts new file mode 100644 index 00000000..8b8d6f91 --- /dev/null +++ b/apps/server/src/project-inventory-read.ts @@ -0,0 +1,33 @@ +import type { AgentRuntime } from "@archcode/agent-core"; +import type { + LatestExecutionDigest, + ProjectAutomationInventoryItem, + ProjectSessionInventoryItem, + SessionExecutionRecord, +} from "@archcode/protocol"; + +export async function readProjectSessionInventory( + runtime: AgentRuntime, + workspaceRoot: string, +): Promise { + return await runtime.listSessionInventory(workspaceRoot); +} + +export async function readProjectAutomationInventory( + runtime: AgentRuntime, + workspaceRoot: string, +): Promise { + return await runtime.listAutomationInventory(workspaceRoot); +} + +export function toLatestExecutionDigest( + execution: SessionExecutionRecord | undefined, +): LatestExecutionDigest | null { + if (execution === undefined) return null; + return { + id: execution.id, + status: execution.status, + startedAt: execution.startedAt, + ...(execution.endedAt === undefined ? {} : { endedAt: execution.endedAt }), + }; +} diff --git a/apps/server/src/routes/attachment-http-error.ts b/apps/server/src/routes/attachment-http-error.ts index 5c266fae..39606b68 100644 --- a/apps/server/src/routes/attachment-http-error.ts +++ b/apps/server/src/routes/attachment-http-error.ts @@ -13,7 +13,7 @@ import { ServerError, SessionNotFoundError } from "../errors"; /** Maps attachment-domain failures at the HTTP boundary without owning storage policy. */ export function mapAttachmentHttpError( error: unknown, - sessionId: string, + sessionId?: string, ): Error | undefined { if (error instanceof AttachmentTooLargeError) { return new ServerError( @@ -57,14 +57,14 @@ export function mapAttachmentHttpError( 409, ); } - if (error instanceof NotRootSessionError) { + if (error instanceof NotRootSessionError && sessionId !== undefined) { return new ServerError( "ATTACHMENT_INVALID", `Session "${sessionId}" is not a user-facing root Session`, 400, ); } - if (error instanceof SessionFileNotFoundError || isMissingFileError(error)) { + if (sessionId !== undefined && (error instanceof SessionFileNotFoundError || isMissingFileError(error))) { return new SessionNotFoundError(sessionId); } return undefined; diff --git a/apps/server/src/routes/attachments.test.ts b/apps/server/src/routes/attachments.test.ts index c59af11a..4075d02c 100644 --- a/apps/server/src/routes/attachments.test.ts +++ b/apps/server/src/routes/attachments.test.ts @@ -3,6 +3,7 @@ import { mkdir, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { NotRootSessionError, + ProjectAttachmentStorage, ProjectRegistry, SessionAttachmentService, silentLogger, @@ -166,6 +167,7 @@ async function createFixture(name: string) { const rootSessionId = crypto.randomUUID(); const validRoots = new Set([rootSessionId]); const attachments = new SessionAttachmentService({ + storage: new ProjectAttachmentStorage(), validateRootSession: async (_root, sessionId) => { if (validRoots.has(sessionId)) return; throw new NotRootSessionError(sessionId, rootSessionId); diff --git a/apps/server/src/routes/attachments.ts b/apps/server/src/routes/attachments.ts index 611c38f5..793e4456 100644 --- a/apps/server/src/routes/attachments.ts +++ b/apps/server/src/routes/attachments.ts @@ -80,7 +80,7 @@ export function createAttachmentsRoutes(runtime: AgentRuntime): Hono { return app; } -function parseDecimal(value: string | undefined, label: string): number { +export function parseDecimal(value: string | undefined, label: string): number { if (value === undefined || !/^(0|[1-9][0-9]*)$/.test(value)) { throw new ServerError( "ATTACHMENT_INVALID", @@ -99,7 +99,10 @@ function parseDecimal(value: string | undefined, label: string): number { return parsed; } -export function attachmentDisposition(name: string): string { +export function attachmentDisposition( + name: string, + disposition: "attachment" | "inline" = "attachment", +): string { const asciiFilename = [...name] .map((character) => /[A-Za-z0-9._ -]/.test(character) ? character : "_") .join("") @@ -108,5 +111,5 @@ export function attachmentDisposition(name: string): string { /['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, ); - return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encoded}`; + return `${disposition}; filename="${asciiFilename}"; filename*=UTF-8''${encoded}`; } diff --git a/apps/server/src/routes/automations.test.ts b/apps/server/src/routes/automations.test.ts index a5c37d16..c0ba6a5b 100644 --- a/apps/server/src/routes/automations.test.ts +++ b/apps/server/src/routes/automations.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdir, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { ProjectRegistry, silentLogger } from "@archcode/agent-core"; -import type { Automation, AutomationInvocation } from "@archcode/protocol"; +import type { Automation, AutomationInvocation, ProjectAutomationInventoryItem } from "@archcode/protocol"; import { errorHandler } from "../error-handler"; import { createAutomationsRoutes } from "./automations"; @@ -12,7 +12,7 @@ function automation(): Automation { return { id: "11111111-1111-4111-8111-111111111111", projectSlug: "project", - createdFromSessionId: "44444444-4444-4444-8444-444444444444", + origin: { kind: "direct" }, name: "Daily check", status: "active", trigger: { kind: "cron", expression: "0 9 * * 1", timezone: "Asia/Shanghai" }, @@ -33,7 +33,9 @@ async function fixture(name: string) { const runtime = { projectRegistry, listAutomations: mock(async () => [item]), + listAutomationInventory: mock(async (): Promise => [{ automation: item, latestInvocation: null }]), readAutomation: mock(async () => item), + createDirectAutomation: mock(async (_root: string, input: Pick) => ({ ...item, ...input })), updateAutomation: mock(async (_root: string, _id: string, input: Partial>) => ({ ...item, ...input })), deleteAutomation: mock(async () => undefined), pauseAutomation: mock(async () => ({ ...item, status: "paused" as const })), @@ -45,7 +47,7 @@ async function fixture(name: string) { status: "pending", createdAt: "2026-07-13T00:00:00.000Z", })), - listAutomationInvocations: mock(async () => []), + listAutomationInvocations: mock(async (): Promise => []), }; const app = createAutomationsRoutes(runtime as unknown as Parameters[0]); app.onError(errorHandler); @@ -71,18 +73,81 @@ describe("automation routes", () => { expect(runtime.runAutomationNow).toHaveBeenCalledWith(project.workspaceRoot, item.id); }); - test("rejects provenance changes through the update route", async () => { + test("creates a direct Automation while keeping origin server-owned", async () => { + const { app, item, project, runtime } = await fixture("direct-create"); + const input = { name: item.name, trigger: item.trigger, action: item.action }; + const response = await app.request(`/${project.slug}/automations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ automation: item }); + expect(runtime.createDirectAutomation).toHaveBeenCalledWith(project.workspaceRoot, input); + + const forged = await app.request(`/${project.slug}/automations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...input, origin: { kind: "session", sessionId: crypto.randomUUID() } }), + }); + expect(forged.status).toBe(400); + expect(runtime.createDirectAutomation).toHaveBeenCalledTimes(1); + }); + + test("returns a structured conflict when worktree execution is unavailable", async () => { + const { app, item, project, runtime } = await fixture("worktree-unavailable"); + runtime.createDirectAutomation.mockRejectedValueOnce(Object.assign(new Error("not a Git worktree"), { + code: "INVALID_CANONICAL_ROOT", + })); + const response = await app.request(`/${project.slug}/automations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: item.name, + trigger: item.trigger, + action: { kind: "start_session", message: "Review", location: "worktree" }, + }), + }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "AUTOMATION_WORKTREE_UNAVAILABLE" } }, + }); + }); + + test("rejects origin changes through the update route", async () => { const { app, item, project, runtime } = await fixture("immutable-provenance"); const res = await app.request(`/${project.slug}/automations/${item.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ createdFromSessionId: crypto.randomUUID() }), + body: JSON.stringify({ origin: { kind: "direct" } }), }); expect(res.status).toBe(400); expect(runtime.updateAutomation).not.toHaveBeenCalled(); }); + test("lists each Automation with its complete latest Invocation snapshot", async () => { + const { app, item, project, runtime } = await fixture("inventory"); + const latest: AutomationInvocation = { + id: "33333333-3333-4333-8333-333333333333", + automationId: item.id, + dueAt: "2026-07-13T00:00:00.000Z", + status: "failed", + createdAt: "2026-07-13T00:00:00.000Z", + completedAt: "2026-07-13T00:01:00.000Z", + error: "dispatch failed", + }; + runtime.listAutomationInventory.mockResolvedValueOnce([{ automation: item, latestInvocation: latest }]); + + const response = await app.request(`/${project.slug}/automations`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ automations: [{ automation: item, latestInvocation: latest }] }); + expect(runtime.listAutomationInventory).toHaveBeenCalledWith(project.workspaceRoot); + }); + test("rejects malformed JSON, invalid IDs, and invalid invocation limits", async () => { const { app, item, project, runtime } = await fixture("validation"); const malformed = await app.request(`/${project.slug}/automations/${item.id}`, { diff --git a/apps/server/src/routes/automations.ts b/apps/server/src/routes/automations.ts index d39cf7e6..74ad3dad 100644 --- a/apps/server/src/routes/automations.ts +++ b/apps/server/src/routes/automations.ts @@ -1,13 +1,15 @@ import { Hono } from "hono"; import { + AutomationCreateSchema, AutomationUpdateSchema, type AgentRuntime, } from "@archcode/agent-core"; -import type { Automation } from "@archcode/protocol"; +import type { Automation, ProjectAutomationInventoryResponse } from "@archcode/protocol"; import { z } from "zod/v4"; import { BadRequestError, ServerError } from "../errors"; import { resolveProject } from "../resolve"; import { zValidator } from "../validation"; +import { readProjectAutomationInventory } from "../project-inventory-read"; const AutomationIdSchema = z.uuid(); const AutomationListParamsSchema = z.strictObject({ slug: z.string().min(1) }); @@ -28,9 +30,23 @@ export function createAutomationsRoutes(runtime: AgentRuntime): Hono { app.get("/:slug/automations", zValidator("param", AutomationListParamsSchema), async (c) => { const project = await resolveProject(runtime, c.req.valid("param").slug); - return c.json({ automations: await runtime.listAutomations(project.workspaceRoot) }); + return c.json({ + automations: await readProjectAutomationInventory(runtime, project.workspaceRoot), + } satisfies ProjectAutomationInventoryResponse); }); + app.post( + "/:slug/automations", + zValidator("param", AutomationListParamsSchema), + zValidator("json", AutomationCreateSchema), + async (c) => { + const project = await resolveProject(runtime, c.req.valid("param").slug); + return c.json({ + automation: await withAutomationErrors(undefined, () => runtime.createDirectAutomation(project.workspaceRoot, c.req.valid("json"))), + }, 201); + }, + ); + app.get("/:slug/automations/:automationId", zValidator("param", AutomationParamsSchema), async (c) => { const { slug, automationId } = c.req.valid("param"); const project = await resolveProject(runtime, slug); @@ -41,7 +57,7 @@ export function createAutomationsRoutes(runtime: AgentRuntime): Hono { const { slug, automationId } = c.req.valid("param"); const project = await resolveProject(runtime, slug); const input = c.req.valid("json"); - return c.json({ automation: await withAutomationNotFound(automationId, () => runtime.updateAutomation(project.workspaceRoot, automationId, input)) }); + return c.json({ automation: await withAutomationErrors(automationId, () => runtime.updateAutomation(project.workspaceRoot, automationId, input)) }); }); app.delete("/:slug/automations/:automationId", zValidator("param", AutomationParamsSchema), async (c) => { @@ -87,12 +103,24 @@ async function readAutomation(runtime: AgentRuntime, workspaceRoot: string, auto } async function withAutomationNotFound(automationId: string, operation: () => Promise): Promise { + return await withAutomationErrors(automationId, operation); +} + +async function withAutomationErrors(automationId: string | undefined, operation: () => Promise): Promise { try { return await operation(); } catch (error) { if (hasCode(error, "AUTOMATION_NOT_FOUND")) { throw new ServerError("AUTOMATION_NOT_FOUND", `Automation not found: ${automationId}`, 404); } + if (hasCode(error, "INVALID_CANONICAL_ROOT") || hasCode(error, "CANONICAL_ROOT_MISMATCH")) { + throw new ServerError( + "BAD_REQUEST", + "Worktree Automations require the Project root to be a Git worktree", + 409, + { scopeCode: "AUTOMATION_WORKTREE_UNAVAILABLE" }, + ); + } throw error; } } diff --git a/apps/server/src/routes/dashboard.test.ts b/apps/server/src/routes/dashboard.test.ts deleted file mode 100644 index f78fe326..00000000 --- a/apps/server/src/routes/dashboard.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import { Hono } from "hono"; -import type { Automation, AutomationInvocation, SessionExecutionRecord, SessionExecutionTerminalStatus, SessionGoal, SessionSummary } from "@archcode/protocol"; -import { createDashboardRoutes } from "./dashboard"; - -const workspaceRoot = process.cwd(); - -function goal(status: SessionGoal["status"] = "blocked"): SessionGoal { - return { - instanceId: "goal", generation: 1, objective: "Finish the migration and run all tests.", status, - usage: { tokens: { inputTokens: 10, outputTokens: 20, totalTokens: 30, reasoningTokens: 0, cachedInputTokens: 0 }, executionTimeMs: 90_000, executionCount: 2 }, - settlementReceipts: [], - createdAt: 1, activatedAt: 1, updatedAt: 2, - }; -} - -function rootSummary(sessionId = "root"): SessionSummary { - return { - sessionId, cwd: workspaceRoot, rootSessionId: sessionId, agentName: "lead", profile: "principal", activeSkillNames: [], - modelSelection: { revision: 0 }, title: "Migration", goal: goal(), createdAt: 1, updatedAt: 2, - }; -} - -function execution(status: SessionExecutionTerminalStatus | "running" = "failed"): SessionExecutionRecord { - const binding = { - selection: { model: "test:model" }, providerId: "test", modelId: "model", - providerDisplayName: "Test", modelDisplayName: "Model", resolution: "profile_default" as const, modelRuntimeRevision: "test-revision", - }; - const usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedInputTokens: 0 }; - if (status === "running") { - return { - id: "execution-1", status, startedAt: 10, durationMs: 0, maxSteps: 50, origin: "user_message", - runs: [{ ordinal: 0, startedAt: 10, binding }], - }; - } - return { - id: "execution-1", status, startedAt: 10, endedAt: 20, - durationMs: 10, maxSteps: 50, origin: "user_message", - runs: [{ ordinal: 0, startedAt: 10, endedAt: 20, durationMs: 10, binding, usageDelta: usage, settlement: { key: "run:root:execution-1:0", goalInstanceId: null } }], - terminalSettlement: { key: "terminal:root:execution-1", goalInstanceId: null }, - }; -} - -function automation(id = "automation-1"): Automation { - return { - id, projectSlug: "demo", createdFromSessionId: "root", name: "Deploy", status: "active", - trigger: { kind: "interval", everyMs: 60_000 }, action: { kind: "start_session", message: "run", location: "project" }, - createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T01:00:00.000Z", nextFireAt: "2026-01-01T02:00:00.000Z", - }; -} - -function invocation(): AutomationInvocation { - return { - id: "invocation-1", automationId: "automation-1", dueAt: "2026-01-01T00:00:00.000Z", status: "failed", - sessionId: "root", createdAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:01:00.000Z", error: "secret failure detail", - }; -} - -function runtime(overrides: Record = {}) { - const project = { slug: "demo", name: "Demo", workspaceRoot, addedAt: "now" }; - return { - projectRegistry: { - get: mock(async (slug: string) => slug === project.slug ? project : undefined), - list: mock(async () => [project]), - }, - listSessions: mock(async () => [rootSummary(), { ...rootSummary("child"), parentSessionId: "root", rootSessionId: "root", agentName: "analyst" }]), - getSessionFile: mock(async () => ({ ...rootSummary(), executions: [execution()] })), - listAutomations: mock(async () => [automation()]), - listAutomationInvocations: mock(async () => [invocation()]), - ...overrides, - } as unknown as Parameters[0]; -} - -describe("DashboardProjection routes", () => { - test("uses one global read model rooted in Session and Automation owners", async () => { - const app = new Hono().route("/api", createDashboardRoutes(runtime())); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - scope: { kind: "global" }, - sessions: [{ - projectSlug: "demo", projectName: "Demo", rootSessionId: "root", sessionTitle: "Migration", createdAt: 1, updatedAt: 2, - goal: goal(), latestExecution: { id: "execution-1", status: "failed", startedAt: 10, endedAt: 20 }, - }], - automations: [{ - projectSlug: "demo", projectName: "Demo", id: "automation-1", name: "Deploy", status: "active", - createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T01:00:00.000Z", nextFireAt: "2026-01-01T02:00:00.000Z", - latestInvocation: { id: "invocation-1", status: "failed", sessionId: "root", createdAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:01:00.000Z" }, - }], - errors: [], - }); - }); - - test("uses the same projection contract for one project", async () => { - const app = new Hono().route("/api", createDashboardRoutes(runtime())); - - const response = await app.request("/api/projects/demo/dashboard"); - - expect(response.status).toBe(200); - expect((await response.json() as { scope: unknown }).scope).toEqual({ kind: "project", projectSlug: "demo" }); - }); - - test("isolates a corrupt project while preserving other global project rows", async () => { - const good = { slug: "good", name: "Good", workspaceRoot, addedAt: "now" }; - const bad = { slug: "bad", name: "Bad", workspaceRoot: "/bad", addedAt: "now" }; - const app = new Hono().route("/api", createDashboardRoutes(runtime({ - projectRegistry: { get: mock(async (slug: string) => slug === "good" ? good : slug === "bad" ? bad : undefined), list: mock(async () => [good, bad]) }, - listSessions: mock(async (root: string) => root === "/bad" ? Promise.reject(new Error("session file is corrupt")) : [rootSummary()]), - }))); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - sessions: [{ projectSlug: "good" }], - errors: [{ projectSlug: "bad", projectName: "Bad", message: "session file is corrupt" }], - }); - }); - - test("uses only latest Session and Invocation records so recovered work leaves attention", async () => { - const app = new Hono().route("/api", createDashboardRoutes(runtime({ - getSessionFile: mock(async () => ({ - ...rootSummary(), - executions: [execution("failed"), { - ...execution("running"), - id: "execution-2", - startedAt: 30, - runs: [{ ordinal: 0, startedAt: 30, binding: { - selection: { model: "test:model" }, providerId: "test", modelId: "model", - providerDisplayName: "Test", modelDisplayName: "Model", resolution: "profile_default", modelRuntimeRevision: "test-revision", - } }], - }], - })), - listAutomationInvocations: mock(async () => [ - invocation(), - { ...invocation(), id: "invocation-2", status: "dispatched", createdAt: "2026-01-01T00:02:00.000Z" }, - ]), - }))); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - sessions: [{ latestExecution: { id: "execution-2", status: "running" } }], - automations: [{ latestInvocation: { id: "invocation-2", status: "dispatched" } }], - }); - }); - - test("isolates an Automation projection failure without dropping a healthy project", async () => { - const good = { slug: "good", name: "Good", workspaceRoot, addedAt: "now" }; - const bad = { slug: "bad", name: "Bad", workspaceRoot: "/bad", addedAt: "now" }; - const app = new Hono().route("/api", createDashboardRoutes(runtime({ - projectRegistry: { - get: mock(async (slug: string) => slug === "good" ? good : slug === "bad" ? bad : undefined), - list: mock(async () => [good, bad]), - }, - listSessions: mock(async () => [rootSummary()]), - listAutomations: mock(async (root: string) => root === "/bad" ? [automation("bad-automation")] : [automation("good-automation")]), - listAutomationInvocations: mock(async (root: string) => { - if (root === "/bad") throw new Error("automation invocation is corrupt"); - return [invocation()]; - }), - }))); - - const response = await app.request("/api/dashboard"); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - sessions: [{ projectSlug: "good" }], - automations: [{ projectSlug: "good", id: "good-automation" }], - errors: [{ projectSlug: "bad", projectName: "Bad", message: "automation invocation is corrupt" }], - }); - }); -}); diff --git a/apps/server/src/routes/dashboard.ts b/apps/server/src/routes/dashboard.ts deleted file mode 100644 index d5c87609..00000000 --- a/apps/server/src/routes/dashboard.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Hono } from "hono"; -import type { AgentRuntime } from "@archcode/agent-core"; -import { z } from "zod/v4"; -import { DashboardProjectionService } from "../dashboard-projection-service"; -import { ProjectNotFoundError } from "../errors"; -import { zValidator } from "../validation"; - -const ProjectDashboardParamsSchema = z.strictObject({ slug: z.string().min(1) }); - -/** - * The Dashboard has one read-model contract. Scope is selected by endpoint, - * never by a client-supplied switch that could accidentally broaden access. - */ -export function createDashboardRoutes(runtime: AgentRuntime): Hono { - const app = new Hono(); - const projection = new DashboardProjectionService(runtime); - - app.get("/dashboard", async (c) => c.json(await projection.read({ kind: "global" }))); - app.get( - "/projects/:slug/dashboard", - zValidator("param", ProjectDashboardParamsSchema), - async (c) => { - const { slug } = c.req.valid("param"); - if (await runtime.projectRegistry.get(slug) === undefined) throw new ProjectNotFoundError(slug); - return c.json(await projection.read({ kind: "project", projectSlug: slug })); - }, - ); - - return app; -} diff --git a/apps/server/src/routes/global-work.test.ts b/apps/server/src/routes/global-work.test.ts new file mode 100644 index 00000000..065ad054 --- /dev/null +++ b/apps/server/src/routes/global-work.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, mock, test } from "bun:test"; +import { Hono } from "hono"; +import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; +import type { + Automation, + AutomationInvocation, + GlobalSSEHitlEntry, + ProjectTodo, + RootSessionSummary, + SessionExecutionRecord, +} from "@archcode/protocol"; + +import { errorHandler } from "../error-handler"; +import { createGlobalWorkRoutes } from "./global-work"; + +const goodProject: ProjectInfo = { + slug: "good", + name: "Good Project", + workspaceRoot: "/projects/good", + addedAt: "2026-08-01T00:00:00.000Z", +}; +const badProject: ProjectInfo = { + slug: "bad", + name: "Bad Project", + workspaceRoot: "/projects/bad", + addedAt: "2026-08-01T00:00:00.000Z", +}; + +describe("global work read routes", () => { + test("GET /api/home returns server-grouped attention, running, review, and upcoming rows", async () => { + const todo = makeTodo(); + const work = rootSession("work", { kind: "todo", todoId: todo.id, entry: "work" }, 50); + const hitlRoot = rootSession("hitl-root", { kind: "direct" }, 40); + const failure = rootSession("failure", { kind: "direct" }, 30); + const automation = makeAutomation(); + const failedAutomation = makeAutomation({ + id: "33333333-3333-4333-8333-333333333333", + name: "Failing review", + }); + const runtime = makeRuntime({ + projects: [goodProject, badProject], + sessions: [work, hitlRoot, failure], + todos: [todo], + automations: [automation, failedAutomation], + automationInvocations: new Map([[failedAutomation.id, { + id: "44444444-4444-4444-8444-444444444444", + automationId: failedAutomation.id, + dueAt: "2026-08-03T00:00:00.000Z", + status: "failed", + createdAt: "2026-08-03T00:00:00.000Z", + completedAt: "2026-08-03T00:01:00.000Z", + error: "model unavailable", + }]]), + executions: new Map([ + [work.sessionId, [execution("completed", 60)]], + [hitlRoot.sessionId, [execution("running", 41)]], + [failure.sessionId, [execution("failed", 31)]], + ]), + hitl: [hitlEntry(hitlRoot.sessionId)], + activities: new Map([[hitlRoot.sessionId, "running"]]), + failBadProject: true, + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const response = await app.request("/api/home"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + needsYou: [ + { kind: "hitl", entityId: "hitl-1", status: "question", project: { slug: "good" } }, + { kind: "session", entityId: "failure", status: "failed", project: { slug: "good" } }, + { kind: "automation", entityId: failedAutomation.id, status: "failed", project: { slug: "good" } }, + ], + running: [], + readyToReview: [{ kind: "todo", entityId: todo.id, status: "ready_to_review" }], + upcoming: [{ kind: "automation", entityId: automation.id, status: "scheduled" }], + projectErrors: [{ project: { slug: "bad", name: "Bad Project" }, message: "Project work is temporarily unavailable" }], + }); + expect(body.readyToReview[0]?.title).toBe("Review the completed work Private PRD body"); + }); + + test("a Goal-attention Session excludes its entire Todo work family from Ready to review", async () => { + const todo = makeTodo(); + const blockedWork: RootSessionSummary = { + ...rootSession("blocked-work", { kind: "todo", todoId: todo.id, entry: "work" }, 40), + goal: { + instanceId: "goal-1", + generation: 1, + objective: "Finish the Todo", + status: "blocked", + usage: { + tokens: { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedInputTokens: 0 }, + executionTimeMs: 0, + executionCount: 0, + }, + settlementReceipts: [], + blockedReason: "Needs a decision", + createdAt: 1, + activatedAt: 1, + updatedAt: 50, + }, + }; + const completedWork = rootSession("completed-work", { kind: "todo", todoId: todo.id, entry: "work" }, 60); + const runtime = makeRuntime({ + projects: [goodProject], + sessions: [blockedWork, completedWork], + todos: [todo], + automations: [], + executions: new Map([[completedWork.sessionId, [execution("completed", 61)]]]), + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const response = await app.request("/api/home"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.needsYou).toEqual([ + expect.objectContaining({ entityId: blockedWork.sessionId, status: "blocked" }), + ]); + expect(body.readyToReview).toEqual([]); + }); + + test("completed Todo-originated Automation work is ready for Todo review", async () => { + const todo = makeTodo(); + const session = rootSession("automation-work", { + kind: "automation", + automationId: "11111111-1111-4111-8111-111111111111", + invocationId: "22222222-2222-4222-8222-222222222222", + todoId: todo.id, + }, 60); + const runtime = makeRuntime({ + projects: [goodProject], + sessions: [session], + todos: [todo], + automations: [], + executions: new Map([[session.sessionId, [execution("completed", 61)]]]), + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const body = await (await app.request("/api/home")).json(); + expect(body.readyToReview).toEqual([ + expect.objectContaining({ kind: "todo", entityId: todo.id, status: "ready_to_review" }), + ]); + }); + + test("GET /api/search matches Todo content, caps at 100, and isolates project failures", async () => { + const todos = Array.from({ length: 101 }, (_, index) => makeTodo({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + content: `Todo ${index}\n\nhidden needle content`, + })); + const runtime = makeRuntime({ + projects: [goodProject, badProject], + sessions: [], + todos, + automations: [], + executions: new Map(), + failBadProject: true, + }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const response = await app.request("/api/search?q=%20needle%20"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.results).toHaveLength(100); + expect(body.truncated).toBe(true); + expect(body.projectErrors).toEqual([{ project: { slug: "bad", name: "Bad Project" }, message: "Project work is temporarily unavailable" }]); + expect(body.results[0]).toEqual({ + kind: "todo", + project: { slug: "good", name: "Good Project" }, + entityId: todos[0]!.id, + title: "Todo 0 hidden needle content", + href: `/projects/good/todos/${todos[0]!.id}`, + context: "in_progress", + }); + expect(body.results[0]).not.toHaveProperty("content"); + }); + + test("GET /api/search enforces the trimmed 1-200 character boundary", async () => { + const runtime = makeRuntime({ projects: [], sessions: [], todos: [], automations: [], executions: new Map() }); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); + app.onError(errorHandler); + + const empty = await app.request("/api/search?q=%20%20"); + const twoHundred = await app.request(`/api/search?q=${"a".repeat(200)}`); + const twoHundredOne = await app.request(`/api/search?q=${"a".repeat(201)}`); + + expect(empty.status).toBe(400); + expect(twoHundred.status).toBe(200); + expect(twoHundredOne.status).toBe(400); + }); +}); + +function makeRuntime(input: { + projects: ProjectInfo[]; + sessions: RootSessionSummary[]; + todos: ProjectTodo[]; + automations: Automation[]; + executions: Map; + automationInvocations?: Map; + hitl?: GlobalSSEHitlEntry[]; + activities?: Map; + failBadProject?: boolean; +}): AgentRuntime { + return { + projectRegistry: { list: mock(async () => input.projects) }, + listSessions: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.sessions; + }), + listSessionInventory: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.sessions.map((session) => { + const latest = input.executions.get(session.sessionId)?.at(-1); + return { + session, + latestExecution: latest === undefined + ? null + : { + id: latest.id, + status: latest.status, + startedAt: latest.startedAt, + ...("endedAt" in latest ? { endedAt: latest.endedAt } : {}), + }, + }; + }); + }), + getSessionFile: mock(async (_workspaceRoot: string, sessionId: string) => ({ + ...input.sessions.find((session) => session.sessionId === sessionId)!, + executions: input.executions.get(sessionId) ?? [], + })), + listAutomations: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.automations; + }), + listAutomationInventory: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return input.automations.map((automation) => ({ + automation, + latestInvocation: input.automationInvocations?.get(automation.id) ?? null, + })); + }), + listAutomationInvocations: mock(async () => []), + contextResolver: { resolve: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return { todos: { listTodos: mock(async () => input.todos) } }; + }) }, + getProjectControlPlaneSnapshot: mock(async (workspaceRoot: string) => { + if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return { + sessionRuntime: { + type: "session.runtime.snapshot", + projectSlugs: [goodProject.slug], + families: [...(input.activities ?? new Map())].map(([rootSessionId, activity]) => ({ + projectSlug: goodProject.slug, + rootSessionId, + activity, + })), + createdAt: 1, + }, + hitl: { + type: "hitl.snapshot", + projectSlugs: [goodProject.slug], + entries: input.hitl ?? [], + createdAt: 1, + }, + }; + }), + } as unknown as AgentRuntime; +} + +function rootSession(sessionId: string, source: RootSessionSummary["source"], updatedAt: number): RootSessionSummary { + return { + sessionId, + cwd: goodProject.workspaceRoot, + rootSessionId: sessionId, + agentName: "lead", + profile: "principal", + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: sessionId, + source, + createdAt: 1, + updatedAt, + }; +} + +function execution(status: "running" | "completed" | "failed", startedAt: number): SessionExecutionRecord { + return { + id: `execution-${startedAt}`, + status, + startedAt, + ...(status === "running" ? {} : { endedAt: startedAt + 1 }), + } as SessionExecutionRecord; +} + +function makeTodo(overrides: Partial = {}): ProjectTodo { + return { + id: "11111111-1111-4111-8111-111111111111", + content: "Review the completed work\n\nPrivate PRD body", + attachmentIds: [], + status: "in_progress", + revision: 1, + createdAt: 1, + updatedAt: 70, + ...overrides, + }; +} + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: "22222222-2222-4222-8222-222222222222", + projectSlug: goodProject.slug, + origin: { kind: "direct" }, + name: "Nightly review", + trigger: { kind: "cron", expression: "0 1 * * *", timezone: "UTC" }, + action: { kind: "start_session", message: "Review", location: "project" }, + status: "active", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + nextFireAt: "2026-08-04T01:00:00.000Z", + ...overrides, + }; +} + +function hitlEntry(rootSessionId: string): GlobalSSEHitlEntry { + return { + projectSlug: goodProject.slug, + hitlId: "hitl-1", + ownerSessionId: rootSessionId, + rootSessionId, + view: { + hitlId: "hitl-1", + owner: { type: "session", id: rootSessionId }, + source: { type: "ask_user", toolCallId: "question-1" }, + status: "pending", + displayPayload: { title: "Choose an option", summary: "Waiting for an answer", redacted: true }, + allowedActions: ["answer", "cancel"], + createdAt: "2026-08-03T00:00:00.000Z", + updatedAt: "2026-08-03T00:00:00.000Z", + }, + }; +} diff --git a/apps/server/src/routes/global-work.ts b/apps/server/src/routes/global-work.ts new file mode 100644 index 00000000..1b672da0 --- /dev/null +++ b/apps/server/src/routes/global-work.ts @@ -0,0 +1,22 @@ +import type { AgentRuntime } from "@archcode/agent-core"; +import { Hono } from "hono"; +import { z } from "zod/v4"; + +import { GlobalWorkReadService } from "../global-work-read-service"; +import { zValidator } from "../validation"; + +const GlobalSearchQuerySchema = z.strictObject({ + q: z.string().trim().min(1).max(200), +}); + +export function createGlobalWorkRoutes(runtime: AgentRuntime): Hono { + const app = new Hono(); + const reads = new GlobalWorkReadService(runtime); + + app.get("/home", async (c) => c.json(await reads.readHome())); + app.get("/search", zValidator("query", GlobalSearchQuerySchema), async (c) => ( + c.json(await reads.search(c.req.valid("query").q)) + )); + + return app; +} diff --git a/apps/server/src/routes/sessions.test.ts b/apps/server/src/routes/sessions.test.ts index 5a830048..2e449b83 100644 --- a/apps/server/src/routes/sessions.test.ts +++ b/apps/server/src/routes/sessions.test.ts @@ -9,12 +9,16 @@ const tempRoot = resolve(import.meta.dir, "__test_tmp__", "sessions-routes"); interface SessionSummaryBody { sessions: Array<{ - sessionId: string; - rootSessionId: string; - parentSessionId?: string; - title?: string | null; - createdAt: number; - updatedAt: number; + session: { + sessionId: string; + rootSessionId: string; + parentSessionId?: string; + title?: string | null; + source: { kind: "direct" }; + createdAt: number; + updatedAt: number; + }; + latestExecution: null | { id: string; status: string; startedAt: number; endedAt?: number }; }>; } @@ -36,6 +40,8 @@ type StoredSessionBody = SessionFileBody & { reminders: unknown[]; rootSessionId: string; parentSessionId?: string; + source?: { kind: "direct" }; + executions: Array<{ id: string; status: string; startedAt: number; endedAt?: number }>; }; class MissingSessionFileError extends Error { @@ -62,6 +68,8 @@ function createStoredSession(input: { reminders: [], rootSessionId: input.rootSessionId ?? sessionId, ...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}), + ...(input.parentSessionId === undefined ? { source: { kind: "direct" as const } } : {}), + executions: [], }; } @@ -114,11 +122,35 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { rootSessionId: session.rootSessionId, ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), title: session.title, + ...(session.source === undefined ? {} : { source: session.source }), createdAt: session.createdAt, updatedAt: session.updatedAt, })) .sort((a, b) => b.createdAt - a.createdAt); }, + listSessionInventory: async (workspaceRoot: string) => { + calls.listSessions += 1; + return [...sessions.entries()] + .filter(([key]) => key.startsWith(`${workspaceRoot}\0`)) + .filter(([, session]) => session.parentSessionId === undefined) + .map(([, session]) => ({ + session: { + sessionId: session.sessionId, + cwd: workspaceRoot, + rootSessionId: session.rootSessionId, + agentName: "lead", + profile: "principal", + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: session.title, + source: session.source!, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }, + latestExecution: session.executions.at(-1) ?? null, + })) + .sort((a, b) => b.session.updatedAt - a.session.updatedAt); + }, getSessionModelState: async (workspaceRoot: string, sessionId: string) => { const key = `${workspaceRoot}\0${sessionId}`; if (!sessions.has(key)) throw new MissingSessionFileError(); @@ -416,15 +448,17 @@ describe("sessions routes", () => { const body = (await res.json()) as SessionSummaryBody; expect(res.status).toBe(200); - expect(body.sessions).toEqual([ - { + expect(body.sessions).toEqual([{ + session: expect.objectContaining({ sessionId: session.sessionId, rootSessionId: session.sessionId, title: null, + source: { kind: "direct" }, createdAt: session.createdAt, updatedAt: session.updatedAt, - }, - ]); + }), + latestExecution: null, + }]); }); test("GET /api/projects/:slug/sessions/:sessionId returns full session data", async () => { @@ -617,7 +651,7 @@ describe("sessions routes", () => { const body = (await res.json()) as SessionSummaryBody; expect(res.status).toBe(200); - expect(body.sessions.map((session) => session.sessionId)).toEqual(["newer", "older"]); + expect(body.sessions.map((item) => item.session.sessionId)).toEqual(["newer", "older"]); }); test("GET /api/projects/:slug/sessions returns only root sessions with identity fields", async () => { @@ -629,15 +663,35 @@ describe("sessions routes", () => { const body = (await res.json()) as SessionSummaryBody; expect(res.status).toBe(200); - expect(body.sessions).toEqual([ - { + expect(body.sessions).toEqual([{ + session: expect.objectContaining({ sessionId: "root-session", rootSessionId: "root-session", title: "Root", + source: { kind: "direct" }, createdAt: 1_000, updatedAt: 1_000, - }, - ]); + }), + latestExecution: null, + }]); + }); + + test("GET /api/projects/:slug/sessions composes the latest Execution digest", async () => { + const { app, project, workspaceRoot, sessions, calls } = await createTestApp("session-execution-digest"); + const session = createStoredSession({ sessionId: "root-session", createdAt: 1_000, title: "Root" }); + session.executions.push( + { id: "execution-1", status: "failed", startedAt: 1_100, endedAt: 1_200 }, + { id: "execution-2", status: "completed", startedAt: 1_300, endedAt: 1_400 }, + ); + sessions.set(`${workspaceRoot}\0root-session`, session); + + const response = await app.request(`/api/projects/${project.slug}/sessions`); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + sessions: [{ latestExecution: { id: "execution-2", status: "completed", startedAt: 1_300, endedAt: 1_400 } }], + }); + expect(calls.getSessionFile).toBe(0); }); test("DELETE /api/projects/:slug/sessions/:sessionId delegates cleanup to runtime", async () => { diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index aa853082..a2171611 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -13,10 +13,12 @@ import { SessionModelSelectionNotAllowedError, } from "@archcode/agent-core"; import type { AgentRuntime } from "@archcode/agent-core"; +import type { ProjectSessionInventoryResponse } from "@archcode/protocol"; import { z } from "zod/v4"; import { BadRequestError, ConflictError, ServerError, SessionNotFoundError, SessionStopConflictHttpError } from "../errors"; import { resolveProject } from "../resolve"; import { zValidator } from "../validation"; +import { readProjectSessionInventory } from "../project-inventory-read"; const ProjectParamsSchema = z.strictObject({ slug: z.string().min(1) }); const SessionParamsSchema = z.strictObject({ @@ -51,15 +53,18 @@ export function createSessionsRoutes(runtime: AgentRuntime): Hono { app.get("/", zValidator("param", ProjectParamsSchema), async (c) => { const project = await resolveProject(runtime, c.req.valid("param").slug); - const sessions = await runtime.listSessions(project.workspaceRoot); + const sessions = await readProjectSessionInventory(runtime, project.workspaceRoot); - return c.json({ sessions }); + return c.json({ sessions } satisfies ProjectSessionInventoryResponse); }); app.post("/", zValidator("param", ProjectParamsSchema), async (c) => { await rejectRequestBody(c.req.text()); const project = await resolveProject(runtime, c.req.valid("param").slug); - return c.json(await runtime.createSession(project.workspaceRoot, { agentName: "lead" }), 201); + return c.json(await runtime.createSession(project.workspaceRoot, { + agentName: "lead", + source: { kind: "direct" }, + }), 201); }); app.get("/:sessionId", zValidator("param", SessionParamsSchema), async (c) => { diff --git a/apps/server/src/routes/todos.test.ts b/apps/server/src/routes/todos.test.ts index 3d2b0cf9..f1c86b6c 100644 --- a/apps/server/src/routes/todos.test.ts +++ b/apps/server/src/routes/todos.test.ts @@ -1,12 +1,14 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Hono } from "hono"; import type { + AttachmentDescriptor, CreateProjectTodoSessionInput, ProjectTodo, ProjectTodoCreateInput, + ProjectTodoRunNowInput, ProjectTodoUpdateInput, } from "@archcode/protocol"; import type { AgentRuntime } from "@archcode/agent-core"; @@ -36,7 +38,7 @@ describe("Project Todo routes", () => { const create = await fixture.app.request(base, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ title: "New idea", body: "Details" }), + body: JSON.stringify({ content: "New idea\n\nDetails" }), }); const update = await fixture.app.request(`${base}/${todo.id}`, { method: "PATCH", @@ -51,7 +53,7 @@ describe("Project Todo routes", () => { expect(list.status).toBe(200); expect(await list.json()).toEqual({ todos: [todo] }); expect(create.status).toBe(201); - expect(fixture.createTodo).toHaveBeenCalledWith({ title: "New idea", body: "Details" }); + expect(fixture.createTodo).toHaveBeenCalledWith({ content: "New idea\n\nDetails" }); expect(update.status).toBe(200); expect(fixture.updateTodo).toHaveBeenCalledWith(todo.id, { expectedRevision: 1, @@ -60,6 +62,75 @@ describe("Project Todo routes", () => { }); }); + test("serves Todo reference mutations and safe inline/download dispositions", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const attachmentId = crypto.randomUUID(); + const base = `/api/projects/${fixture.project.slug}/todos/${todo.id}/attachments`; + const bytes = new TextEncoder().encode("%PDF-1.7 fixture"); + + const list = await fixture.app.request(base); + expect(await list.json()).toEqual({ todoRevision: todo.revision, attachments: [] }); + + const upload = await fixture.app.request( + `${base}/${attachmentId}?name=brief.pdf&sizeBytes=${bytes.byteLength}&expectedRevision=${todo.revision}`, + { + method: "PUT", + headers: { "content-type": "application/pdf" }, + body: bytes, + }, + ); + expect(upload.status).toBe(200); + expect(fixture.uploadAttachment).toHaveBeenCalledWith(expect.objectContaining({ + todoId: todo.id, + attachmentId, + expectedRevision: todo.revision, + name: "brief.pdf", + sizeBytes: bytes.byteLength, + })); + + const contentPath = join(workspaceRoot, "opened-content"); + await writeFile(contentPath, bytes); + fixture.openAttachment.mockResolvedValue({ + descriptor: { + id: attachmentId, + name: "brief.pdf", + mediaType: "application/pdf", + sizeBytes: bytes.byteLength, + kind: "file", + }, + contentPath, + }); + const opened = await fixture.app.request(`${base}/${attachmentId}`); + expect(opened.headers.get("content-disposition")).toStartWith("inline;"); + expect(opened.headers.get("x-content-type-options")).toBe("nosniff"); + + fixture.openAttachment.mockResolvedValue({ + descriptor: { + id: attachmentId, + name: "unsafe.html", + mediaType: "text/html", + sizeBytes: bytes.byteLength, + kind: "file", + }, + contentPath, + }); + const downloaded = await fixture.app.request(`${base}/${attachmentId}`); + expect(downloaded.headers.get("content-disposition")).toStartWith("attachment;"); + + const removed = await fixture.app.request(`${base}/${attachmentId}`, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: todo.revision + 1 }), + }); + expect(removed.status).toBe(200); + expect(fixture.removeAttachment).toHaveBeenCalledWith({ + todoId: todo.id, + attachmentId, + expectedRevision: todo.revision + 1, + }); + }); + test("creates each Todo Session entry through one backend-owned endpoint", async () => { const todo = makeTodo({ status: "ready" }); const fixture = createFixture(todo); @@ -99,6 +170,28 @@ describe("Project Todo routes", () => { }); }); + test("runs a captured Todo through the single backend-owned command", async () => { + const todo = makeTodo({ status: "in_progress" }); + const fixture = createFixture(todo); + const input: ProjectTodoRunNowInput = { + clientRequestId: crypto.randomUUID(), + content: "Start now\n\nImplement it.", + }; + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/run-now`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + todo, + session: { sessionId: "11111111-1111-4111-8111-111111111111", source: { kind: "todo", todoId: todo.id, entry: "work" } }, + }); + expect(fixture.runNow).toHaveBeenCalledWith(input); + }); + test("strictly validates flat mutation and Session request bodies", async () => { const fixture = createFixture(makeTodo()); const base = `/api/projects/${fixture.project.slug}/todos`; @@ -112,7 +205,7 @@ describe("Project Todo routes", () => { const mixedArchive = await fixture.app.request(`${base}/${fixture.todo.id}`, { method: "PATCH", headers, - body: JSON.stringify({ expectedRevision: 1, archived: true, title: "Mixed" }), + body: JSON.stringify({ expectedRevision: 1, archived: true, content: "Mixed" }), }); const invalidEntry = await fixture.app.request(`${base}/${fixture.todo.id}/sessions`, { method: "POST", @@ -128,11 +221,49 @@ describe("Project Todo routes", () => { initialIntent: "plan", }), }); + const invalidRunNow = await fixture.app.request(`${base}/run-now`, { + method: "POST", + headers, + body: JSON.stringify({ clientRequestId: crypto.randomUUID(), content: "Start", origin: "forged" }), + }); expect(emptyMutation.status).toBe(400); expect(mixedArchive.status).toBe(400); expect(invalidEntry.status).toBe(400); expect(invalidPlanIntent.status).toBe(400); + expect(invalidRunNow.status).toBe(400); + expect(fixture.runNow).not.toHaveBeenCalled(); + }); + + test("maps Run now key conflicts and recovery-required failures", async () => { + const fixture = createFixture(makeTodo()); + const path = `/api/projects/${fixture.project.slug}/todos/run-now`; + const clientRequestId = crypto.randomUUID(); + const request = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clientRequestId, content: "Start" }), + }; + fixture.runNow.mockRejectedValueOnce(Object.assign(new Error("different input"), { + code: "PROJECT_TODO_RUN_NOW_CONFLICT", + clientRequestId, + })); + const conflict = await fixture.app.request(path, request); + fixture.runNow.mockRejectedValueOnce(Object.assign(new Error("manual recovery"), { + code: "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED", + todoId: fixture.todo.id, + sessionId: "11111111-1111-4111-8111-111111111111", + })); + const recovery = await fixture.app.request(path, request); + + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "PROJECT_TODO_RUN_NOW_CONFLICT", clientRequestId } }, + }); + expect(recovery.status).toBe(500); + expect(await recovery.json()).toMatchObject({ + error: { code: "INTERNAL_ERROR", details: { scopeCode: "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED", todoId: fixture.todo.id } }, + }); }); test("maps not-found and current-domain conflicts", async () => { @@ -145,7 +276,7 @@ describe("Project Todo routes", () => { const missing = await fixture.app.request(base, { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ expectedRevision: 1, body: "Change" }), + body: JSON.stringify({ expectedRevision: 1, content: "Change" }), }); fixture.updateTodo.mockRejectedValueOnce(Object.assign(new Error("stale"), { code: "PROJECT_TODO_REVISION_CONFLICT", @@ -154,7 +285,7 @@ describe("Project Todo routes", () => { const conflict = await fixture.app.request(base, { method: "PATCH", headers: { "content-type": "application/json" }, - body: JSON.stringify({ expectedRevision: 1, body: "Change" }), + body: JSON.stringify({ expectedRevision: 1, content: "Change" }), }); expect(missing.status).toBe(404); @@ -166,6 +297,62 @@ describe("Project Todo routes", () => { error: { code: "PROJECT_TODO_REVISION_CONFLICT", message: "stale" }, }); }); + + test("reads the fixed bounded Todo Plan and returns null when it is absent", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const planDirectory = join(workspaceRoot, ".archcode", "plans"); + await mkdir(planDirectory, { recursive: true }); + const planPath = join(planDirectory, `${todo.id}.md`); + await writeFile(planPath, "# Plan\n\nShip it.\n"); + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + plan: { + path: `.archcode/plans/${todo.id}.md`, + markdown: "# Plan\n\nShip it.\n", + updatedAt: expect.any(Number), + }, + }); + + await rm(planPath); + const missing = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(missing.status).toBe(200); + expect(await missing.json()).toEqual({ plan: null }); + }); + + test("rejects a Todo Plan symlink instead of reading outside the fixed path", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const planDirectory = join(workspaceRoot, ".archcode", "plans"); + const outsidePath = join(workspaceRoot, "outside.md"); + await mkdir(planDirectory, { recursive: true }); + await writeFile(outsidePath, "outside"); + await symlink(outsidePath, join(planDirectory, `${todo.id}.md`)); + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "TODO_PLAN_UNSAFE_PATH" } }, + }); + }); + + test("rejects a Todo Plan larger than the bounded read limit", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const planDirectory = join(workspaceRoot, ".archcode", "plans"); + const planPath = join(planDirectory, `${todo.id}.md`); + await mkdir(planDirectory, { recursive: true }); + await writeFile(planPath, ""); + await truncate(planPath, 1024 * 1024 + 1); + + const response = await fixture.app.request(`/api/projects/${fixture.project.slug}/todos/${todo.id}/plan`); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ + error: { code: "BAD_REQUEST", details: { scopeCode: "TODO_PLAN_TOO_LARGE" } }, + }); + }); }); function createFixture(todo: ProjectTodo) { @@ -177,12 +364,50 @@ function createFixture(todo: ProjectTodo) { }; const service = { listTodos: mock(async () => [todo]), + readTodo: mock(async (todoId: string) => { + if (todoId !== todo.id) throw Object.assign(new Error("missing"), { code: "PROJECT_TODO_NOT_FOUND", todoId }); + return todo; + }), createTodo: mock(async (input: ProjectTodoCreateInput) => ({ ...todo, ...input })), updateTodo: mock(async (_todoId: string, _input: ProjectTodoUpdateInput) => todo), + listAttachments: mock(async () => ({ todoRevision: todo.revision, attachments: [] })), + uploadAttachment: mock(async (input) => ({ + todo: { ...todo, revision: todo.revision + 1, attachmentIds: [...todo.attachmentIds, input.attachmentId] }, + attachment: { + id: input.attachmentId, + name: input.name, + mediaType: input.mediaType ?? "application/octet-stream", + sizeBytes: input.sizeBytes, + kind: "file" as const, + }, + })), + openAttachment: mock(async (): Promise<{ + readonly descriptor: AttachmentDescriptor; + readonly contentPath: string; + }> => { + throw new Error("not configured"); + }), + removeAttachment: mock(async () => todo), createSession: mock(async (_todoId: string, _input: CreateProjectTodoSessionInput) => ({ todo, sessionId: "11111111-1111-4111-8111-111111111111", })), + runNow: mock(async (_input: ProjectTodoRunNowInput) => ({ + todo, + session: { + sessionId: "11111111-1111-4111-8111-111111111111", + cwd: workspaceRoot, + rootSessionId: "11111111-1111-4111-8111-111111111111", + agentName: "lead" as const, + profile: "principal" as const, + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: "Capture an idea", + source: { kind: "todo" as const, todoId: todo.id, entry: "work" as const }, + createdAt: todo.createdAt, + updatedAt: todo.updatedAt, + }, + })), } satisfies ProjectTodoServiceLike; const runtime = { projectRegistry: { get: mock(async (slug: string) => slug === project.slug ? project : undefined) }, @@ -198,8 +423,8 @@ function makeTodo(overrides: Partial = {}): ProjectTodo { const now = Date.now(); return { id: crypto.randomUUID(), - title: "Capture an idea", - body: "Explore the idea.", + content: "Capture an idea\n\nExplore the idea.", + attachmentIds: [], status: "idea", revision: 1, createdAt: now, diff --git a/apps/server/src/routes/todos.ts b/apps/server/src/routes/todos.ts index a97275fb..51313e40 100644 --- a/apps/server/src/routes/todos.ts +++ b/apps/server/src/routes/todos.ts @@ -1,18 +1,28 @@ import { Hono } from "hono"; +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative } from "node:path"; import type { CreateProjectTodoSessionInput, CreateProjectTodoSessionResponse, + AttachmentDescriptor, ProjectTodo, + ProjectTodoAttachmentListResponse, + ProjectTodoAttachmentMutationResponse, ProjectTodoCreateInput, + ProjectTodoPlan, + ProjectTodoPlanResponse, + ProjectTodoRunNowInput, + ProjectTodoRunNowResponse, ProjectTodoUpdateInput, } from "@archcode/protocol"; import { - PROJECT_TODO_BODY_MAX_LENGTH, + PROJECT_TODO_CONTENT_MAX_LENGTH, PROJECT_TODO_REJECTION_REASON_MAX_LENGTH, - PROJECT_TODO_TITLE_MAX_LENGTH, } from "@archcode/protocol"; import { CreateProjectTodoSessionSchema, + ProjectTodoRunNowSchema, type AgentRuntime, } from "@archcode/agent-core"; import { z } from "zod/v4"; @@ -20,20 +30,26 @@ import { z } from "zod/v4"; import { BadRequestError, ServerError } from "../errors"; import { resolveProject } from "../resolve"; import { zValidator } from "../validation"; +import { mapAttachmentHttpError } from "./attachment-http-error"; +import { attachmentDisposition, parseDecimal } from "./attachments"; const ProjectTodoListParamsSchema = z.strictObject({ slug: z.string().min(1) }); const ProjectTodoParamsSchema = z.strictObject({ slug: z.string().min(1), todoId: z.uuid(), }); +const ProjectTodoAttachmentParamsSchema = ProjectTodoParamsSchema.extend({ + attachmentId: z.uuid(), +}); +const ProjectTodoAttachmentDeleteBodySchema = z.strictObject({ + expectedRevision: z.number().int().positive(), +}); const ProjectTodoCreateBodySchema = z.strictObject({ - title: z.string().trim().min(1).max(PROJECT_TODO_TITLE_MAX_LENGTH), - body: z.string().max(PROJECT_TODO_BODY_MAX_LENGTH).optional(), + content: z.string().trim().min(1).max(PROJECT_TODO_CONTENT_MAX_LENGTH), }); const ProjectTodoUpdateBodySchema = z.strictObject({ expectedRevision: z.number().int().positive(), - title: z.string().trim().min(1).max(PROJECT_TODO_TITLE_MAX_LENGTH).optional(), - body: z.string().max(PROJECT_TODO_BODY_MAX_LENGTH).optional(), + content: z.string().trim().min(1).max(PROJECT_TODO_CONTENT_MAX_LENGTH).optional(), status: z.enum(["idea", "ready", "in_progress", "done", "rejected"]).optional(), rejectionReason: z.string().trim().min(1).max(PROJECT_TODO_REJECTION_REASON_MAX_LENGTH).optional(), archived: z.boolean().optional(), @@ -47,14 +63,37 @@ const ProjectTodoUpdateBodySchema = z.strictObject({ context.addIssue({ code: "custom", path: ["archived"], message: "archived cannot be combined with other Todo fields" }); } }); +const MAX_TODO_PLAN_BYTES = 1024 * 1024; export interface ProjectTodoServiceLike { listTodos(): Promise; + readTodo(todoId: string): Promise; createTodo(input: ProjectTodoCreateInput): Promise; updateTodo(todoId: string, input: ProjectTodoUpdateInput): Promise; + listAttachments(todoId: string): Promise; + uploadAttachment(input: { + readonly todoId: string; + readonly attachmentId: string; + readonly expectedRevision: number; + readonly name: string; + readonly sizeBytes: number; + readonly mediaType?: string; + readonly contentLength?: number; + readonly body: ReadableStream | null; + }): Promise; + openAttachment(input: { + readonly todoId: string; + readonly attachmentId: string; + }): Promise<{ readonly descriptor: AttachmentDescriptor; readonly contentPath: string }>; + removeAttachment(input: { + readonly todoId: string; + readonly attachmentId: string; + readonly expectedRevision: number; + }): Promise; createSession( todoId: string, input: CreateProjectTodoSessionInput, ): Promise; + runNow(input: ProjectTodoRunNowInput): Promise; } export function createTodosRoutes(runtime: AgentRuntime): Hono { @@ -85,6 +124,133 @@ export function createTodosRoutes(runtime: AgentRuntime): Hono { }, ); + app.post( + "/:slug/todos/run-now", + zValidator("param", ProjectTodoListParamsSchema), + zValidator("json", ProjectTodoRunNowSchema), + async (c) => { + const project = await resolveProject(runtime, c.req.valid("param").slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + return c.json(await service.runNow(c.req.valid("json")), 201); + } catch (error) { + throw mapTodoError(error); + } + }, + ); + + app.get( + "/:slug/todos/:todoId/attachments", + zValidator("param", ProjectTodoParamsSchema), + async (c) => { + const { slug, todoId } = c.req.valid("param"); + const project = await resolveProject(runtime, slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + return c.json(await service.listAttachments(todoId)); + } catch (error) { + throw mapAttachmentOrTodoError(error); + } + }, + ); + + app.put( + "/:slug/todos/:todoId/attachments/:attachmentId", + zValidator("param", ProjectTodoAttachmentParamsSchema), + async (c) => { + const { slug, todoId, attachmentId } = c.req.valid("param"); + const project = await resolveProject(runtime, slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + const name = c.req.query("name") ?? ""; + const sizeBytes = parseDecimal(c.req.query("sizeBytes"), "sizeBytes"); + const expectedRevision = parseDecimal(c.req.query("expectedRevision"), "expectedRevision"); + const contentLengthHeader = c.req.header("content-length"); + const contentLength = contentLengthHeader === undefined + ? undefined + : parseDecimal(contentLengthHeader, "Content-Length"); + try { + return c.json(await service.uploadAttachment({ + todoId, + attachmentId, + expectedRevision, + name, + sizeBytes, + mediaType: c.req.header("content-type"), + contentLength, + body: c.req.raw.body, + })); + } catch (error) { + throw mapAttachmentOrTodoError(error); + } + }, + ); + + app.get( + "/:slug/todos/:todoId/attachments/:attachmentId", + zValidator("param", ProjectTodoAttachmentParamsSchema), + async (c) => { + const { slug, todoId, attachmentId } = c.req.valid("param"); + const project = await resolveProject(runtime, slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + const opened = await service.openAttachment({ todoId, attachmentId }); + const inline = opened.descriptor.kind === "image" + || opened.descriptor.mediaType === "application/pdf"; + return new Response(Bun.file(opened.contentPath), { + headers: { + "content-type": opened.descriptor.mediaType, + "content-length": String(opened.descriptor.sizeBytes), + "content-disposition": attachmentDisposition( + opened.descriptor.name, + inline ? "inline" : "attachment", + ), + "x-content-type-options": "nosniff", + }, + }); + } catch (error) { + throw mapAttachmentOrTodoError(error); + } + }, + ); + + app.delete( + "/:slug/todos/:todoId/attachments/:attachmentId", + zValidator("param", ProjectTodoAttachmentParamsSchema), + zValidator("json", ProjectTodoAttachmentDeleteBodySchema), + async (c) => { + const { slug, todoId, attachmentId } = c.req.valid("param"); + const project = await resolveProject(runtime, slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + return c.json({ + todo: await service.removeAttachment({ + todoId, + attachmentId, + expectedRevision: c.req.valid("json").expectedRevision, + }), + }); + } catch (error) { + throw mapAttachmentOrTodoError(error); + } + }, + ); + + app.get( + "/:slug/todos/:todoId/plan", + zValidator("param", ProjectTodoParamsSchema), + async (c) => { + const { slug, todoId } = c.req.valid("param"); + const project = await resolveProject(runtime, slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + await service.readTodo(todoId); + return c.json({ plan: await readTodoPlan(project.workspaceRoot, todoId) } satisfies ProjectTodoPlanResponse); + } catch (error) { + throw mapTodoError(error); + } + }, + ); + app.patch( "/:slug/todos/:todoId", zValidator("param", ProjectTodoParamsSchema), @@ -120,6 +286,63 @@ export function createTodosRoutes(runtime: AgentRuntime): Hono { return app; } +export async function readTodoPlan( + workspaceRoot: string, + todoId: string, +): Promise { + const relativePath = join(".archcode", "plans", `${todoId}.md`); + const candidate = join(workspaceRoot, relativePath); + let handle; + try { + handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return null; + if (hasErrorCode(error, "ELOOP")) { + throw new ServerError("BAD_REQUEST", "Todo Plan must not be a symbolic link", 400, { scopeCode: "TODO_PLAN_UNSAFE_PATH" }); + } + throw error; + } + try { + const [canonicalWorkspace, canonicalCandidate, fileInfo, pathInfo] = await Promise.all([ + realpath(workspaceRoot), + realpath(candidate), + handle.stat(), + lstat(candidate), + ]); + const canonicalPlansRoot = join(canonicalWorkspace, ".archcode", "plans"); + const relativeCandidate = relative(canonicalPlansRoot, canonicalCandidate); + const unsafePath = relativeCandidate !== `${todoId}.md` + || relativeCandidate.startsWith("..") + || isAbsolute(relativeCandidate) + || pathInfo.isSymbolicLink() + || pathInfo.dev !== fileInfo.dev + || pathInfo.ino !== fileInfo.ino; + if (unsafePath || !fileInfo.isFile()) { + throw new ServerError("BAD_REQUEST", "Todo Plan must be a regular file", 400, { scopeCode: "TODO_PLAN_UNSAFE_PATH" }); + } + if (fileInfo.size > MAX_TODO_PLAN_BYTES) { + throw new ServerError("BAD_REQUEST", "Todo Plan exceeds the 1 MiB read limit", 413, { scopeCode: "TODO_PLAN_TOO_LARGE" }); + } + const buffer = Buffer.allocUnsafe(MAX_TODO_PLAN_BYTES + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > MAX_TODO_PLAN_BYTES) { + throw new ServerError("BAD_REQUEST", "Todo Plan exceeds the 1 MiB read limit", 413, { scopeCode: "TODO_PLAN_TOO_LARGE" }); + } + return { + path: relativePath, + markdown: buffer.subarray(0, bytesRead).toString("utf8"), + updatedAt: fileInfo.mtimeMs, + }; + } catch (error) { + if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR") || hasErrorCode(error, "ELOOP")) { + throw new ServerError("BAD_REQUEST", "Todo Plan path changed while it was being read", 400, { scopeCode: "TODO_PLAN_UNSAFE_PATH" }); + } + throw error; + } finally { + await handle.close(); + } +} + async function resolveTodos(runtime: AgentRuntime, workspaceRoot: string): Promise { const context = await runtime.contextResolver.resolve(workspaceRoot); return context.todos; @@ -132,16 +355,37 @@ function mapTodoError(error: unknown): Error { if (hasCode(error, "PROJECT_TODO_NOT_FOUND")) { return new ServerError("PROJECT_TODO_NOT_FOUND", error.message, 404, { todoId: error.todoId }); } + if (hasCode(error, "PROJECT_TODO_RUN_NOW_CONFLICT")) { + return new ServerError("BAD_REQUEST", error.message, 409, { + scopeCode: error.code, + clientRequestId: "clientRequestId" in error ? error.clientRequestId : undefined, + }); + } + if (hasCode(error, "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED")) { + return new ServerError("INTERNAL_ERROR", error.message, 500, { + scopeCode: error.code, + todoId: error.todoId, + sessionId: "sessionId" in error ? error.sessionId : undefined, + }); + } if (isProjectTodoConflict(error)) { return new ServerError(error.code, error.message, 409, error); } return error instanceof Error ? error : new Error(String(error)); } +function mapAttachmentOrTodoError(error: unknown): Error { + return mapAttachmentHttpError(error) ?? mapTodoError(error); +} + function hasCode(error: unknown, code: string): error is Error & { readonly code: string; readonly todoId?: string } { return error instanceof Error && "code" in error && error.code === code; } +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + type ProjectTodoConflictCode = | "PROJECT_TODO_REVISION_CONFLICT" | "PROJECT_TODO_INVALID_MUTATION" diff --git a/apps/web/package.json b/apps/web/package.json index 07b8c5f2..11b8945d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "bun test --isolate && bun run test:interaction", - "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SidebarToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/features/Sidebar.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" + "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" }, "dependencies": { "@archcode/protocol": "workspace:*", diff --git a/apps/web/src/api/automations.test.ts b/apps/web/src/api/automations.test.ts index a9faf708..833cbc40 100644 --- a/apps/web/src/api/automations.test.ts +++ b/apps/web/src/api/automations.test.ts @@ -37,13 +37,12 @@ describe("Automation API queries", () => { }); }); -test("Automation invalidation refreshes list, dashboard, detail, and history", async () => { +test("Automation invalidation refreshes list, Home, detail, and history", async () => { const calls: unknown[] = []; await invalidateAutomation({ invalidateQueries: async (input) => { calls.push(input.queryKey); } }, "demo", "a1"); expect(calls).toEqual([ queryKeys.projectAutomations("demo"), - queryKeys.dashboardProjection({ kind: "global" }), - queryKeys.dashboardProjection({ kind: "project", projectSlug: "demo" }), + queryKeys.home, queryKeys.automation("demo", "a1"), queryKeys.automationInvocations("demo", "a1"), ]); diff --git a/apps/web/src/api/mutations.test.ts b/apps/web/src/api/mutations.test.ts index bbdbb758..9e3e3604 100644 --- a/apps/web/src/api/mutations.test.ts +++ b/apps/web/src/api/mutations.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { createSession, deleteSession, invalidateSessionModelSelectionQuery, patchSessionModelSelection, postMessage, setSessionGoalBudget, stopSessionFamily, uploadSessionAttachment } from "./mutations"; +import { createSession, deleteSession, invalidateProjectCatalog, invalidateSessionModelSelectionQuery, patchSessionModelSelection, postMessage, removeProjectTodoAttachment, setSessionGoalBudget, stopSessionFamily, uploadProjectTodoAttachment, uploadSessionAttachment } from "./mutations"; +import { queryKeys } from "./queries"; const originalFetch = globalThis.fetch; const originalDocument = globalThis.document; @@ -10,6 +11,18 @@ afterEach(() => { globalThis.document = originalDocument; }); +describe("project catalog mutation invalidation", () => { + test("refreshes both the Project Rail and global Home projection", async () => { + const invalidateQueries = mock(async () => undefined); + + await invalidateProjectCatalog({ invalidateQueries } as never); + + expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.projects }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.home }); + }); +}); + function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: init.status ?? 200, @@ -109,6 +122,45 @@ describe("web session runtime mutation API calls", () => { })).resolves.toMatchObject({ id: "11111111-1111-4111-8111-111111111111", kind: "file" }); }); + test("uploads a Todo reference with the authoritative expected revision", async () => { + globalThis.document = { cookie: "" } as Document; + const file = new File(["hello"], "brief.pdf", { type: "application/pdf" }); + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe(`/api/projects/${TEST_PROJECT_SLUG}/todos/todo-1/attachments/22222222-2222-4222-8222-222222222222?name=brief.pdf&sizeBytes=5&expectedRevision=7`); + expect(init?.method).toBe("PUT"); + expect(init?.body).toBe(file); + expect(new Headers(init?.headers).get("Content-Type")).toBe(file.type); + return jsonResponse({ todo: { id: "todo-1", revision: 8 }, attachment: { id: "22222222-2222-4222-8222-222222222222", name: "brief.pdf", mediaType: "application/pdf", sizeBytes: 5, kind: "file" } }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(uploadProjectTodoAttachment({ + slug: TEST_PROJECT_SLUG, + todoId: "todo-1", + attachmentId: "22222222-2222-4222-8222-222222222222", + expectedRevision: 7, + file, + })).resolves.toMatchObject({ todo: { revision: 8 }, attachment: { kind: "file" } }); + }); + + test("removes a Todo reference with the expected revision body", async () => { + globalThis.document = { cookie: "" } as Document; + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe(`/api/projects/${TEST_PROJECT_SLUG}/todos/todo-1/attachments/22222222-2222-4222-8222-222222222222`); + expect(init?.method).toBe("DELETE"); + expect(JSON.parse(String(init?.body))).toEqual({ expectedRevision: 8 }); + return jsonResponse({ todo: { id: "todo-1", revision: 9 } }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(removeProjectTodoAttachment({ + slug: TEST_PROJECT_SLUG, + todoId: "todo-1", + attachmentId: "22222222-2222-4222-8222-222222222222", + expectedRevision: 8, + })).resolves.toMatchObject({ todo: { revision: 9 } }); + }); + test("PATCH model selection sends optimistic revision and returns complete model state", async () => { globalThis.document = { cookie: "" } as Document; const response = { modelSelection: { revision: 3, override: requestedModelSelection.selection }, nextModelSelection: { requested: requestedModelSelection, resolved: { selection: requestedModelSelection.selection, providerId: "openai", modelId: "gpt-5", providerDisplayName: "OpenAI", modelDisplayName: "GPT-5", resolution: "session_override" as const, modelRuntimeRevision: "m3" } } }; diff --git a/apps/web/src/api/mutations.ts b/apps/web/src/api/mutations.ts index 0a60eda2..d04f4b94 100644 --- a/apps/web/src/api/mutations.ts +++ b/apps/web/src/api/mutations.ts @@ -1,7 +1,13 @@ import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { apiFetch } from "./client"; import { queryKeys } from "./queries"; -import type { AttachmentDescriptor, RequestedModelSelection, SessionModelState } from "@archcode/protocol"; +import type { + AttachmentDescriptor, + ProjectTodoAttachmentMutationResponse, + ProjectTodoResponse, + RequestedModelSelection, + SessionModelState, +} from "@archcode/protocol"; import { removeProjectControlPlane, removeSessionControlPlane, @@ -15,6 +21,11 @@ import type { Project, Session, SessionSummary, + ProjectSessionInventoryItem, + ProjectTodoRunNowResponse, + Automation, + AutomationAction, + AutomationTrigger, UpdateAutomationPayload, ProjectTodo, ProjectTodoCreateInput, @@ -23,6 +34,15 @@ import type { } from "./types"; import { createClientUuid } from "../lib/client-uuid"; +export async function invalidateProjectCatalog( + queryClient: Pick, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.projects }), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), + ]); +} + export function useUpdateProjectName() { const queryClient = useQueryClient(); @@ -33,7 +53,7 @@ export function useUpdateProjectName() { body: { name }, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + await invalidateProjectCatalog(queryClient); }, }); } @@ -47,7 +67,7 @@ export function useAddProject() { body: { workspaceRoot: path, ...(name ? { name } : {}) }, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + await invalidateProjectCatalog(queryClient); }, }); } @@ -61,7 +81,8 @@ export function useDeleteProject() { }), onSuccess: async (_data, slug) => { removeProjectControlPlane(slug); - await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); + queryClient.removeQueries({ queryKey: ["projects", slug] }); + await invalidateProjectCatalog(queryClient); }, }); } @@ -106,9 +127,9 @@ export function useDeleteSession() { return useMutation({ mutationFn: (input: DeleteSessionInput) => deleteSession(input), onSuccess: (_data, variables) => { - queryClient.setQueryData( + queryClient.setQueryData( queryKeys.sessions(variables.slug), - (sessions) => sessions?.filter((session) => session.sessionId !== variables.rootSessionId), + (items) => items?.filter((item) => item.session.sessionId !== variables.rootSessionId), ); for (const deletedSessionId of new Set(variables.sessionIds)) { queryClient.removeQueries({ @@ -126,15 +147,7 @@ export function useDeleteSession() { void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.sessions(variables.slug) }), queryClient.invalidateQueries({ queryKey: queryKeys.projectTodos(variables.slug) }), - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardProjection({ kind: "global" }), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardProjection({ - kind: "project", - projectSlug: variables.slug, - }), - }), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), ]); }, }); @@ -347,7 +360,7 @@ async function invalidateSessionGoalQueries(queryClient: QueryClient, slug: stri await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.session(slug, sessionId) }), queryClient.invalidateQueries({ queryKey: queryKeys.sessions(slug) }), - ...invalidateDashboardProjection(queryClient, slug), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), ]); } @@ -406,7 +419,7 @@ export function invalidateAutomation( ): Promise { return Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.projectAutomations(slug) }), - ...invalidateDashboardProjection(qc, slug), + qc.invalidateQueries({ queryKey: queryKeys.home }), ...(automationId === undefined ? [] : [ qc.invalidateQueries({ queryKey: queryKeys.automation(slug, automationId) }), qc.invalidateQueries({ queryKey: queryKeys.automationInvocations(slug, automationId) }), @@ -478,6 +491,26 @@ export function useDeleteAutomation() { }); } +export interface CreateAutomationInput { + slug: string; + name: string; + trigger: AutomationTrigger; + action: AutomationAction; +} + +export function useCreateAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ slug, ...input }: CreateAutomationInput) => apiFetch<{ automation: Automation }>( + `/api/projects/${encodeURIComponent(slug)}/automations`, + { method: "POST", body: input }, + ), + onSuccess: async (_data, variables) => { + await invalidateAutomation(queryClient, variables.slug); + }, + }); +} + // ─── Project Todo mutations ─── type TodoMutationVariables = { slug: string; todoId: string }; @@ -496,6 +529,57 @@ async function invalidateProjectTodo( await queryClient.invalidateQueries({ queryKey: queryKeys.projectTodos(slug), exact: true, refetchType: "all" }); } +export interface UploadProjectTodoAttachmentInput { + slug: string; + todoId: string; + attachmentId: string; + expectedRevision: number; + file: File; +} + +/** Upload one raw file and activate its Todo reference atomically on the server. */ +export function uploadProjectTodoAttachment({ + slug, + todoId, + attachmentId, + expectedRevision, + file, +}: UploadProjectTodoAttachmentInput): Promise { + const query = new URLSearchParams({ + name: file.name, + sizeBytes: String(file.size), + expectedRevision: String(expectedRevision), + }); + return apiFetch( + `${todoUrl(slug, todoId, "attachments")}/${encodeURIComponent(attachmentId)}?${query.toString()}`, + { + method: "PUT", + headers: file.type ? { "Content-Type": file.type } : undefined, + body: file, + }, + ); +} + +export interface RemoveProjectTodoAttachmentInput { + slug: string; + todoId: string; + attachmentId: string; + expectedRevision: number; +} + +/** Remove the Todo reference; physical cleanup remains a server concern. */ +export function removeProjectTodoAttachment({ + slug, + todoId, + attachmentId, + expectedRevision, +}: RemoveProjectTodoAttachmentInput): Promise { + return apiFetch( + `${todoUrl(slug, todoId, "attachments")}/${encodeURIComponent(attachmentId)}`, + { method: "DELETE", body: { expectedRevision } }, + ); +} + async function invalidateProjectTodoSession( queryClient: ReturnType, slug: string, @@ -503,21 +587,11 @@ async function invalidateProjectTodoSession( await Promise.all([ invalidateProjectTodo(queryClient, slug), queryClient.invalidateQueries({ queryKey: queryKeys.sessions(slug) }), - ...invalidateDashboardProjection(queryClient, slug), + queryClient.invalidateQueries({ queryKey: queryKeys.home }), queryClient.invalidateQueries({ queryKey: queryKeys.projectAutomations(slug) }), ]); } -function invalidateDashboardProjection( - qc: { invalidateQueries: (opts: { queryKey: readonly unknown[] }) => Promise }, - slug: string, -): Promise[] { - return [ - qc.invalidateQueries({ queryKey: queryKeys.dashboardProjection({ kind: "global" }) }), - qc.invalidateQueries({ queryKey: queryKeys.dashboardProjection({ kind: "project", projectSlug: slug }) }), - ]; -} - export function useCreateProjectTodo() { const queryClient = useQueryClient(); return useMutation({ @@ -532,6 +606,20 @@ export function useCreateProjectTodo() { }); } +export function useRunProjectTodoNow() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ slug, clientRequestId, content }: { slug: string; clientRequestId: string; content: string }) => + apiFetch(`${todoUrl(slug)}/run-now`, { + method: "POST", + body: { clientRequestId, content }, + }), + onSettled: async (_data, _error, variables) => { + await invalidateProjectTodoSession(queryClient, variables.slug); + }, + }); +} + export function useUpdateProjectTodo() { const queryClient = useQueryClient(); return useMutation({ diff --git a/apps/web/src/api/queries.test.ts b/apps/web/src/api/queries.test.ts index 684ab462..4a2b8aba 100644 --- a/apps/web/src/api/queries.test.ts +++ b/apps/web/src/api/queries.test.ts @@ -1,14 +1,13 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import type { AgentDescriptor, - DashboardProjection, SessionGoal, SessionSummary, } from "@archcode/protocol"; import { agentsQueryOptions, - dashboardProjectionQueryOptions, diffQueryOptions, + projectTodoAttachmentsQueryOptions, queryKeys, sessionsQueryOptions, } from "./queries"; @@ -98,67 +97,34 @@ describe("web Session Goal query contracts", () => { ).toEqual(sessions); }); - test("fetches the shared Dashboard projection in global scope", async () => { - globalThis.document = { cookie: "" } as Document; - const projection: DashboardProjection = { - scope: { kind: "global" }, - sessions: [], - automations: [], - errors: [], - }; - globalThis.fetch = mock(async (input) => { - expect(String(input)).toBe("/api/dashboard"); - return jsonResponse(projection); - }) as unknown as typeof fetch; - const options = dashboardProjectionQueryOptions({ kind: "global" }); - expect([...options.queryKey]).toEqual(["dashboard", "global"]); - expect( - await ( - options as unknown as QueryOptionWithFn - ).queryFn(), - ).toEqual(projection); - }); - - test("fetches the same Dashboard projection contract in project scope", async () => { + test("keeps Diff scoped to a Session", async () => { globalThis.document = { cookie: "" } as Document; - const projection: DashboardProjection = { - scope: { kind: "project", projectSlug: "demo space" }, - sessions: [], - automations: [], - errors: [], - }; globalThis.fetch = mock(async (input) => { - expect(String(input)).toBe("/api/projects/demo%20space/dashboard"); - return jsonResponse(projection); + expect(String(input)).toBe(`/api/projects/${slug}/diff?sessionId=root`); + return jsonResponse({ files: [] }); }) as unknown as typeof fetch; - const options = dashboardProjectionQueryOptions({ - kind: "project", - projectSlug: "demo space", - }); - expect([...options.queryKey]).toEqual([ - "dashboard", - "project", - "demo space", - ]); expect( await ( - options as unknown as QueryOptionWithFn + diffQueryOptions(slug, "root") as unknown as QueryOptionWithFn< + unknown[] + > ).queryFn(), - ).toEqual(projection); + ).toEqual([]); }); +}); - test("keeps Diff scoped to a Session", async () => { +describe("Todo reference query contract", () => { + test("reads the authoritative Todo revision and ordered descriptors", async () => { globalThis.document = { cookie: "" } as Document; + const response = { todoRevision: 4, attachments: [{ id: "attachment-1", name: "brief.pdf", mediaType: "application/pdf", sizeBytes: 5, kind: "file" as const }] }; globalThis.fetch = mock(async (input) => { - expect(String(input)).toBe(`/api/projects/${slug}/diff?sessionId=root`); - return jsonResponse({ files: [] }); + expect(String(input)).toBe(`/api/projects/${slug}/todos/todo-1/attachments`); + return jsonResponse(response); }) as unknown as typeof fetch; expect( await ( - diffQueryOptions(slug, "root") as unknown as QueryOptionWithFn< - unknown[] - > + projectTodoAttachmentsQueryOptions(slug, "todo-1") as unknown as QueryOptionWithFn ).queryFn(), - ).toEqual([]); + ).toEqual(response); }); }); diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index 98249d92..ce519a7e 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiFetch } from "./client"; -import type { AgentDescriptor, DashboardProjection, DashboardScope, ModelRuntimeCatalog } from "@archcode/protocol"; +import type { AgentDescriptor, HomeResponse, ModelRuntimeCatalog, WorkSearchResponse } from "@archcode/protocol"; import type { DiffFile, DirectoryListResponse, @@ -10,6 +10,10 @@ import type { Project, Session, SessionSummary, + ProjectSessionInventoryItem, + ProjectAutomationInventoryItem, + ProjectTodoPlan, + ProjectTodoAttachmentListResponse, SessionTreeResponse, ProjectTodo, } from "./types"; @@ -22,9 +26,8 @@ export const queryKeys = { modelRuntime: ["config", "model-runtime"] as const, update: ["update"] as const, projects: ["projects"] as const, - dashboardProjection: (scope: DashboardScope) => scope.kind === "global" - ? ["dashboard", "global"] as const - : ["dashboard", "project", scope.projectSlug] as const, + home: ["home"] as const, + workSearch: (query: string) => ["search", query] as const, sessions: (slug: string) => ["projects", slug, "sessions"] as const, session: (slug: string, sessionId: string) => ["projects", slug, "sessions", sessionId] as const, tree: (slug: string, rootSessionId: string) => ["projects", slug, "sessions", rootSessionId, "tree"] as const, @@ -37,6 +40,7 @@ export const queryKeys = { automation: (slug: string, automationId: string) => ["projects", slug, "automations", automationId] as const, automationInvocations: (slug: string, automationId: string) => ["projects", slug, "automations", automationId, "invocations"] as const, projectTodos: (slug: string) => ["projects", slug, "todos"] as const, + projectTodoAttachments: (slug: string, todoId: string) => ["projects", slug, "todos", todoId, "attachments"] as const, }; export function modelRuntimeQueryOptions() { @@ -66,11 +70,27 @@ export function projectsQueryOptions() { }); } +export function homeQueryOptions() { + return queryOptions({ queryKey: queryKeys.home, queryFn: () => apiFetch("/api/home") }); +} + +export function workSearchQueryOptions(query: string) { + const normalized = query.trim(); + return queryOptions({ + queryKey: queryKeys.workSearch(normalized), + queryFn: ({ signal }) => apiFetch( + `/api/search?q=${encodeURIComponent(normalized)}`, + { signal }, + ), + enabled: normalized.length > 0 && normalized.length <= 200, + }); +} + export function sessionsQueryOptions(slug: string) { return queryOptions({ queryKey: queryKeys.sessions(slug), queryFn: async () => { - const response = await apiFetch<{ sessions: SessionSummary[] }>( + const response = await apiFetch<{ sessions: ProjectSessionInventoryItem[] }>( `/api/projects/${encodeURIComponent(slug)}/sessions`, ); return response.sessions; @@ -107,15 +127,6 @@ export function focusedSessionQueryOptions(slug: string, focusSessionId: string }); } -export function dashboardProjectionQueryOptions(scope: DashboardScope) { - return queryOptions({ - queryKey: queryKeys.dashboardProjection(scope), - queryFn: () => apiFetch(scope.kind === "global" - ? "/api/dashboard" - : `/api/projects/${encodeURIComponent(scope.projectSlug)}/dashboard`), - }); -} - export function diffQueryOptions(slug: string, sessionId?: string) { const scopedSessionId = sessionId?.trim() || undefined; return queryOptions({ @@ -138,6 +149,14 @@ export function useProjects() { return useQuery(projectsQueryOptions()); } +export function useHome() { + return useQuery(homeQueryOptions()); +} + +export function useWorkSearch(query: string) { + return useQuery(workSearchQueryOptions(query)); +} + export function useAgents() { return useQuery(agentsQueryOptions()); } @@ -147,6 +166,10 @@ export function useModelRuntime() { } export function useSessions(slug: string) { + return useQuery({ ...sessionsQueryOptions(slug), select: (items) => items.map((item) => item.session) }); +} + +export function useSessionInventory(slug: string) { return useQuery(sessionsQueryOptions(slug)); } @@ -175,11 +198,6 @@ export function useSessionTree(slug: string, rootSessionId: string) { return useQuery(sessionTreeQueryOptions(slug, rootSessionId)); } -/** Fetches the raw server read projection; UI composition remains local and transient. */ -export function useDashboardReadProjection(scope: DashboardScope) { - return useQuery(dashboardProjectionQueryOptions(scope)); -} - export function useDiff( slug: string, sessionId?: string, @@ -223,7 +241,7 @@ export function automationsQueryOptions(slug: string) { return queryOptions({ queryKey: queryKeys.projectAutomations(slug), queryFn: async () => { - const response = await apiFetch<{ automations: Automation[] }>( + const response = await apiFetch<{ automations: ProjectAutomationInventoryItem[] }>( `/api/projects/${encodeURIComponent(slug)}/automations`, ); return response.automations; @@ -261,6 +279,10 @@ export function automationInvocationsQueryOptions(slug: string, automationId: st // ─── Automation hooks ─── export function useAutomations(slug: string) { + return useQuery({ ...automationsQueryOptions(slug), select: (items) => items.map((item) => item.automation) }); +} + +export function useAutomationInventory(slug: string) { return useQuery(automationsQueryOptions(slug)); } @@ -288,3 +310,31 @@ export function projectTodosQueryOptions(slug: string) { export function useProjectTodos(slug: string) { return useQuery(projectTodosQueryOptions(slug)); } + +export function projectTodoPlanQueryOptions(slug: string, todoId: string) { + return queryOptions({ + queryKey: ["projects", slug, "todos", todoId, "plan"] as const, + queryFn: () => apiFetch<{ plan: ProjectTodoPlan | null }>( + `/api/projects/${encodeURIComponent(slug)}/todos/${encodeURIComponent(todoId)}/plan`, + ).then((response) => response.plan), + enabled: slug.length > 0 && todoId.length > 0, + }); +} + +export function useProjectTodoPlan(slug: string, todoId: string) { + return useQuery(projectTodoPlanQueryOptions(slug, todoId)); +} + +export function projectTodoAttachmentsQueryOptions(slug: string, todoId: string) { + return queryOptions({ + queryKey: queryKeys.projectTodoAttachments(slug, todoId), + queryFn: () => apiFetch( + `/api/projects/${encodeURIComponent(slug)}/todos/${encodeURIComponent(todoId)}/attachments`, + ), + enabled: slug.length > 0 && todoId.length > 0, + }); +} + +export function useProjectTodoAttachments(slug: string, todoId: string) { + return useQuery(projectTodoAttachmentsQueryOptions(slug, todoId)); +} diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index a1ce569f..f4066fdf 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -5,6 +5,10 @@ export type { DirectoryListResponse, DirectorySearchResponse, SessionSummary, + RootSessionSummary, + ProjectSessionInventoryItem, + ProjectAutomationInventoryItem, + ProjectTodoRunNowResponse, SessionTreeResponse, SessionTreeNode, SessionTreeDiagnostic, @@ -35,17 +39,14 @@ export type { AutomationStatus, AutomationTrigger, ProjectTodo, + AttachmentDescriptor, + ProjectTodoResponse, + ProjectTodoAttachmentListResponse, + ProjectTodoAttachmentMutationResponse, ProjectTodoStatus, ProjectTodoCreateInput, ProjectTodoUpdateInput, CreateProjectTodoSessionInput, - DashboardScope, - DashboardProjection, - DashboardRootSession, - DashboardExecution, - DashboardAutomation, - DashboardAutomationInvocation, - DashboardProjectError, } from "@archcode/protocol"; import type { @@ -57,10 +58,16 @@ import type { SessionSummary, } from "@archcode/protocol"; +export interface ProjectTodoPlan { + path: string; + markdown: string; + updatedAt: number; +} + /** Complete persisted Session file returned by the Session detail endpoint. */ export type Session = ProtocolSession & Pick; -/** Visible Session-owned Goal projection, returned by Session and dashboard APIs. */ +/** Visible Session-owned Goal projection returned by Session and Home APIs. */ export type SessionGoalView = SessionGoal; export type SessionWithGoal = Session & { goal?: SessionGoalView }; diff --git a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx index 3448e5f7..14b09533 100644 --- a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx +++ b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx @@ -241,6 +241,7 @@ async function render( messages: SessionMessage[], execution: SessionExecutionRecord, steps: SessionStep[] = [], + focusClientRequestId?: string, ): Promise { await act(async () => { createWebSessionStore("session", "project") @@ -265,6 +266,7 @@ async function render( sessionId="session" sessionIdentity={{ agentName: "lead", profile: "principal" }} agents={[]} + focusClientRequestId={focusClientRequestId} /> , @@ -419,6 +421,20 @@ describe("ExecutionWorkstream", () => { ).toContain("Two"); }); + test("focuses and highlights the canonical message matched by an Invocation client request id", async () => { + await render( + [{ ...(message("invocation-message", "user", "Automated instruction", 10) as Extract), clientRequestId: "invocation-1" }], + completed(), + [], + "invocation-1", + ); + + const target = container.querySelector('[data-client-request-id="invocation-1"]'); + expect(target).not.toBeNull(); + expect(target?.className).toContain("ring-brand"); + expect(document.activeElement).toBe(target); + }); + test("renders ordered independent Work Segments while final output stays with the last segment", async () => { await render( [ diff --git a/apps/web/src/components/composite/ExecutionWorkstream.tsx b/apps/web/src/components/composite/ExecutionWorkstream.tsx index 3d03da16..c6875288 100644 --- a/apps/web/src/components/composite/ExecutionWorkstream.tsx +++ b/apps/web/src/components/composite/ExecutionWorkstream.tsx @@ -713,6 +713,7 @@ interface ExecutionTurnProps { onButtonRef: (segmentId: string, button: HTMLButtonElement | null) => void; onArticleRef: (segmentId: string, article: HTMLElement | null) => void; onInspectModelAudit?: (messageId: string) => void; + focusClientRequestId?: string | null; } const ExecutionTurn = memo(function ExecutionTurn({ @@ -724,6 +725,7 @@ const ExecutionTurn = memo(function ExecutionTurn({ onInspectModelAudit, onButtonRef, onArticleRef, + focusClientRequestId, }: ExecutionTurnProps) { executionTurnRenderObserverForTest?.(execution.id); @@ -736,9 +738,11 @@ const ExecutionTurn = memo(function ExecutionTurn({
onArticleRef(segment.id, section)} - className="flex min-w-0 scroll-mt-4 flex-col gap-3" + className={`flex min-w-0 scroll-mt-4 flex-col gap-3 ${segment.inputMessage?.clientRequestId === focusClientRequestId ? "rounded-md ring-2 ring-brand ring-offset-2 ring-offset-bg-base" : ""}`} + data-client-request-id={segment.inputMessage?.clientRequestId} data-execution-navigation-target={segment.id} data-work-segment={segment.id} + tabIndex={segment.inputMessage?.clientRequestId === focusClientRequestId ? -1 : undefined} > {segment.inputMessage && ( void; + focusClientRequestId?: string | null; } export function ExecutionWorkstream({ @@ -837,6 +842,7 @@ export function ExecutionWorkstream({ sessionIdentity, agents, onInspectModelAudit, + focusClientRequestId, }: ExecutionWorkstreamProps) { const messages = useSessionStore(sessionId, (state) => state.messages, slug); const executions = useSessionStore( @@ -897,6 +903,12 @@ export function ExecutionWorkstream({ () => projection.executions.flatMap((execution) => execution.segments), [projection.executions], ); + const focusedSegmentId = useMemo( + () => focusClientRequestId === null || focusClientRequestId === undefined + ? undefined + : segments.find((segment) => segment.inputMessage?.clientRequestId === focusClientRequestId)?.id, + [focusClientRequestId, segments], + ); const uiSnapshotRef = useRef( getWorkstreamUiSnapshot(slug, routeScopeId, sessionId), @@ -1628,6 +1640,18 @@ export function ExecutionWorkstream({ [clearPendingInputDirection, clearTouchMomentum, setFollowLatest], ); + const appliedClientRequestFocusRef = useRef(null); + useLayoutEffect(() => { + if (focusClientRequestId === null || focusClientRequestId === undefined || focusedSegmentId === undefined) return; + const focusKey = `${focusClientRequestId}\u0000${focusedSegmentId}`; + if (appliedClientRequestFocusRef.current === focusKey) return; + const target = articleByExecutionIdRef.current.get(focusedSegmentId); + if (!target) return; + appliedClientRequestFocusRef.current = focusKey; + jumpToExecution(focusedSegmentId, "auto"); + target.focus({ preventScroll: true }); + }, [focusClientRequestId, focusedSegmentId, jumpToExecution]); + const isEmpty = projection.items.length === 0 && projection.diagnostics.length === 0; @@ -1690,6 +1714,7 @@ export function ExecutionWorkstream({ onButtonRef={registerWorkButton} onArticleRef={registerExecutionArticle} onInspectModelAudit={onInspectModelAudit} + focusClientRequestId={focusClientRequestId} /> ); } diff --git a/apps/web/src/components/features/AddProjectModal.test.tsx b/apps/web/src/components/features/AddProjectModal.test.tsx index 065f07c8..5bc63c30 100644 --- a/apps/web/src/components/features/AddProjectModal.test.tsx +++ b/apps/web/src/components/features/AddProjectModal.test.tsx @@ -240,7 +240,7 @@ describe("AddProjectModal", () => { expect("name" in (addProjectMutate.mock.calls[0]?.[0] as Record)).toBe(false); }); - test("opens a successfully registered project's Dashboard", () => { + test("opens a successfully registered project's Todos", () => { const tree = renderWithState(["", "", "/workspace/archcode", -1]); const submit = findAll( tree, @@ -256,7 +256,7 @@ describe("AddProjectModal", () => { addedAt: "2026-01-01T00:00:00.000Z", }); - expect(navigate).toHaveBeenCalledWith("/projects/archcode"); + expect(navigate).toHaveBeenCalledWith("/projects/archcode/todos"); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/components/features/AddProjectModal.tsx b/apps/web/src/components/features/AddProjectModal.tsx index 7bd24a2e..451eba2f 100644 --- a/apps/web/src/components/features/AddProjectModal.tsx +++ b/apps/web/src/components/features/AddProjectModal.tsx @@ -143,7 +143,7 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { { path: selectedPath }, { onSuccess: (project) => { - navigate(`/projects/${project.slug}`); + navigate(`/projects/${project.slug}/todos`); onClose(); }, }, diff --git a/apps/web/src/components/features/ChatHeader.test.tsx b/apps/web/src/components/features/ChatHeader.test.tsx index 6b18ef7c..c702a057 100644 --- a/apps/web/src/components/features/ChatHeader.test.tsx +++ b/apps/web/src/components/features/ChatHeader.test.tsx @@ -6,6 +6,7 @@ import type { import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { JSDOM } from "jsdom"; +import { MemoryRouter } from "react-router-dom"; import { ChatHeader } from "./ChatHeader"; import { __resetWebSessionStoresForTest, @@ -84,14 +85,22 @@ describe("ChatHeader", () => { }); await act(async () => root.render( - - {}} - /> - , + + + {}} + /> + + , ), ); const status = container.querySelector( @@ -99,7 +108,12 @@ describe("ChatHeader", () => { ); expect(status?.textContent).toContain("Needs you"); expect(status?.getAttribute("data-product-status")).toBe("needs_you"); - expect(container.querySelector('header > button[aria-label="Collapse project sidebar"]')).not.toBeNull(); - expect(container.querySelector('header button[aria-label="Expand context inspector"]')).not.toBeNull(); + const source = container.querySelector('[data-testid="session-source"]'); + expect(source?.className).not.toContain("max-[760px]:hidden"); + expect(source?.querySelector("a")?.getAttribute("href")).toBe("/projects/demo/todos/todo-1"); + expect(container.querySelector('[data-testid="session-source-annotation"]')?.className).toContain("max-[760px]:hidden"); + const inspectorButton = container.querySelector('header button[aria-label="Expand context inspector"]'); + expect(inspectorButton).not.toBeNull(); + expect(inspectorButton?.className).not.toContain("max-[760px]:hidden"); }); }); diff --git a/apps/web/src/components/features/ChatHeader.tsx b/apps/web/src/components/features/ChatHeader.tsx index 1fc46f7e..ef63adfa 100644 --- a/apps/web/src/components/features/ChatHeader.tsx +++ b/apps/web/src/components/features/ChatHeader.tsx @@ -2,7 +2,6 @@ import { Link } from "react-router-dom"; import { useSessionStore } from "../../store/session-store"; import { TodoProgressButton } from "./TodoProgressButton"; import { InspectorToggleButton } from "./InspectorToggleButton"; -import { SidebarToggleButton } from "./SidebarToggleButton"; import { executionVisualKind, presentExecutionStatus, @@ -18,6 +17,7 @@ export interface ChatHeaderSource { label: string; title: string; to: string; + usesLiveTodoReferences?: true; } interface ChatHeaderProps { @@ -66,7 +66,6 @@ export function ChatHeader({ return (
-

@@ -121,12 +120,12 @@ export function ChatHeader({ <> {source.label}{" "} @@ -138,6 +137,11 @@ export function ChatHeader({ > {source.title} + {source.usesLiveTodoReferences ? ( + + · Using live Todo references + + ) : null} )} diff --git a/apps/web/src/components/features/ChatInput.test.tsx b/apps/web/src/components/features/ChatInput.test.tsx index 40c63d8f..fedfd7eb 100644 --- a/apps/web/src/components/features/ChatInput.test.tsx +++ b/apps/web/src/components/features/ChatInput.test.tsx @@ -662,14 +662,14 @@ describe("ChatInput runtime controls", () => { expect(findAll(tree, (element) => element.props?.["aria-label"] === "Collapse queued-message composer")).toHaveLength(1); }); - test("a suspended family with HITL keeps Stop and presents Needs attention", () => { + test("a suspended family with HITL keeps Stop and presents Needs you", () => { activity = "waiting_for_human"; hitlReady = true; pendingHitlCount = 1; const tree = renderChatInput(); expect(findAll(tree, (element) => element.props?.title === "Stop")).toHaveLength(1); - expect(findAll(tree, (element) => textContent(element) === "Needs attention")).toHaveLength(1); + expect(findAll(tree, (element) => textContent(element) === "Needs you")).toHaveLength(1); expect(findAll(tree, (element) => element.props?.title === "Send message")).toHaveLength(0); }); diff --git a/apps/web/src/components/features/ChatInput.tsx b/apps/web/src/components/features/ChatInput.tsx index dd66e30a..8cf896f4 100644 --- a/apps/web/src/components/features/ChatInput.tsx +++ b/apps/web/src/components/features/ChatInput.tsx @@ -59,6 +59,7 @@ export interface ChatInputProps { activity: SessionFamilyActivity | undefined; hitlReady: boolean; hasPendingHitl: boolean; + focusOnReady?: boolean; } function composerStatus( @@ -69,7 +70,7 @@ function composerStatus( if (activity === undefined) return { label: "Connecting", kind: "running", tone: "neutral" }; if (!hitlReady) return { label: "Syncing", kind: "running", tone: "info" }; if (activity === "stopping") return { label: "Stopping", kind: "running", tone: "warning" }; - if (hasPendingHitl) return { label: "Needs attention", kind: "needs_you" }; + if (hasPendingHitl) return { label: "Needs you", kind: "needs_you" }; if (activity === "running" || activity === "resuming") { return { label: sessionFamilyActivityLabel(activity), kind: "running" }; } @@ -85,6 +86,7 @@ export function ChatInput({ activity, hitlReady, hasPendingHitl, + focusOnReady = false, }: ChatInputProps) { const [value, setValue] = useState(""); const [showSlashMenu, setShowSlashMenu] = useState(false); @@ -94,6 +96,7 @@ export function ChatInput({ const [attachments, setAttachments] = useState([]); const [attachmentNotice, setAttachmentNotice] = useState(); const textareaRef = useRef(null); + const focusOnReadyAppliedRef = useRef(false); const slashMenuRef = useRef(null); const fileInputRef = useRef(null); const attachmentsRef = useRef([]); @@ -154,6 +157,12 @@ export function ChatInput({ if (!hasPendingHitl) setHitlComposerExpanded(false); }, [hasPendingHitl]); + useEffect(() => { + if (!focusOnReady || !canCompose || focusOnReadyAppliedRef.current) return; + focusOnReadyAppliedRef.current = true; + textareaRef.current?.focus(); + }, [canCompose, focusOnReady]); + useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( diff --git a/apps/web/src/components/features/CloseProjectDialog.test.tsx b/apps/web/src/components/features/CloseProjectDialog.test.tsx index 3724e488..76294e5b 100644 --- a/apps/web/src/components/features/CloseProjectDialog.test.tsx +++ b/apps/web/src/components/features/CloseProjectDialog.test.tsx @@ -137,7 +137,7 @@ describe("CloseProjectDialog", () => { expect(copy).toContain("The workspace folder will"); expect(copy).toContain("not"); expect(copy).toContain("be deleted"); - expect(copy).toContain("removes the project from the sidebar"); + expect(copy).toContain("removes the project from ArchCode"); }); test("destructive Close Project button deletes by slug and fires callbacks on success", () => { diff --git a/apps/web/src/components/features/CloseProjectDialog.tsx b/apps/web/src/components/features/CloseProjectDialog.tsx index c8d9051f..72fbb025 100644 --- a/apps/web/src/components/features/CloseProjectDialog.tsx +++ b/apps/web/src/components/features/CloseProjectDialog.tsx @@ -71,7 +71,7 @@ export function CloseProjectDialog({

The workspace folder will not be deleted. This only - removes the project from the sidebar. + removes the project from ArchCode.

diff --git a/apps/web/src/components/features/ComposerQueueList.interaction.tsx b/apps/web/src/components/features/ComposerQueueList.interaction.tsx index e6c7dcff..e4116d51 100644 --- a/apps/web/src/components/features/ComposerQueueList.interaction.tsx +++ b/apps/web/src/components/features/ComposerQueueList.interaction.tsx @@ -83,6 +83,7 @@ beforeEach(async () => { Object.defineProperties(dom.window.HTMLElement.prototype, { attachEvent: { configurable: true, value: () => {} }, detachEvent: { configurable: true, value: () => {} }, + scrollIntoView: { configurable: true, value: () => {} }, }); for (const [name, value] of Object.entries({ window: dom.window, @@ -206,7 +207,7 @@ describe("ComposerQueueList", () => { client.setQueryData(queryKeys.modelRuntime, modelRuntime); await act(async () => { - root.render(); + root.render(); await Promise.resolve(); }); @@ -231,6 +232,9 @@ describe("ComposerQueueList", () => { expect(localModel?.className).toContain("max-[560px]:max-w-16"); const queuedRow = container.querySelector('[data-testid="composer-queue-queued-row"]'); + expect(queuedRow?.getAttribute("data-client-request-id")).toBe("queued-client"); + expect(queuedRow?.className).toContain("ring-brand"); + expect(document.activeElement).toBe(queuedRow); expect(queuedRow?.textContent).toContain("Steer"); expect(queuedRow?.textContent).toContain("Edit"); expect(queuedRow?.textContent).toContain("Delete"); diff --git a/apps/web/src/components/features/ComposerQueueList.tsx b/apps/web/src/components/features/ComposerQueueList.tsx index bc4be7c1..9b8672a9 100644 --- a/apps/web/src/components/features/ComposerQueueList.tsx +++ b/apps/web/src/components/features/ComposerQueueList.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LoaderCircle, TriangleAlert } from "lucide-react"; import type { ExecutionModelBindingSummary, @@ -30,7 +30,7 @@ type QueueEntry = | { kind: "durable"; message: PendingSessionMessage; time: number; order: number } | { kind: "local"; message: LocalSendingMessage; time: number; order: number }; -export function ComposerQueueList({ slug, sessionId }: { slug: string; sessionId: string }) { +export function ComposerQueueList({ slug, sessionId, focusClientRequestId }: { slug: string; sessionId: string; focusClientRequestId?: string | null }) { const pendingMessages = useSessionStore(sessionId, (state) => state.pendingMessages, slug); const localSendingMessages = useSessionStore(sessionId, (state) => state.localSendingMessages, slug); const activeModelBinding = useSessionStore(sessionId, (state) => state.activeModelBinding, slug); @@ -58,6 +58,7 @@ export function ComposerQueueList({ slug, sessionId }: { slug: string; sessionId sessionId={sessionId} slug={slug} steerTargetExecutionId={steerTargetExecutionId} + focused={entry.message.clientRequestId === focusClientRequestId} /> ) : ( @@ -90,6 +91,7 @@ function DurableQueueRow({ activeModelBinding, modelRuntime, nextModelSelection, + focused, }: { message: PendingSessionMessage; slug: string; @@ -98,6 +100,7 @@ function DurableQueueRow({ activeModelBinding?: ExecutionModelBindingSummary; modelRuntime?: ModelRuntimeCatalog; nextModelSelection?: SessionNextModelSelection; + focused: boolean; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(message.content); @@ -118,6 +121,13 @@ function DurableQueueRow({ const mutationError = queueMutationError(editMessage.error, deleteMessage.error, steerMessage.error); const busy = editMessage.isPending || deleteMessage.isPending || steerMessage.isPending; const nextDraft = draft.trim(); + const rowRef = useRef(null); + + useEffect(() => { + if (!focused || !rowRef.current) return; + rowRef.current.scrollIntoView({ block: "nearest" }); + rowRef.current.focus({ preventScroll: true }); + }, [focused, message.id]); const openEditor = () => { editMessage.reset(); @@ -127,9 +137,12 @@ function DurableQueueRow({ return (
{message.state === "steering" ? ( diff --git a/apps/web/src/components/features/DeleteResourceDialogs.test.tsx b/apps/web/src/components/features/DeleteResourceDialogs.test.tsx index 39837d51..6d6bf38e 100644 --- a/apps/web/src/components/features/DeleteResourceDialogs.test.tsx +++ b/apps/web/src/components/features/DeleteResourceDialogs.test.tsx @@ -66,7 +66,7 @@ const childSession = session("child-session", "Explore implementation", rootSess const automation: Automation = { id: "automation-1", projectSlug: "demo", - createdFromSessionId: "source-session", + origin: { kind: "session", sessionId: "source-session" }, name: "Continue review", trigger: { kind: "interval", everyMs: 60_000 }, action: { diff --git a/apps/web/src/components/features/EditAutomationDialog.interaction.tsx b/apps/web/src/components/features/EditAutomationDialog.interaction.tsx index 22a89d42..98d2357e 100644 --- a/apps/web/src/components/features/EditAutomationDialog.interaction.tsx +++ b/apps/web/src/components/features/EditAutomationDialog.interaction.tsx @@ -22,7 +22,7 @@ const automationTimezone = "Asia/Shanghai"; const automation: Automation = { id: "automation-edit-limits", projectSlug: "archcode", - createdFromSessionId: "session-source", + origin: { kind: "session", sessionId: "session-source" }, name: "Daily review", trigger: { kind: "cron", expression: "0 9 * * *", timezone: automationTimezone }, action: { kind: "start_session", message: "Review current work.", location: "project" }, @@ -145,4 +145,36 @@ describe("EditAutomationDialog limits", () => { expect(saveButton().disabled).toBe(false); } }); + + test("protects a dirty draft and states the fixed Lead principal contract", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + let closeCount = 0; + await act(async () => { + root.render( + + { closeCount += 1; }} slug="archcode" automation={automation} /> + , + ); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("root Lead Session with the principal profile"); + change(field("automation-name"), "Changed draft"); + const cancel = [...document.querySelectorAll("button")] + .find((candidate) => candidate.textContent === "Cancel"); + if (cancel === undefined) throw new Error("Missing Cancel button"); + + let confirmCount = 0; + dom.window.confirm = () => { + confirmCount += 1; + return false; + }; + act(() => cancel.click()); + expect(confirmCount).toBe(1); + expect(closeCount).toBe(0); + + dom.window.confirm = () => true; + act(() => cancel.click()); + expect(closeCount).toBe(1); + }); }); diff --git a/apps/web/src/components/features/EditAutomationDialog.test.ts b/apps/web/src/components/features/EditAutomationDialog.test.ts index 36a4a076..4e11b90b 100644 --- a/apps/web/src/components/features/EditAutomationDialog.test.ts +++ b/apps/web/src/components/features/EditAutomationDialog.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { intervalFromMilliseconds, intervalToMilliseconds, + isoToLocalDateTimeInput, } from "./EditAutomationDialog"; describe("EditAutomationDialog interval controls", () => { @@ -17,4 +18,12 @@ describe("EditAutomationDialog interval controls", () => { expect(intervalFromMilliseconds(900_000)).toEqual({ value: 15, unit: "minutes" }); expect(intervalFromMilliseconds(90_000)).toEqual({ value: 90, unit: "seconds" }); }); + + test("projects an ISO instant into the local datetime input", () => { + const instant = "2026-08-03T09:30:00.000Z"; + const date = new Date(instant); + const expected = new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); + expect(isoToLocalDateTimeInput(instant)).toBe(expected); + expect(new Date(isoToLocalDateTimeInput(instant)).toISOString()).toBe(instant); + }); }); diff --git a/apps/web/src/components/features/EditAutomationDialog.tsx b/apps/web/src/components/features/EditAutomationDialog.tsx index 86825e50..d28f43d7 100644 --- a/apps/web/src/components/features/EditAutomationDialog.tsx +++ b/apps/web/src/components/features/EditAutomationDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AUTOMATION_MESSAGE_MAX_LENGTH, AUTOMATION_NAME_MAX_LENGTH, @@ -17,7 +17,7 @@ import { type LucideIcon, } from "lucide-react"; -import { useUpdateAutomation } from "../../api/mutations"; +import { useCreateAutomation, useUpdateAutomation } from "../../api/mutations"; import type { Automation, AutomationAction, @@ -56,11 +56,17 @@ export function intervalFromMilliseconds(everyMs: number): { value: number; unit return { value: everyMs / INTERVAL_UNIT_MS.seconds, unit: "seconds" }; } +export function isoToLocalDateTimeInput(value: string): string { + const date = new Date(value); + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + interface EditAutomationDialogProps { open: boolean; onClose: () => void; slug: string; - automation: Automation; + automation?: Automation; } export function EditAutomationDialog({ @@ -70,6 +76,29 @@ export function EditAutomationDialog({ automation, }: EditAutomationDialogProps) { const update = useUpdateAutomation(); + const create = useCreateAutomation(); + const initial = useMemo(() => { + const interval = automation?.trigger.kind === "interval" + ? intervalFromMilliseconds(automation.trigger.everyMs) + : { value: 1, unit: "minutes" as const }; + return { + name: automation?.name ?? "", + triggerKind: automation?.trigger.kind ?? "interval" as const, + onceAt: automation?.trigger.kind === "once" ? isoToLocalDateTimeInput(automation.trigger.at) : "", + intervalValue: interval.value, + intervalUnit: interval.unit, + cron: automation?.trigger.kind === "cron" ? automation.trigger.expression : "*/15 * * * *", + timezone: automation?.trigger.kind === "cron" + ? automation.trigger.timezone + : Intl.DateTimeFormat().resolvedOptions().timeZone, + actionKind: automation?.action.kind ?? "start_session" as const, + message: automation?.action.message ?? "", + sessionId: automation?.action.kind === "send_message" ? automation.action.sessionId : "", + location: automation?.action.kind === "start_session" ? automation.action.location : "project" as const, + }; + // Snapshot the server value when the dialog opens. Realtime inventory + // refreshes for the same Automation must not overwrite an in-progress edit. + }, [open, automation?.id]); const [name, setName] = useState(""); const [triggerKind, setTriggerKind] = useState("interval"); const [onceAt, setOnceAt] = useState(""); @@ -84,27 +113,37 @@ export function EditAutomationDialog({ useEffect(() => { if (!open) return; - const interval = automation.trigger.kind === "interval" - ? intervalFromMilliseconds(automation.trigger.everyMs) - : { value: 1, unit: "minutes" as const }; + setName(initial.name); + setTriggerKind(initial.triggerKind); + setOnceAt(initial.onceAt); + setIntervalValue(initial.intervalValue); + setIntervalUnit(initial.intervalUnit); + setCron(initial.cron); + setTimezone(initial.timezone); + setActionKind(initial.actionKind); + setMessage(initial.message); + setSessionId(initial.sessionId); + setLocation(initial.location); + }, [initial, open]); - setName(automation.name); - setTriggerKind(automation.trigger.kind); - setOnceAt(automation.trigger.kind === "once" ? automation.trigger.at.slice(0, 16) : ""); - setIntervalValue(interval.value); - setIntervalUnit(interval.unit); - setCron(automation.trigger.kind === "cron" ? automation.trigger.expression : "*/15 * * * *"); - setTimezone(automation.trigger.kind === "cron" - ? automation.trigger.timezone - : Intl.DateTimeFormat().resolvedOptions().timeZone); - setActionKind(automation.action.kind); - setMessage(automation.action.message); - setSessionId(automation.action.kind === "send_message" ? automation.action.sessionId : ""); - setLocation(automation.action.kind === "start_session" ? automation.action.location : "project"); - }, [open, automation.id]); - - const pending = update.isPending; - const error = update.error; + const pending = update.isPending || create.isPending; + const error = update.error ?? create.error; + const dirty = name !== initial.name + || triggerKind !== initial.triggerKind + || onceAt !== initial.onceAt + || intervalValue !== initial.intervalValue + || intervalUnit !== initial.intervalUnit + || cron !== initial.cron + || timezone !== initial.timezone + || actionKind !== initial.actionKind + || message !== initial.message + || sessionId !== initial.sessionId + || location !== initial.location; + const requestClose = () => { + if (pending) return; + if (dirty && !window.confirm("Discard unsaved Automation changes?")) return; + onClose(); + }; const everyMs = intervalToMilliseconds(intervalValue, intervalUnit); const valid = name.trim().length > 0 && name.trim().length <= AUTOMATION_NAME_MAX_LENGTH @@ -130,7 +169,8 @@ export function EditAutomationDialog({ ? { kind: "start_session", message: message.trim(), location } : { kind: "send_message", message: message.trim(), sessionId: sessionId.trim() }; const payload: Required = { name: name.trim(), trigger, action }; - update.mutate({ slug, automationId: automation.id, ...payload }, { onSuccess: onClose }); + if (automation) update.mutate({ slug, automationId: automation.id, ...payload }, { onSuccess: onClose }); + else create.mutate({ slug, ...payload }, { onSuccess: onClose }); }; const errorMessage = error @@ -140,7 +180,7 @@ export function EditAutomationDialog({ : null; return ( - { if (!next && !pending) onClose(); }}> + { if (!next) requestClose(); }}>
@@ -149,7 +189,7 @@ export function EditAutomationDialog({
- Edit Automation + {automation ? "Edit Automation" : "New Automation"} Schedule an ordinary Session message. The Session keeps its existing tools and permissions. @@ -157,10 +197,10 @@ export function EditAutomationDialog({
@@ -348,6 +388,7 @@ export function EditAutomationDialog({ onChange={() => setLocation("worktree")} />
+ Every run starts a root Lead Session with the principal profile. ) : (
@@ -389,18 +430,18 @@ export function EditAutomationDialog({
diff --git a/apps/web/src/components/features/HitlAttentionList.tsx b/apps/web/src/components/features/HitlAttentionList.tsx index a8710a39..b92d7dbe 100644 --- a/apps/web/src/components/features/HitlAttentionList.tsx +++ b/apps/web/src/components/features/HitlAttentionList.tsx @@ -11,8 +11,8 @@ export function hitlAttentionLink(entry: ScopedHitlView): string { export function HitlAttentionList({ entries, - emptyMessage = "No requests need your attention", - title = "Needs attention", + emptyMessage = "Nothing needs you", + title = "Needs you", maxItems, showProject = false, testId = "hitl-attention-list", diff --git a/apps/web/src/components/features/HitlBell.interaction.tsx b/apps/web/src/components/features/HitlBell.interaction.tsx index b7b15ca4..d846d21b 100644 --- a/apps/web/src/components/features/HitlBell.interaction.tsx +++ b/apps/web/src/components/features/HitlBell.interaction.tsx @@ -88,7 +88,7 @@ function addRequest(hitlId: string, title: string) { } function bell(): HTMLButtonElement { - const trigger = container.querySelector("button[aria-label='Open requests needing attention']") as HTMLButtonElement | null; + const trigger = container.querySelector("button[aria-label='Open work that needs you']") as HTMLButtonElement | null; if (!trigger) throw new Error("Missing Bell trigger"); return trigger; } @@ -103,7 +103,7 @@ describe("HitlBell interactions", () => { addRequest("two", "Confirm the migration window"); await render(); - expect(bell().querySelector("span[aria-label='2 requests need attention']")?.textContent).toBe("2"); + expect(bell().querySelector("span[aria-label='2 items need you']")?.textContent).toBe("2"); await click(bell()); const dialog = container.querySelector("section[role='dialog']") as HTMLElement; expect(dialog).not.toBeNull(); @@ -115,7 +115,7 @@ describe("HitlBell interactions", () => { expect(dialog.textContent).not.toContain("Deny"); expect(dialog.textContent).not.toContain("Answer"); - const closeButton = dialog.querySelector("button[aria-label='Close requests needing attention']") as HTMLButtonElement; + const closeButton = dialog.querySelector("button[aria-label='Close work that needs you']") as HTMLButtonElement; closeButton.focus(); expect(document.activeElement).toBe(closeButton); await act(async () => { @@ -135,11 +135,11 @@ describe("HitlBell interactions", () => { expect(container.textContent).toContain("Enable desktop alerts"); await click(container.querySelector("button[class*='text-text-secondary']") as HTMLButtonElement); expect(notificationRequests).toBe(1); - await click(container.querySelector("section button[aria-label='Close requests needing attention']") as HTMLButtonElement); + await click(container.querySelector("section button[aria-label='Close work that needs you']") as HTMLButtonElement); expect(document.activeElement).toBe(bell()); await click(bell()); - const backdrop = container.querySelectorAll("button[aria-label='Close requests needing attention']")[0] as HTMLButtonElement; + const backdrop = container.querySelectorAll("button[aria-label='Close work that needs you']")[0] as HTMLButtonElement; await click(backdrop); expect(container.querySelector("section[role='dialog']")).toBeNull(); expect(document.activeElement).toBe(bell()); diff --git a/apps/web/src/components/features/HitlBell.tsx b/apps/web/src/components/features/HitlBell.tsx index ffddebcd..42548602 100644 --- a/apps/web/src/components/features/HitlBell.tsx +++ b/apps/web/src/components/features/HitlBell.tsx @@ -40,11 +40,11 @@ export function HitlBell({ {open && <> - {mobile &&
+
{ expect(button.getAttribute("aria-label")).toBe("Collapse context inspector"); expect(button.getAttribute("data-state")).toBe("expanded"); expect(button.className).toContain("h-8"); - expect(button.className).toContain("max-[760px]:hidden"); + expect(button.className).not.toContain("max-[760px]:hidden"); + expect(button.className).toContain("[@media(pointer:coarse)]:h-11"); expect(button.className).not.toContain("border-border-default"); expect(button.querySelector(".lucide-panel-right-close")).not.toBeNull(); diff --git a/apps/web/src/components/features/PanelToggleButton.tsx b/apps/web/src/components/features/PanelToggleButton.tsx index a06d36f6..30748d0d 100644 --- a/apps/web/src/components/features/PanelToggleButton.tsx +++ b/apps/web/src/components/features/PanelToggleButton.tsx @@ -6,7 +6,7 @@ import { } from "lucide-react"; export const WORKBENCH_PANEL_TOGGLE_CLASS = - "flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-text-tertiary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand max-[760px]:hidden [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"; + "flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-text-tertiary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"; export function PanelToggleButton({ side, diff --git a/apps/web/src/components/features/ProjectActionMenu.tsx b/apps/web/src/components/features/ProjectActionMenu.tsx index 38ddfdd2..058751bf 100644 --- a/apps/web/src/components/features/ProjectActionMenu.tsx +++ b/apps/web/src/components/features/ProjectActionMenu.tsx @@ -55,7 +55,7 @@ function renderMenuItems( /** * Dropdown-triggered project action menu. - * Use in Sidebar header button — renders a DropdownMenu with an explicit trigger element. + * Used by the ProjectToolbar action button with an explicit trigger element. */ export function ProjectActionDropdown({ project, @@ -91,4 +91,4 @@ export function ProjectActionContextMenu({ ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/features/ProjectBar.test.tsx b/apps/web/src/components/features/ProjectBar.test.tsx index af337a9a..e6b0c62d 100644 --- a/apps/web/src/components/features/ProjectBar.test.tsx +++ b/apps/web/src/components/features/ProjectBar.test.tsx @@ -75,6 +75,7 @@ const useState = mock((initial: T): [T, (value: T | ((previous: T) => T)) => ]); const useCallback = mock( unknown>(callback: T) => callback); const useNavigate = mock(() => navigate); +const useLocation = mock(() => ({ pathname: "/projects/demo-project/todos" })); const useParams = mock(() => ({ slug: "demo-project" })); const useProjects = mock(() => ({ data: [project] })); const toggleTheme = mock(() => {}); @@ -99,10 +100,12 @@ mock.module("react/jsx-dev-runtime", () => ({ mock.module("react-router-dom", () => ({ useNavigate, + useLocation, useParams, })); mock.module("lucide-react", () => ({ + Search: "Search", Moon: "Moon", Plus: "Plus", Settings: "Settings", @@ -163,8 +166,8 @@ mock.module("../../api/mutations", () => ({ ({ ProjectBar } = await import("./ProjectBar")); -function render(): unknown { - return ProjectBar({ onAddProject, onSettings, theme: "dark", toggleTheme }); +function render(mobile = false): unknown { + return ProjectBar({ mobile, onAddProject, onSettings, theme: "dark", toggleTheme }); } function projectNode(tree: unknown) { @@ -175,7 +178,7 @@ function projectNode(tree: unknown) { describe("ProjectBar", () => { beforeEach(() => { attentionVisibleHitl = []; - for (const fn of [navigate, onAddProject, onSettings, setState, useState, useCallback, useNavigate, useParams, useProjects, toggleTheme]) { + for (const fn of [navigate, onAddProject, onSettings, setState, useState, useCallback, useNavigate, useLocation, useParams, useProjects, toggleTheme]) { fn.mockClear(); } }); @@ -210,7 +213,7 @@ describe("ProjectBar", () => { expect(textContent(node)).toContain("de"); node.props.onClick({ ctrlKey: false, metaKey: false }); - expect(navigate).toHaveBeenCalledWith("/projects/demo-project"); + expect(navigate).toHaveBeenCalledWith("/projects/demo-project/todos"); }); test("add project affordance is a native button", () => { @@ -244,10 +247,15 @@ describe("ProjectBar", () => { { projectSlug: "other-project", ownerSessionId: "root", rootSessionId: "root", view: { hitlId: "same" } }, ]; - const badges = findAll(render(), (element) => element.props?.["aria-label"] === "2 requests need attention"); + const badges = findAll(render(), (element) => element.props?.["aria-label"] === "2 items need you"); expect(badges).toHaveLength(1); }); + test("uses the bottom-sheet attention panel on mobile", () => { + const bell = findAll(render(true), (element) => typeName(element) === "HitlBell")[0]; + expect(bell?.props?.mobile).toBe(true); + }); + test("settings affordance opens the settings modal", () => { const settingsNode = findAll( render(), diff --git a/apps/web/src/components/features/ProjectBar.tsx b/apps/web/src/components/features/ProjectBar.tsx index 779abf20..88df4a79 100644 --- a/apps/web/src/components/features/ProjectBar.tsx +++ b/apps/web/src/components/features/ProjectBar.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { Moon, Plus, Settings, Sun } from "lucide-react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { Moon, Plus, Search, Settings, Sun } from "lucide-react"; import { useProjects } from "../../api/queries"; import type { Theme } from "../../hooks/use-theme"; import { useAttentionVisibleScopedHitl } from "../../store/hitl-store"; @@ -11,8 +11,11 @@ import { CloseProjectDialog } from "./CloseProjectDialog"; import type { Project } from "../../api/types"; interface ProjectBarProps { + mobile?: boolean; onAddProject?: () => void; onSettings?: () => void; + onSearch?: () => void; + searchTriggerRef?: React.RefObject; showBell?: boolean; theme: Theme; toggleTheme: () => void; @@ -22,8 +25,9 @@ function getInitials(slug: string): string { return slug.slice(0, 2).toLowerCase(); } -export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, toggleTheme }: ProjectBarProps) { +export function ProjectBar({ mobile = false, onAddProject, onSettings, onSearch, searchTriggerRef, showBell = true, theme, toggleTheme }: ProjectBarProps) { const navigate = useNavigate(); + const location = useLocation(); const { slug: activeSlug } = useParams<{ slug: string }>(); const { data: projects } = useProjects(); const attentionVisibleHitl = useAttentionVisibleScopedHitl(); @@ -34,7 +38,7 @@ export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, t const handleProjectClick = (slug: string, e?: React.MouseEvent) => { // Ctrl-click / Cmd-click should not navigate — context menu handles it if (e && (e.ctrlKey || e.metaKey)) return; - navigate(`/projects/${slug}`); + navigate(`/projects/${slug}/todos`); }; const handleAddProject = () => { @@ -50,7 +54,7 @@ export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, t if (project.slug === activeSlug) { const remaining = projects?.filter((p) => p.slug !== project.slug); if (remaining && remaining.length > 0) { - navigate(`/projects/${remaining[0].slug}`); + navigate(`/projects/${remaining[0].slug}/todos`); } else { navigate("/"); } @@ -67,8 +71,9 @@ export function ProjectBar({ onAddProject, onSettings, showBell = true, theme, t > + {showBell && } + + )} + />} + + + {editingProject && setEditingProject(null)} />} + {closingProject && ( + setClosingProject(null)} + onClosed={handleProjectClosed} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/features/SessionComposerDock.tsx b/apps/web/src/components/features/SessionComposerDock.tsx index e6220b51..e1cbd02b 100644 --- a/apps/web/src/components/features/SessionComposerDock.tsx +++ b/apps/web/src/components/features/SessionComposerDock.tsx @@ -12,10 +12,14 @@ export function SessionComposerDock({ slug, sessionId, focusHitlId, + focusComposer = false, + focusClientRequestId, }: { slug: string; sessionId: string; focusHitlId?: string | null; + focusComposer?: boolean; + focusClientRequestId?: string | null; }) { const activity = useSessionFamilyActivity(slug, sessionId); const goal = useSessionStore(sessionId, (state) => state.goal, slug); @@ -90,7 +94,7 @@ export function SessionComposerDock({ )} - +
diff --git a/apps/web/src/components/features/Sidebar.interaction.tsx b/apps/web/src/components/features/Sidebar.interaction.tsx deleted file mode 100644 index 7c439b06..00000000 --- a/apps/web/src/components/features/Sidebar.interaction.tsx +++ /dev/null @@ -1,451 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import type { - Automation, - Project, - ProjectTodo, - SessionSummaryWithGoal, -} from "../../api/types"; -import type { SessionFamilyActivity } from "@archcode/protocol"; -import type { ScopedHitlView } from "../../store/hitl-store"; - -const navigationCalls: string[] = []; -const navigate = mock((path: string) => { - navigationCalls.push(path); -}); -const createSession = { isPending: false, mutate: mock(() => {}) }; -const postMessage = { mutate: mock(() => {}) }; - -let route = { - pathname: "/projects/demo/sessions/recent", - params: { slug: "demo", sessionId: "recent", automationId: "" }, -}; -let sessions: SessionSummaryWithGoal[] = []; -let automations: Automation[] = []; -let projectTodos: ProjectTodo[] = []; -let runtimeInitialized = true; -let runtimeFamilies: Record< - string, - { activity: SessionFamilyActivity } -> = {}; -let attentionVisibleHitl: ScopedHitlView[] = []; - -const Icon = (props: Record) => ; -const ListTodoIcon = (props: Record) => ( - -); - -mock.module("react-router-dom", () => ({ - Link: ({ to, children, ...props }: { to: string; children?: ReactNode }) => ( - - {children} - - ), - useLocation: () => ({ pathname: route.pathname }), - useNavigate: () => navigate, - useParams: () => route.params, -})); - -mock.module("lucide-react", () => ({ - ChevronRight: Icon, - Focus: Icon, - LayoutDashboard: Icon, - ListTodo: ListTodoIcon, - MoreHorizontal: Icon, - PanelLeftClose: Icon, - Plus: Icon, - Trash2: Icon, - TriangleAlert: Icon, -})); - -mock.module("../../api/mutations", () => ({ - useCreateSession: () => createSession, - useDeleteSession: () => ({ mutate: mock(() => {}), isPending: false, error: null }), - usePostMessage: () => postMessage, -})); - -const project: Project = { - slug: "demo", - name: "Demo project", - workspaceRoot: "/workspace/demo", - addedAt: "2026-07-25T00:00:00.000Z", -}; - -mock.module("../../api/queries", () => ({ - useProjects: () => ({ data: [project] }), - useSessions: () => ({ data: sessions }), - useSessionTree: () => ({ data: undefined, isLoading: false, error: null }), - useAutomations: () => ({ data: automations }), - useProjectTodos: () => ({ data: projectTodos }), -})); - -mock.module("../../store/session-runtime-store", () => ({ - runtimeFamilyKey: (slug: string, sessionId: string) => `${slug}:${sessionId}`, - useSessionRuntimeInitialized: () => runtimeInitialized, - useSessionRuntimeFamilies: () => runtimeFamilies, -})); - -mock.module("../../store/hitl-store", () => ({ - selectSessionFamilyHitl: ( - entries: ScopedHitlView[], - slug: string, - rootSessionId: string, - ) => - entries.filter( - (entry) => - entry.projectSlug === slug && entry.rootSessionId === rootSessionId, - ), - useAttentionVisibleScopedHitl: () => attentionVisibleHitl, -})); - -mock.module("../primitives/StatusGlyph", () => ({ - StatusGlyph: ({ kind, label }: { kind: string; label?: string }) => ( - - ), -})); - -mock.module("./GoalStatusMark", () => ({ - GoalStatusMark: ({ label }: { label?: string }) => ( - - ), -})); - -mock.module("./ProjectActionMenu", () => ({ - ProjectActionDropdown: ({ trigger }: { trigger: ReactNode }) => ( - <>{trigger} - ), -})); - -mock.module("./EditProjectDialog", () => ({ EditProjectDialog: () => null })); -mock.module("./CloseProjectDialog", () => ({ CloseProjectDialog: () => null })); - -const { Sidebar } = await import("./Sidebar"); - -function session( - sessionId: string, - title: string, - updatedAt: number, - goal?: SessionSummaryWithGoal["goal"], -): SessionSummaryWithGoal { - return { - sessionId, - title, - updatedAt, - createdAt: updatedAt - 1, - cwd: "/workspace/demo", - rootSessionId: sessionId, - agentName: "lead", - profile: "principal", - activeSkillNames: [], - modelSelection: { model: "test:model" }, - goal, - } as unknown as SessionSummaryWithGoal; -} - -function attention( - rootSessionId: string, - source: "tool_permission" | "ask_user", -): ScopedHitlView { - return { - projectSlug: "demo", - ownerSessionId: rootSessionId, - rootSessionId, - view: { - hitlId: `${rootSessionId}-${source}`, - owner: { type: "session", id: rootSessionId }, - source: - source === "tool_permission" - ? { type: "tool_permission", toolCallId: "tool-1", toolName: "bash" } - : { type: "ask_user", toolCallId: "tool-1" }, - status: "pending", - displayPayload: { title: "Needs a response", redacted: true }, - allowedActions: - source === "tool_permission" ? ["approve", "deny"] : ["answer"], - createdAt: "2026-07-25T00:00:00.000Z", - updatedAt: "2026-07-25T00:00:00.000Z", - }, - }; -} - -function automation(id: string, name: string): Automation { - return { - id, - name, - status: "active", - trigger: { kind: "once", at: "2026-07-26T00:00:00.000Z" }, - action: { - kind: "send_message", - sessionId: "recent", - content: "Review status", - }, - createdAt: "2026-07-25T00:00:00.000Z", - updatedAt: "2026-07-25T00:00:00.000Z", - } as unknown as Automation; -} - -function projectTodo( - id: string, - status: ProjectTodo["status"], - archivedAt?: number, -): ProjectTodo { - return { - id, - title: id, - body: "", - status, - revision: 1, - createdAt: 1, - updatedAt: 1, - ...(archivedAt === undefined ? {} : { archivedAt }), - }; -} - -let dom: JSDOM; -let root: Root; -let container: HTMLElement; -const originals = new Map(); - -beforeEach(() => { - dom = new JSDOM( - '
', - { url: "http://localhost" }, - ); - for (const [name, value] of Object.entries({ - window: dom.window, - document: dom.window.document, - navigator: dom.window.navigator, - HTMLElement: dom.window.HTMLElement, - Node: dom.window.Node, - MouseEvent: dom.window.MouseEvent, - IS_REACT_ACT_ENVIRONMENT: true, - })) { - originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); - Object.defineProperty(globalThis, name, { configurable: true, value }); - } - navigationCalls.length = 0; - route = { - pathname: "/projects/demo/sessions/recent", - params: { slug: "demo", sessionId: "recent", automationId: "" }, - }; - runtimeInitialized = true; - runtimeFamilies = { - "demo:running": { activity: "running" }, - "demo:recent": { activity: "idle" }, - }; - sessions = [ - session("permission", "Review destructive command", 1_753_000_000_000), - session("question", "Choose the rollout plan", 1_753_000_001_000), - session("mixed", "Resolve pending requests", 1_753_000_001_500), - session("running", "Rebuild execution surface", 1_753_000_002_000), - session("recent", "Tighten workbench hierarchy", 1_753_000_003_000, { - instanceId: "goal-1", - settlementReceipts: [], - generation: 1, - objective: "Ship the workbench", - status: "active", - usage: { - tokens: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - reasoningTokens: 0, - cachedInputTokens: 0, - }, - executionTimeMs: 0, - executionCount: 0, - }, - createdAt: 1, - activatedAt: 1, - updatedAt: 1, - }), - ]; - attentionVisibleHitl = [ - attention("permission", "tool_permission"), - attention("question", "ask_user"), - attention("mixed", "tool_permission"), - attention("mixed", "ask_user"), - ]; - automations = [automation("auto-1", "Nightly review")]; - projectTodos = [ - projectTodo("idea", "idea"), - projectTodo("ready", "ready"), - projectTodo("done", "done"), - projectTodo("rejected", "rejected"), - projectTodo("archived", "idea", 2), - ]; - container = document.getElementById("root")!; - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - dom.window.close(); - for (const [name, descriptor] of originals) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else Reflect.deleteProperty(globalThis, name); - } - originals.clear(); -}); - -async function renderSidebar(): Promise { - await act(async () => root.render()); -} - -describe("Sidebar Session list", () => { - test("gives Todos a distinct icon and counts only open active work", async () => { - await renderSidebar(); - - const todoLink = container.querySelector( - 'a[href="/projects/demo/todos"]', - ) as HTMLAnchorElement; - expect(todoLink.querySelector('[data-icon="list-todo"]')).not.toBeNull(); - expect( - todoLink.querySelector('[data-testid="sidebar-todo-count"]')?.textContent, - ).toBe("2"); - expect(todoLink.getAttribute("aria-label")).toBe("Todos, 2 open"); - }); - - test("groups by attention, live work, then recency using one-line accessible Session rows", async () => { - await renderSidebar(); - - const groupOrder = [ - ...container.querySelectorAll("[data-testid^=sidebar-session-group-]"), - ].map((group) => group.getAttribute("data-testid")); - expect(groupOrder).toEqual([ - "sidebar-session-group-needs-you", - "sidebar-session-group-running", - "sidebar-session-group-recent", - ]); - - const permission = container.querySelector( - '[data-testid="sidebar-session-permission"]', - ) as HTMLButtonElement; - const question = container.querySelector( - '[data-testid="sidebar-session-question"]', - ) as HTMLButtonElement; - const mixed = container.querySelector( - '[data-testid="sidebar-session-mixed"]', - ) as HTMLButtonElement; - const running = container.querySelector( - '[data-testid="sidebar-session-running"]', - ) as HTMLButtonElement; - const recent = container.querySelector( - '[data-testid="sidebar-session-recent"]', - ) as HTMLButtonElement; - expect( - permission.querySelector('[data-visual-kind="needs_you"]'), - ).not.toBeNull(); - expect( - question.querySelector( - '[data-testid="sidebar-session-attention-question"]', - )?.textContent, - ).toContain("Question"); - expect( - permission.querySelector( - '[data-testid="sidebar-session-attention-permission"]', - )?.textContent, - ).toContain("Permission"); - expect( - mixed.querySelector('[data-testid="sidebar-session-attention-mixed"]') - ?.textContent, - ).toContain("2 requests"); - expect(mixed.textContent).not.toContain("Permission"); - expect(mixed.textContent).not.toContain("Question"); - expect(mixed.getAttribute("aria-label")).toContain("2 requests waiting"); - expect( - running.querySelector('[data-visual-kind="running"]'), - ).not.toBeNull(); - expect(recent.querySelector('[data-visual-kind="idle"]')).not.toBeNull(); - expect( - recent.querySelector('[data-testid="sidebar-session-goal-recent"]'), - ).not.toBeNull(); - expect(recent.getAttribute("aria-current")).toBe("page"); - expect(permission.getAttribute("aria-label")).toContain("Needs attention"); - expect(permission.getAttribute("aria-label")).toContain( - "Permission waiting", - ); - expect(running.textContent).not.toContain("running ·"); - const relativeTime = permission.querySelector("time") as HTMLTimeElement; - expect(relativeTime).not.toBeNull(); - expect(relativeTime.dateTime).toBe( - new Date(sessions[0]!.updatedAt).toISOString(), - ); - expect(relativeTime.title).not.toBe(""); - expect(relativeTime.getAttribute("aria-label")).toContain( - relativeTime.textContent, - ); - expect(permission.getAttribute("aria-label")).toContain( - relativeTime.textContent, - ); - expect(permission.getAttribute("title")).toBeNull(); - expect(permission.className).toContain("h-9"); - expect(permission.className).toContain("[@media(pointer:coarse)]:min-h-11"); - expect( - container.querySelector( - 'button[aria-label="Actions for Tighten workbench hierarchy"]', - ), - ).not.toBeNull(); - - await act(async () => permission.click()); - expect(navigationCalls).toEqual(["/projects/demo/sessions/permission"]); - - route = { ...route, params: { ...route.params, sessionId: "permission" } }; - await renderSidebar(); - expect(permission.getAttribute("aria-current")).toBe("page"); - expect(recent.getAttribute("aria-current")).toBeNull(); - }); - - test("keeps waiting and resuming families in the live group without HITL", async () => { - runtimeFamilies = { - "demo:waiting": { activity: "waiting_for_human" }, - "demo:resuming": { activity: "resuming" }, - }; - sessions = [ - session("waiting", "Waiting on child", 2), - session("resuming", "Resuming work", 1), - session("idle", "Recent work", 0), - ]; - attentionVisibleHitl = []; - - await renderSidebar(); - - const liveGroup = container.querySelector('[data-testid="sidebar-session-group-running"]') as HTMLElement; - const waiting = container.querySelector('[data-testid="sidebar-session-waiting"]') as HTMLButtonElement; - const resuming = container.querySelector('[data-testid="sidebar-session-resuming"]') as HTMLButtonElement; - expect(liveGroup).not.toBeNull(); - expect(waiting.getAttribute("aria-label")).toContain("Waiting"); - expect(waiting.querySelector('[data-visual-kind="pending"]')).not.toBeNull(); - expect(resuming.getAttribute("aria-label")).toContain("Resuming"); - expect(resuming.querySelector('[data-visual-kind="running"]')).not.toBeNull(); - }); - - test("keeps Automation navigation and creation surface available from its tab", async () => { - await renderSidebar(); - - const automationTab = [...container.querySelectorAll('[role="tab"]')].find( - (element) => element.textContent === "Automations", - ) as HTMLButtonElement; - await act(async () => automationTab.click()); - - const panel = container.querySelector( - "#sidebar-panel-automations", - ) as HTMLElement; - expect(panel.hidden).toBe(false); - const row = container.querySelector( - '[data-testid="sidebar-automation-auto-1"]', - ) as HTMLButtonElement; - expect(row.textContent).toContain("Nightly review"); - expect(row.textContent).not.toContain("auto-1"); - expect(row.className).toContain("h-8"); - expect(row.className).toContain("[@media(pointer:coarse)]:min-h-11"); - expect(row.getAttribute("aria-label")).toContain("auto-1"); - await act(async () => row.click()); - expect(navigationCalls).toEqual(["/projects/demo/automations/auto-1"]); - }); -}); diff --git a/apps/web/src/components/features/Sidebar.tsx b/apps/web/src/components/features/Sidebar.tsx deleted file mode 100644 index 76cac891..00000000 --- a/apps/web/src/components/features/Sidebar.tsx +++ /dev/null @@ -1,747 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; -import { - ChevronRight, - LayoutDashboard, - ListTodo, - MoreHorizontal, - Plus, - Trash2, - type LucideIcon, -} from "lucide-react"; -import { useCreateSession, usePostMessage } from "../../api/mutations"; -import { useAutomations, useProjects, useProjectTodos, useSessions } from "../../api/queries"; -import type { - Automation, - Project, - SessionSummary, - SessionSummaryWithGoal, -} from "../../api/types"; -import type { SessionFamilyActivity } from "@archcode/protocol"; -import { ProjectActionDropdown } from "./ProjectActionMenu"; -import { EditProjectDialog } from "./EditProjectDialog"; -import { CloseProjectDialog } from "./CloseProjectDialog"; -import { DeleteSessionDialog } from "./DeleteSessionDialog"; -import { - runtimeFamilyKey, - useSessionRuntimeFamilies, - useSessionRuntimeInitialized, -} from "../../store/session-runtime-store"; -import { - selectSessionFamilyHitl, - type ScopedHitlView, - useAttentionVisibleScopedHitl, -} from "../../store/hitl-store"; -import { StatusGlyph } from "../primitives/StatusGlyph"; -import { GoalStatusMark } from "./GoalStatusMark"; -import { presentSessionGoalStatus } from "../../lib/session-goal-presentation"; -import { automationVisualKind } from "../../lib/automation-status-presentation"; -import { sessionFamilyActivityLabel, sessionFamilyVisual } from "../../lib/session-family-presentation"; -import { - RelativeTimeValue, - useRelativeTimePresentation, -} from "../primitives/TemporalText"; -import { - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRoot, - DropdownMenuTrigger, -} from "../ui/DropdownMenu"; - -// Helpers - -/** Robust lowercase string conversion that never throws on nullish/unknown values. */ -function toSearchable(value: unknown): string { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - try { - return String(value); - } catch { - return ""; - } -} - -// Tab model - -type SidebarTab = "sessions" | "automations"; - -const TABS: Array<{ id: SidebarTab; label: string }> = [ - { id: "sessions", label: "Sessions" }, - { id: "automations", label: "Automations" }, -]; - -export function deriveSidebarTabFromPath(pathname: string): SidebarTab | null { - if (pathname.includes("/sessions/")) return "sessions"; - if (pathname.includes("/automations")) return "automations"; - return null; -} - -// Session list projection - -type SessionListGroup = "needs-you" | "running" | "recent"; - -interface SessionAttention { - readonly count: number; - readonly label: "Permission" | "Question" | "Requests"; -} - -interface SidebarSessionRow { - readonly session: SessionSummaryWithGoal; - readonly activity: SessionFamilyActivity | undefined; - readonly attention?: SessionAttention; -} - -function sessionAttention(entries: readonly ScopedHitlView[]): SessionAttention | undefined { - const primary = entries[0]; - if (!primary) return undefined; - const sourceTypes = new Set(entries.map((entry) => entry.view.source.type)); - return { - count: entries.length, - label: sourceTypes.size > 1 - ? "Requests" - : primary.view.source.type === "tool_permission" - ? "Permission" - : "Question", - }; -} - -function attentionLabel(attention: SessionAttention): string { - return attention.label === "Requests" - ? `${attention.count} requests` - : attention.label; -} - -function sessionListGroup( - activity: SessionFamilyActivity | undefined, - attention: SessionAttention | undefined, -): SessionListGroup { - if (attention) return "needs-you"; - if (activity !== undefined && activity !== "idle") return "running"; - return "recent"; -} - -function sessionStateLabel( - activity: SessionFamilyActivity | undefined, - attention: SessionAttention | undefined, -): string { - if (attention) return "Needs attention"; - return sessionFamilyActivityLabel(activity); -} - -// Status glyphs - -function SessionStatusGlyph({ - activity, - attention, -}: { - activity: SessionFamilyActivity | undefined; - attention?: SessionAttention; -}) { - if (attention) return ; - const visual = sessionFamilyVisual(activity); - return ; -} - -function AutomationStatusGlyph({ status }: { status: Automation["status"] }) { - return ; -} - -// List items - -function SessionItem({ - session, - activity, - attention, - isActive, - onClick, - onDelete, -}: { - session: SessionSummaryWithGoal; - activity: SessionFamilyActivity | undefined; - attention?: SessionAttention; - isActive: boolean; - onClick: () => void; - onDelete: () => void; -}) { - const updatedAt = session.updatedAt; - const goalLabel = session.goal ? presentSessionGoalStatus(session.goal.status).label : undefined; - const stateLabel = sessionStateLabel(activity, attention); - const visibleAttentionLabel = attention ? attentionLabel(attention) : undefined; - const relativeUpdatedAt = useRelativeTimePresentation(updatedAt); - const accessibleName = [ - session.title || "Untitled", - stateLabel, - attention - ? attention.label === "Requests" - ? `${attention.count} requests waiting` - : `${attention.label} waiting${attention.count > 1 ? `, ${attention.count} requests` : ""}` - : undefined, - goalLabel ? `Goal ${goalLabel}` : undefined, - relativeUpdatedAt.full, - ].filter((part): part is string => part !== undefined).join(" · "); - - return ( -
- {isActive && ( - - ); -} - -function AutomationItem({ - automation, - isActive, - onClick, -}: { - automation: Automation; - isActive: boolean; - onClick: () => void; -}) { - const scheduleLabel = automation.trigger.kind === "once" ? "once" : automation.trigger.kind === "interval" ? `interval ${automation.trigger.everyMs}ms` : `cron ${automation.trigger.expression}`; - const actionLabel = automation.action.kind.replaceAll("_", " "); - const accessibleName = `${automation.name} · ${automation.status} · ${scheduleLabel} · ${actionLabel} · ${automation.id}`; - - return ( - - ); -} - -// Shared sub-components - -const SEARCH_INPUT_CLASS = - "w-full rounded-md border border-border-control bg-bg-elevated px-3 py-2 text-[13px] text-text-primary placeholder:text-text-muted transition-colors duration-[var(--motion-hover)] focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand-subtle"; - -function CreateButton({ - onClick, - title, - label, - disabled, -}: { - onClick: () => void; - title: string; - label: string; - disabled?: boolean; -}) { - return ( - - ); -} - -function SubGroupHeader({ title, count }: { title: string; count?: number }) { - return ( -
- {title} - {count !== undefined && ( - - {count} - - )} -
- ); -} - -function EmptyRow({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function DashboardLinkButton({ - to, - label, - isActive, - icon: Icon = LayoutDashboard, - count, -}: { - to: string; - label: string; - isActive: boolean; - icon?: LucideIcon; - count?: number; -}) { - return ( - - {isActive &&