diff --git a/.plan/04-prd-task-bootstrap/plan.md b/.plan/04-prd-task-bootstrap/plan.md new file mode 100644 index 0000000..d9dab44 --- /dev/null +++ b/.plan/04-prd-task-bootstrap/plan.md @@ -0,0 +1,124 @@ +# PRD Task Bootstrap Plan + +## Goal +Add a small PR-ready workflow that lets a user select a strategy/PRD markdown file from the current workspace, parse actionable items from it, and populate ordered backlog cards so work can begin immediately. + +## Scope +1. In scope + - create-dialog import UX for workspace markdown files + - safe runtime API for reading markdown files from the workspace + - markdown-to-task parsing utilities + - preserving imported task order in backlog batch creation + - focused tests for parsing, runtime API behavior, and ordered creation +2. Out of scope for this pass + - dependency graph generation from PRD sections + - automatic task starting on import without user review + - rich markdown AST parsing or new third-party parser dependencies + - changes to inline card creation outside the main create dialog + +## Current Investigation Snapshot +1. `TaskCreateDialog` already supports multi-create by splitting prompt text into list items. +2. `useTaskEditor.handleCreateTasks` is the current multi-create source of truth, but backlog insertion currently reverses input order visually. +3. `TaskPromptComposer` already uses `workspace.searchFiles` through TRPC for workspace-scoped file discovery. +4. There is no dedicated workspace file-read API yet, so markdown import needs a new safe runtime surface. +5. Existing `.plan//plan.md` + `status.md` pairs are the repository convention for tracked initiatives. + +## Decision Table +1. Import entry point + - Decision: add import UI only to `web-ui/src/components/task-create-dialog.tsx` + - Rationale: keeps the change tight and reuses the existing create/review flow +2. Supported file types + - Decision: `.md`, `.markdown`, `.mdx` + - Rationale: covers common PRD/strategy files without broad file-reading scope +3. Imported prompt source reference + - Decision: append `@` to each imported prompt when not already present + - Rationale: preserves provenance and fits existing file reference conventions +4. Importable markdown content + - Decision: import unchecked checklist items anywhere plus top-level bullet/numbered items under execution-like headings + - Rationale: keeps extraction actionable and avoids pulling in scope/risk prose +5. Batch ordering + - Decision: preserve source order visually in backlog top-to-bottom + - Rationale: imported plans need predictable execution order + +## Execution Phases + +### Phase 1: Planning docs and acceptance criteria +Deliverables: +1. Add this plan file and matching `status.md`. +2. Freeze parsing and UX decisions for the first pass. + +Exit criteria: +1. Decisions above are reflected in implementation and tests. + +### Phase 2: Runtime workspace markdown read support +Changes: +1. Add shared request/response contracts for reading a workspace file. +2. Add request validation. +3. Add a safe workspace helper that validates path, extension, workspace containment, and file size. +4. Expose a `workspace.readFile` TRPC query and cover it with tests. + +Exit criteria: +1. Web UI can read a selected markdown file through the runtime using workspace scoping. +2. Invalid paths, unsupported extensions, and missing files fail safely. + +### Phase 3: Prompt parsing and ordered batch creation +Changes: +1. Move plain list parsing into `web-ui/src/utils/task-prompt.ts`. +2. Add markdown import parsing with tests. +3. Update batch creation in `useTaskEditor` so backlog order matches source order. + +Exit criteria: +1. Imported tasks are editable before creation. +2. Creating multiple tasks preserves document order visually. + +### Phase 4: Create-dialog import UX +Changes: +1. Add markdown search/load controls to the main create dialog. +2. Let users choose a markdown file, parse prompts, and review/edit them in multi-task mode. +3. Show clear errors for empty parses and read failures. + +Exit criteria: +1. A user can import tasks from a workspace PRD/strategy file without leaving the dialog. +2. Existing single-task and prompt-splitting flows continue to work. + +### Phase 5: Validation and PR polish +Changes: +1. Run targeted runtime and web tests. +2. Confirm no unnecessary architectural spread beyond the existing task-create flow. + +Exit criteria: +1. Relevant tests pass. +2. The diff remains tight and upstream-friendly. + +## Risks and Mitigations +1. Parser under-extracts useful tasks + - Mitigation: support explicit checklist items anywhere and common execution headings. +2. Parser over-extracts non-task bullets + - Mitigation: ignore checked items, nested bullets, code fences, and non-execution sections. +3. Search results become stale between search and read + - Mitigation: revalidate the selected path in the server-side read helper. +4. Batch order change surprises existing multi-create users + - Mitigation: keep the change small, test it directly, and call it out in the summary/PR notes. + +## Validation Checklist +1. Runtime/API + - `test/runtime/api-validation.test.ts` + - `test/runtime/trpc/workspace-api.test.ts` + - helper test for workspace markdown reads +2. Web UI + - `web-ui/src/utils/task-prompt.test.ts` + - `web-ui/src/hooks/use-task-editor.test.tsx` + - targeted create-dialog coverage if needed +3. Manual flow + - search and import a markdown plan file + - verify imported prompts include `@path` + - create tasks and confirm backlog order matches document order + - confirm back-to-single behaves correctly for prompt split vs markdown import + +## Rollback Strategy +1. Keep the runtime file-read path isolated behind `workspace.readFile`. +2. Keep markdown parsing isolated in `task-prompt.ts`. +3. If the feature needs to be reverted, remove the dialog import UI and the additive runtime route without affecting saved board data. + +## Progress Tracking Location +Use `.plan/04-prd-task-bootstrap/status.md` as the implementation tracker for this initiative. diff --git a/.plan/04-prd-task-bootstrap/status.md b/.plan/04-prd-task-bootstrap/status.md new file mode 100644 index 0000000..89c4f28 --- /dev/null +++ b/.plan/04-prd-task-bootstrap/status.md @@ -0,0 +1,58 @@ +# PRD Task Bootstrap Status + +## Current State +1. Initiative: `04-prd-task-bootstrap` +2. Overall progress: implemented, pending full local verification +3. Last updated: 2026-03-16 + +## Decision Tracker +1. Import entry point (`TaskCreateDialog` only) + - Status: decided +2. Supported file types (`.md`, `.markdown`, `.mdx`) + - Status: decided +3. Imported prompt source references (`@path` suffix) + - Status: decided +4. Importable markdown rules (unchecked checklists + execution-section lists) + - Status: decided +5. Preserve batch order visually in backlog + - Status: decided + +## Phase Checklist + +### Phase 1: Planning and acceptance criteria +- [x] Review existing task-create and workspace file search flows +- [x] Add initiative plan and status tracker +- [x] Freeze first-pass scope decisions + +### Phase 2: Runtime workspace markdown read support +- [x] Add shared request/response contracts +- [x] Add validation helper +- [x] Add safe workspace markdown read helper +- [x] Add TRPC route and runtime tests + +### Phase 3: Prompt parsing and ordered batch creation +- [x] Move plain list parsing into shared prompt utilities +- [x] Add markdown import parsing tests +- [x] Preserve input order in batch backlog creation + +### Phase 4: Create-dialog import UX +- [x] Add markdown import search/load controls +- [x] Parse imported markdown into editable task prompts +- [x] Handle empty parse and read failures cleanly + +### Phase 5: Validation and PR polish +- [ ] Run targeted tests (blocked locally: repo dependencies are not installed in this workspace) +- [x] Verify the diff stays tight and upstream-friendly + +## Investigation Summary +1. The feature can reuse existing multi-create and create-and-start flows. +2. The main additive backend gap is safe workspace markdown file reading. +3. The main non-additive behavior change is fixing batch creation order to match input order visually. + +## Open Risks +1. Some PRD markdown files may not contain importable actionable items. +2. Search index staleness can produce paths that no longer exist at read time. +3. UI complexity can grow if import UX spreads beyond the main create dialog. + +## Resume Point +Install workspace dependencies, then run the targeted root and web-ui test/typecheck commands to finish Phase 5 verification. diff --git a/AGENTS.md b/AGENTS.md index 7065150..adb0cf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,4 +86,5 @@ Dark theme - Do NOT use Blueprint, Tailwind's light-mode defaults, or any `dark:` prefix. The theme is always dark. Misc. tribal knowledge -- Kanban is launched from the user's shell and inherits its environment. For agent detection and task-agent startup, prefer direct PATH checks and direct process launches over spawning an interactive shell. Avoid `zsh -i`, shell fallback command discovery, or "launch shell then type command into it" on hot paths. On setups with heavy shell init like `conda` or `nvm`, doing that per task can freeze the runtime and even make new Terminal.app windows feel hung when several tasks start at once. It's fine to use an actual interactive shell for explicit shell terminals, not for normal agent session work. \ No newline at end of file +- Kanban is launched from the user's shell and inherits its environment. For agent detection and task-agent startup, prefer direct PATH checks and direct process launches over spawning an interactive shell. Avoid `zsh -i`, shell fallback command discovery, or "launch shell then type command into it" on hot paths. On setups with heavy shell init like `conda` or `nvm`, doing that per task can freeze the runtime and even make new Terminal.app windows feel hung when several tasks start at once. It's fine to use an actual interactive shell for explicit shell terminals, not for normal agent session work. +- In `web-ui` Vitest runs, `window.localStorage` may be an incomplete shim. Tests should use the storage helpers or install an explicit `Storage` mock instead of assuming methods like `clear()` or `removeItem()` exist. \ No newline at end of file diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index bc06fa6..cc41e02 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -58,6 +58,17 @@ export const runtimeWorkspaceFileSearchResponseSchema = z.object({ }); export type RuntimeWorkspaceFileSearchResponse = z.infer; +export const runtimeWorkspaceFileReadRequestSchema = z.object({ + path: z.string(), +}); +export type RuntimeWorkspaceFileReadRequest = z.infer; + +export const runtimeWorkspaceFileReadResponseSchema = z.object({ + path: z.string(), + content: z.string(), +}); +export type RuntimeWorkspaceFileReadResponse = z.infer; + export const runtimeAgentIdSchema = z.enum(["claude", "codex", "gemini", "opencode", "droid", "cline"]); export type RuntimeAgentId = z.infer; @@ -73,6 +84,7 @@ export const runtimeBoardCardSchema = z.object({ startInPlanMode: z.boolean(), autoReviewEnabled: z.boolean().optional(), autoReviewMode: runtimeTaskAutoReviewModeSchema.optional(), + agentId: runtimeAgentIdSchema.optional(), baseRef: z.string(), createdAt: z.number(), updatedAt: z.number(), @@ -492,6 +504,7 @@ export const runtimeTaskSessionStartRequestSchema = z.object({ prompt: z.string(), startInPlanMode: z.boolean().optional(), resumeFromTrash: z.boolean().optional(), + agentId: runtimeAgentIdSchema.optional(), baseRef: z.string(), cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), diff --git a/src/core/api-validation.ts b/src/core/api-validation.ts index c9f4a11..466680f 100644 --- a/src/core/api-validation.ts +++ b/src/core/api-validation.ts @@ -14,6 +14,7 @@ import { type RuntimeTaskWorkspaceInfoRequest, type RuntimeTerminalWsClientMessage, type RuntimeWorkspaceChangesRequest, + type RuntimeWorkspaceFileReadRequest, type RuntimeWorkspaceFileSearchRequest, type RuntimeWorkspaceStateSaveRequest, type RuntimeWorktreeDeleteRequest, @@ -31,6 +32,7 @@ import { runtimeTaskWorkspaceInfoRequestSchema, runtimeTerminalWsClientMessageSchema, runtimeWorkspaceChangesRequestSchema, + runtimeWorkspaceFileReadRequestSchema, runtimeWorkspaceFileSearchRequestSchema, runtimeWorkspaceStateSaveRequestSchema, runtimeWorktreeDeleteRequestSchema, @@ -106,6 +108,17 @@ export function parseWorkspaceFileSearchRequest(query: URLSearchParams): Runtime }); } +export function parseWorkspaceFileReadRequest(value: unknown): RuntimeWorkspaceFileReadRequest { + const parsed = parseWithSchema(runtimeWorkspaceFileReadRequestSchema, value); + const path = parsed.path.trim(); + if (!path) { + throw new Error("File path cannot be empty."); + } + return { + path, + }; +} + export function parseGitCheckoutRequest(value: unknown): RuntimeGitCheckoutRequest { const parsed = parseWithSchema(runtimeGitCheckoutRequestSchema, value); const branch = parsed.branch.trim(); diff --git a/src/core/task-board-mutations.ts b/src/core/task-board-mutations.ts index 683c074..2e87f34 100644 --- a/src/core/task-board-mutations.ts +++ b/src/core/task-board-mutations.ts @@ -1,4 +1,5 @@ import type { + RuntimeAgentId, RuntimeBoardCard, RuntimeBoardColumnId, RuntimeBoardData, @@ -12,6 +13,7 @@ export interface RuntimeCreateTaskInput { startInPlanMode?: boolean; autoReviewEnabled?: boolean; autoReviewMode?: RuntimeTaskAutoReviewMode; + agentId?: RuntimeAgentId; baseRef: string; } @@ -20,6 +22,7 @@ export interface RuntimeUpdateTaskInput { startInPlanMode?: boolean; autoReviewEnabled?: boolean; autoReviewMode?: RuntimeTaskAutoReviewMode; + agentId?: RuntimeAgentId; baseRef: string; } @@ -266,6 +269,7 @@ export function addTaskToColumn( startInPlanMode: Boolean(input.startInPlanMode), autoReviewEnabled: Boolean(input.autoReviewEnabled), autoReviewMode: normalizeTaskAutoReviewMode(input.autoReviewMode), + ...(input.agentId ? { agentId: input.agentId } : {}), baseRef, createdAt: now, updatedAt: now, @@ -530,6 +534,7 @@ export function updateTask( startInPlanMode: Boolean(input.startInPlanMode), autoReviewEnabled: Boolean(input.autoReviewEnabled), autoReviewMode: normalizeTaskAutoReviewMode(input.autoReviewMode), + agentId: input.agentId ?? card.agentId, baseRef, updatedAt: now, }; diff --git a/src/terminal/agent-registry.ts b/src/terminal/agent-registry.ts index 045d769..f6ca742 100644 --- a/src/terminal/agent-registry.ts +++ b/src/terminal/agent-registry.ts @@ -64,8 +64,12 @@ function getCuratedDefinitions(runtimeConfig: RuntimeConfigState, detected: stri }); } -export function resolveAgentCommand(runtimeConfig: RuntimeConfigState): ResolvedAgentCommand | null { - const selected = RUNTIME_AGENT_CATALOG.find((entry) => entry.id === runtimeConfig.selectedAgentId); +export function resolveAgentCommand( + runtimeConfig: RuntimeConfigState, + agentIdOverride?: RuntimeAgentId | null, +): ResolvedAgentCommand | null { + const effectiveAgentId = agentIdOverride ?? runtimeConfig.selectedAgentId; + const selected = RUNTIME_AGENT_CATALOG.find((entry) => entry.id === effectiveAgentId); if (!selected) { return null; } diff --git a/src/trpc/app-router.ts b/src/trpc/app-router.ts index b6c0826..ac32845 100644 --- a/src/trpc/app-router.ts +++ b/src/trpc/app-router.ts @@ -38,6 +38,8 @@ import type { RuntimeTaskWorkspaceInfoResponse, RuntimeWorkspaceChangesRequest, RuntimeWorkspaceChangesResponse, + RuntimeWorkspaceFileReadRequest, + RuntimeWorkspaceFileReadResponse, RuntimeWorkspaceFileSearchRequest, RuntimeWorkspaceFileSearchResponse, RuntimeWorkspaceStateResponse, @@ -83,6 +85,8 @@ import { runtimeTaskWorkspaceInfoResponseSchema, runtimeWorkspaceChangesRequestSchema, runtimeWorkspaceChangesResponseSchema, + runtimeWorkspaceFileReadRequestSchema, + runtimeWorkspaceFileReadResponseSchema, runtimeWorkspaceFileSearchRequestSchema, runtimeWorkspaceFileSearchResponseSchema, runtimeWorkspaceStateResponseSchema, @@ -162,6 +166,10 @@ export interface RuntimeTrpcContext { scope: RuntimeTrpcWorkspaceScope, input: RuntimeWorkspaceFileSearchRequest, ) => Promise; + readFile: ( + scope: RuntimeTrpcWorkspaceScope, + input: RuntimeWorkspaceFileReadRequest, + ) => Promise; loadState: (scope: RuntimeTrpcWorkspaceScope) => Promise; saveState: ( scope: RuntimeTrpcWorkspaceScope, @@ -346,6 +354,12 @@ export const runtimeAppRouter = t.router({ .query(async ({ ctx, input }) => { return await ctx.workspaceApi.searchFiles(ctx.workspaceScope, input); }), + readFile: workspaceProcedure + .input(runtimeWorkspaceFileReadRequestSchema) + .output(runtimeWorkspaceFileReadResponseSchema) + .query(async ({ ctx, input }) => { + return await ctx.workspaceApi.readFile(ctx.workspaceScope, input); + }), getState: workspaceProcedure.output(runtimeWorkspaceStateResponseSchema).query(async ({ ctx }) => { return await ctx.workspaceApi.loadState(ctx.workspaceScope); }), diff --git a/src/trpc/runtime-api.ts b/src/trpc/runtime-api.ts index b9eb6a8..5f14605 100644 --- a/src/trpc/runtime-api.ts +++ b/src/trpc/runtime-api.ts @@ -65,12 +65,14 @@ export function createRuntimeApi(deps: CreateRuntimeApiDependencies): RuntimeTrp try { const body = parseTaskSessionStartRequest(input); const scopedRuntimeConfig = await deps.loadScopedRuntimeConfig(workspaceScope); - const resolved = resolveAgentCommand(scopedRuntimeConfig); + const resolved = resolveAgentCommand(scopedRuntimeConfig, body.agentId); if (!resolved) { return { ok: false, summary: null, - error: "No runnable agent command is configured. Open Settings, install a supported CLI, and select it.", + error: body.agentId + ? "The runtime assigned to this task is not runnable. Install it or choose a different runtime for the task." + : "No runnable agent command is configured. Open Settings, install a supported CLI, and select it.", }; } const taskCwd = await resolveExistingTaskCwdOrEnsure({ diff --git a/src/trpc/workspace-api.ts b/src/trpc/workspace-api.ts index b73a92c..386b2fd 100644 --- a/src/trpc/workspace-api.ts +++ b/src/trpc/workspace-api.ts @@ -7,11 +7,13 @@ import type { RuntimeGitSyncAction, RuntimeGitSyncResponse, RuntimeWorkspaceChangesMode, + RuntimeWorkspaceFileReadResponse, RuntimeWorkspaceFileSearchResponse, RuntimeWorkspaceStateResponse, } from "../core/api-contract.js"; import { parseGitCheckoutRequest, + parseWorkspaceFileReadRequest, parseWorktreeDeleteRequest, parseWorktreeEnsureRequest, } from "../core/api-validation.js"; @@ -26,6 +28,7 @@ import { import { getCommitDiff, getGitLog, getGitRefs } from "../workspace/git-history.js"; import { discardGitChanges, getGitSyncSummary, runGitCheckoutAction, runGitSyncAction } from "../workspace/git-sync.js"; import { searchWorkspaceFiles } from "../workspace/search-workspace-files.js"; +import { readWorkspaceMarkdownFile, WorkspaceFileReadError } from "../workspace/read-workspace-file.js"; import { deleteTaskWorktree, ensureTaskWorktreeIfDoesntExist, @@ -156,6 +159,20 @@ function createEmptyGitDiscardErrorResponse(error: unknown): RuntimeGitDiscardRe }; } +function toWorkspaceFileReadTrpcError(error: unknown): TRPCError { + if (error instanceof WorkspaceFileReadError) { + return new TRPCError({ + code: error.code, + message: error.message, + }); + } + const message = error instanceof Error ? error.message : String(error); + return new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message, + }); +} + export function createWorkspaceApi(deps: CreateWorkspaceApiDependencies): RuntimeTrpcContext["workspaceApi"] { return { loadGitSummary: async (workspaceScope, input) => { @@ -298,6 +315,15 @@ export function createWorkspaceApi(deps: CreateWorkspaceApiDependencies): Runtim files, } satisfies RuntimeWorkspaceFileSearchResponse; }, + readFile: async (workspaceScope, input) => { + try { + const body = parseWorkspaceFileReadRequest(input); + const response = await readWorkspaceMarkdownFile(workspaceScope.workspacePath, body.path); + return response satisfies RuntimeWorkspaceFileReadResponse; + } catch (error) { + throw toWorkspaceFileReadTrpcError(error); + } + }, loadState: async (workspaceScope) => { return await deps.buildWorkspaceStateSnapshot(workspaceScope.workspaceId, workspaceScope.workspacePath); }, diff --git a/src/workspace/read-workspace-file.ts b/src/workspace/read-workspace-file.ts new file mode 100644 index 0000000..f6ed8e0 --- /dev/null +++ b/src/workspace/read-workspace-file.ts @@ -0,0 +1,119 @@ +import type { Stats } from "node:fs"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; + +export const IMPORTABLE_WORKSPACE_MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]); +export const MAX_WORKSPACE_MARKDOWN_FILE_BYTES = 256 * 1024; + +export type WorkspaceFileReadErrorCode = "BAD_REQUEST" | "NOT_FOUND" | "INTERNAL_SERVER_ERROR"; + +export class WorkspaceFileReadError extends Error { + readonly code: WorkspaceFileReadErrorCode; + + constructor(code: WorkspaceFileReadErrorCode, message: string) { + super(message); + this.name = "WorkspaceFileReadError"; + this.code = code; + } +} + +function createWorkspaceFileReadError(code: WorkspaceFileReadErrorCode, message: string): WorkspaceFileReadError { + return new WorkspaceFileReadError(code, message); +} + +function normalizeWorkspaceRelativePath(relativePath: string): { posixPath: string; platformPath: string } { + const trimmedPath = relativePath.trim(); + if (!trimmedPath) { + throw createWorkspaceFileReadError("BAD_REQUEST", "File path cannot be empty."); + } + + const slashNormalizedPath = trimmedPath.replaceAll("\\", "/"); + if (isAbsolute(trimmedPath) || /^[A-Za-z]:\//.test(slashNormalizedPath)) { + throw createWorkspaceFileReadError("BAD_REQUEST", "File path must be relative to the workspace."); + } + + if (slashNormalizedPath.split("/").some((segment) => segment === "..")) { + throw createWorkspaceFileReadError("BAD_REQUEST", "File path must stay within the workspace."); + } + + const normalizedPosixPath = slashNormalizedPath + .split("/") + .filter((segment) => segment.length > 0 && segment !== ".") + .join("/"); + if (!normalizedPosixPath) { + throw createWorkspaceFileReadError("BAD_REQUEST", "File path cannot be empty."); + } + + return { + posixPath: normalizedPosixPath, + platformPath: normalizedPosixPath.split("/").join(sep), + }; +} + +function assertImportableMarkdownPath(relativePath: string): void { + if (!IMPORTABLE_WORKSPACE_MARKDOWN_EXTENSIONS.has(extname(relativePath).toLowerCase())) { + throw createWorkspaceFileReadError( + "BAD_REQUEST", + "Only .md, .markdown, and .mdx files can be imported.", + ); + } +} + +function isPathInsideWorkspace(workspacePath: string, targetPath: string): boolean { + const relativePath = relative(workspacePath, targetPath); + return relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath)); +} + +export async function readWorkspaceMarkdownFile( + workspacePath: string, + relativePath: string, +): Promise<{ path: string; content: string }> { + const normalizedPath = normalizeWorkspaceRelativePath(relativePath); + assertImportableMarkdownPath(normalizedPath.posixPath); + + let resolvedWorkspacePath: string; + try { + resolvedWorkspacePath = await realpath(workspacePath); + } catch { + throw createWorkspaceFileReadError("INTERNAL_SERVER_ERROR", "Workspace path is unavailable."); + } + + const requestedPath = resolve(resolvedWorkspacePath, normalizedPath.platformPath); + let resolvedFilePath: string; + try { + resolvedFilePath = await realpath(requestedPath); + } catch (error) { + const errorCode = error instanceof Error && "code" in error ? error.code : undefined; + if (errorCode === "ENOENT" || errorCode === "ENOTDIR") { + throw createWorkspaceFileReadError("NOT_FOUND", "Workspace file not found."); + } + throw createWorkspaceFileReadError("INTERNAL_SERVER_ERROR", "Unable to access workspace file."); + } + + if (!isPathInsideWorkspace(resolvedWorkspacePath, resolvedFilePath)) { + throw createWorkspaceFileReadError("BAD_REQUEST", "File path must stay within the workspace."); + } + + let fileStats: Stats; + try { + fileStats = await stat(resolvedFilePath); + } catch { + throw createWorkspaceFileReadError("INTERNAL_SERVER_ERROR", "Unable to inspect workspace file."); + } + if (!fileStats.isFile()) { + throw createWorkspaceFileReadError("BAD_REQUEST", "File path must point to a file."); + } + if (fileStats.size > MAX_WORKSPACE_MARKDOWN_FILE_BYTES) { + throw createWorkspaceFileReadError("BAD_REQUEST", "Markdown file is too large to import."); + } + + try { + const content = await readFile(resolvedFilePath, "utf8"); + return { + path: normalizedPath.posixPath, + content, + }; + } catch { + throw createWorkspaceFileReadError("INTERNAL_SERVER_ERROR", "Unable to read workspace file."); + } +} diff --git a/test/runtime/api-validation.test.ts b/test/runtime/api-validation.test.ts index b67c51b..c1d5c20 100644 --- a/test/runtime/api-validation.test.ts +++ b/test/runtime/api-validation.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { parseHookIngestRequest, parseTaskSessionStartRequest, + parseWorkspaceFileReadRequest, parseWorkspaceFileSearchRequest, } from "../../src/core/api-validation.js"; @@ -36,6 +37,19 @@ describe("parseWorkspaceFileSearchRequest", () => { }); }); +describe("parseWorkspaceFileReadRequest", () => { + it("trims a valid file path", () => { + const parsed = parseWorkspaceFileReadRequest({ path: " docs/plan.md " }); + expect(parsed).toEqual({ path: "docs/plan.md" }); + }); + + it("throws when the file path is empty", () => { + expect(() => { + parseWorkspaceFileReadRequest({ path: " " }); + }).toThrow("File path cannot be empty."); + }); +}); + describe("parseHookIngestRequest", () => { it("parses and trims task and workspace identifiers", () => { const parsed = parseHookIngestRequest({ diff --git a/test/runtime/trpc/workspace-api.test.ts b/test/runtime/trpc/workspace-api.test.ts index c92f8e9..4719314 100644 --- a/test/runtime/trpc/workspace-api.test.ts +++ b/test/runtime/trpc/workspace-api.test.ts @@ -16,6 +16,10 @@ const workspaceChangesMocks = vi.hoisted(() => ({ getWorkspaceChangesFromRef: vi.fn(), })); +const readWorkspaceFileMocks = vi.hoisted(() => ({ + readWorkspaceMarkdownFile: vi.fn(), +})); + vi.mock("../../../src/workspace/task-worktree.js", () => ({ deleteTaskWorktree: vi.fn(), ensureTaskWorktreeIfDoesntExist: vi.fn(), @@ -30,6 +34,20 @@ vi.mock("../../../src/workspace/get-workspace-changes.js", () => ({ getWorkspaceChangesFromRef: workspaceChangesMocks.getWorkspaceChangesFromRef, })); +vi.mock("../../../src/workspace/read-workspace-file.js", () => ({ + readWorkspaceMarkdownFile: readWorkspaceFileMocks.readWorkspaceMarkdownFile, + WorkspaceFileReadError: class WorkspaceFileReadError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "WorkspaceFileReadError"; + this.code = code; + } + }, +})); + +import { WorkspaceFileReadError } from "../../../src/workspace/read-workspace-file.js"; import { createWorkspaceApi } from "../../../src/trpc/workspace-api.js"; function createSummary(overrides: Partial = {}): RuntimeTaskSessionSummary { @@ -67,6 +85,7 @@ describe("createWorkspaceApi loadChanges", () => { workspaceChangesMocks.getWorkspaceChanges.mockReset(); workspaceChangesMocks.getWorkspaceChangesBetweenRefs.mockReset(); workspaceChangesMocks.getWorkspaceChangesFromRef.mockReset(); + readWorkspaceFileMocks.readWorkspaceMarkdownFile.mockReset(); workspaceTaskWorktreeMocks.resolveTaskCwd.mockResolvedValue("/tmp/worktree"); workspaceChangesMocks.createEmptyWorkspaceChangesResponse.mockResolvedValue(createChangesResponse()); @@ -170,3 +189,59 @@ describe("createWorkspaceApi loadChanges", () => { expect(workspaceChangesMocks.getWorkspaceChangesBetweenRefs).not.toHaveBeenCalled(); }); }); + +describe("createWorkspaceApi readFile", () => { + it("delegates markdown file reads to the workspace helper", async () => { + readWorkspaceFileMocks.readWorkspaceMarkdownFile.mockResolvedValue({ + path: "docs/plan.md", + content: "# Plan", + }); + + const api = createWorkspaceApi({ + ensureTerminalManagerForWorkspace: vi.fn(), + broadcastRuntimeWorkspaceStateUpdated: vi.fn(), + broadcastRuntimeProjectsUpdated: vi.fn(), + buildWorkspaceStateSnapshot: vi.fn(), + }); + + await expect( + api.readFile( + { + workspaceId: "workspace-1", + workspacePath: "/tmp/repo", + }, + { path: " docs/plan.md " }, + ), + ).resolves.toEqual({ + path: "docs/plan.md", + content: "# Plan", + }); + expect(readWorkspaceFileMocks.readWorkspaceMarkdownFile).toHaveBeenCalledWith("/tmp/repo", "docs/plan.md"); + }); + + it("preserves typed workspace read failures as TRPC errors", async () => { + readWorkspaceFileMocks.readWorkspaceMarkdownFile.mockRejectedValue( + new WorkspaceFileReadError("BAD_REQUEST", "Workspace file not found."), + ); + + const api = createWorkspaceApi({ + ensureTerminalManagerForWorkspace: vi.fn(), + broadcastRuntimeWorkspaceStateUpdated: vi.fn(), + broadcastRuntimeProjectsUpdated: vi.fn(), + buildWorkspaceStateSnapshot: vi.fn(), + }); + + await expect( + api.readFile( + { + workspaceId: "workspace-1", + workspacePath: "/tmp/repo", + }, + { path: "docs/missing.md" }, + ), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Workspace file not found.", + }); + }); +}); diff --git a/test/runtime/workspace/read-workspace-file.test.ts b/test/runtime/workspace/read-workspace-file.test.ts new file mode 100644 index 0000000..43f0602 --- /dev/null +++ b/test/runtime/workspace/read-workspace-file.test.ts @@ -0,0 +1,67 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + MAX_WORKSPACE_MARKDOWN_FILE_BYTES, + readWorkspaceMarkdownFile, +} from "../../../src/workspace/read-workspace-file.js"; +import { createTempDir } from "../../utilities/temp-dir.js"; + +describe("readWorkspaceMarkdownFile", () => { + it("reads an importable markdown file within the workspace", async () => { + const { path, cleanup } = createTempDir("kanban-read-workspace-file-"); + try { + mkdirSync(join(path, "docs"), { recursive: true }); + writeFileSync(join(path, "docs", "plan.md"), "# Plan\n\n- [ ] Ship feature\n", "utf8"); + + await expect(readWorkspaceMarkdownFile(path, "docs/plan.md")).resolves.toEqual({ + path: "docs/plan.md", + content: "# Plan\n\n- [ ] Ship feature\n", + }); + } finally { + cleanup(); + } + }); + + it("rejects traversal outside the workspace", async () => { + const { path, cleanup } = createTempDir("kanban-read-workspace-file-"); + try { + await expect(readWorkspaceMarkdownFile(path, "../outside.md")).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "File path must stay within the workspace.", + }); + } finally { + cleanup(); + } + }); + + it("rejects unsupported file extensions", async () => { + const { path, cleanup } = createTempDir("kanban-read-workspace-file-"); + try { + writeFileSync(join(path, "notes.txt"), "not markdown", "utf8"); + + await expect(readWorkspaceMarkdownFile(path, "notes.txt")).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Only .md, .markdown, and .mdx files can be imported.", + }); + } finally { + cleanup(); + } + }); + + it("rejects files larger than the import size cap", async () => { + const { path, cleanup } = createTempDir("kanban-read-workspace-file-"); + try { + writeFileSync(join(path, "large.md"), "a".repeat(MAX_WORKSPACE_MARKDOWN_FILE_BYTES + 1), "utf8"); + + await expect(readWorkspaceMarkdownFile(path, "large.md")).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Markdown file is too large to import.", + }); + } finally { + cleanup(); + } + }); +}); diff --git a/web-ui/src/App.tsx b/web-ui/src/App.tsx index 99031a8..0465401 100644 --- a/web-ui/src/App.tsx +++ b/web-ui/src/App.tsx @@ -121,6 +121,15 @@ export default function App(): ReactElement { } return shortcuts[0]?.label ?? null; }, [runtimeProjectConfig?.selectedShortcutLabel, shortcuts]); + const taskAgentOptions = useMemo( + () => + (runtimeProjectConfig?.agents ?? []).map((agent) => ({ + value: agent.id, + label: agent.label, + installed: agent.installed, + })), + [runtimeProjectConfig?.agents], + ); const { upsertSession, @@ -219,6 +228,8 @@ export default function App(): ReactElement { setNewTaskAutoReviewEnabled, newTaskAutoReviewMode, setNewTaskAutoReviewMode, + newTaskAgentId, + setNewTaskAgentId, isNewTaskStartInPlanModeDisabled, newTaskBranchRef, setNewTaskBranchRef, @@ -231,6 +242,8 @@ export default function App(): ReactElement { setEditTaskAutoReviewEnabled, editTaskAutoReviewMode, setEditTaskAutoReviewMode, + editTaskAgentId, + setEditTaskAgentId, isEditTaskStartInPlanModeDisabled, editTaskBranchRef, setEditTaskBranchRef, @@ -635,6 +648,9 @@ export default function App(): ReactElement { onAutoReviewEnabledChange={setEditTaskAutoReviewEnabled} autoReviewMode={editTaskAutoReviewMode} onAutoReviewModeChange={setEditTaskAutoReviewMode} + agentId={editTaskAgentId} + agentOptions={taskAgentOptions} + onAgentIdChange={setEditTaskAgentId} workspaceId={currentProjectId} branchRef={editTaskBranchRef} branchOptions={createTaskBranchOptions} @@ -920,6 +936,9 @@ export default function App(): ReactElement { onAutoReviewEnabledChange={setNewTaskAutoReviewEnabled} autoReviewMode={newTaskAutoReviewMode} onAutoReviewModeChange={setNewTaskAutoReviewMode} + agentId={newTaskAgentId} + agentOptions={taskAgentOptions} + onAgentIdChange={setNewTaskAgentId} workspaceId={currentProjectId} branchRef={newTaskBranchRef} branchOptions={createTaskBranchOptions} diff --git a/web-ui/src/components/task-create-dialog.test.tsx b/web-ui/src/components/task-create-dialog.test.tsx new file mode 100644 index 0000000..a25fd8c --- /dev/null +++ b/web-ui/src/components/task-create-dialog.test.tsx @@ -0,0 +1,253 @@ +import { act, type ReactElement, type ReactNode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TaskCreateDialog } from "@/components/task-create-dialog"; +import type { RuntimeAgentId } from "@/runtime/types"; +import type { TaskAutoReviewMode } from "@/types"; + +const searchFilesQueryMock = vi.hoisted(() => vi.fn()); +const readFileQueryMock = vi.hoisted(() => vi.fn()); + +vi.mock("react-hotkeys-hook", () => ({ + useHotkeys: () => {}, +})); + +vi.mock("@/runtime/trpc-client", () => ({ + getRuntimeTrpcClient: () => ({ + workspace: { + searchFiles: { + query: searchFilesQueryMock, + }, + readFile: { + query: readFileQueryMock, + }, + }, + }), +})); + +vi.mock("@/components/task-prompt-composer", () => ({ + TaskPromptComposer: ({ value, onValueChange, placeholder }: { value: string; onValueChange: (value: string) => void; placeholder?: string }) => ( +