summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/directives.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-01 18:06:38 +0100
committerGitHub <noreply@github.com>2026-05-01 18:06:38 +0100
commit80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch)
tree5802a9923eab572a98a6d8b21be2fdc56fdf7118 /makima/src/server/handlers/directives.rs
parent6d922307223d12f436b229d4c4b29b8835b93b6c (diff)
downloadsoryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.tar.gz
soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.zip
feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec redirect, palette, SWR (#117)
Phases 1-3 of the unified-surface plan, bundled per the user's request. The directive document/folder UI is now the canonical place to interact with makima; legacy /exec is subsumed by routing redirects, /contracts is already hidden in nav, and orphan tasks surface under a top-level tmp/ pseudo-folder. Phase 4 (porting contract-only features) was confirmed out of scope; Phase 5 (deleting the contracts code) is a follow-up. ## Backend - New migration `20260501000000_archive_existing_contracts.sql` flips every legacy contract to `archived` so /contracts is read-only history. - New endpoint `POST /api/v1/directives/{id}/tasks` creates an ephemeral task — `directive_id` set, `directive_step_id` NULL, repo/branch inherited from the directive. Reuses `create_task_for_owner`. - New endpoint `GET /api/v1/directives/{id}/tasks` lists ephemeral tasks attached to a directive (drives the per-folder ephemeral group). - `GET /api/v1/mesh/tasks?orphan=true` returns top-level tasks with no `directive_id` AND no `parent_task_id` — backs the sidebar's tmp/. - New repo helpers `list_ephemeral_directive_tasks_for_owner` and `list_orphan_tasks_for_owner`. - The existing `mesh_merge` endpoints are reused as-is for ephemeral task merge (no new merge logic needed). ## Frontend ### Sticky composer + auto-scroll fix (`DocumentTaskStream.tsx`) - Sticky comment composer pinned to viewport bottom; padding compensates so the last entry isn't hidden behind it. - `autoScroll` now resumes when the user scrolls back within 80px of the bottom (previously stuck off forever after a single scroll-up). - Floating "↓ Jump to latest" chip when the user has scrolled away. - Action header strip: explicit Stop / Send / Open-in-task-page + conditional "Merge to base ↗" button on ephemeral terminal tasks. - Module-level cache of historical entries by taskId so re-selecting a task you've viewed renders instantly while a fresh fetch runs. ### Sidebar (`document-directives.tsx`) - Top-level `tmp/` folder: orphan tasks, polled every 5s. - Per-directive `tasks/` subfolder now also surfaces ephemeral tasks (lazily fetched on folder open) with a distinct asterisk-on-terminal icon (`EphemeralTaskIcon`). - Inline hover-action chips on each directive folder header: Start / Pause / PR / +New task. Right-click menu still works as a power-user fallback. - "Now executing" amber strip in the editor pane: surfaces the live orchestrator/completion/running-step task with a one-click jump. - Inline `+ New task` modal (name + plan); on submit calls `createDirectiveTask` and navigates into the freshly-spawned task. - New `EphemeralAwareTaskStream` wrapper passes `ephemeral` and `status` to `DocumentTaskStream` so the merge button only shows when the selected task is genuinely an ephemeral spinoff in a terminal state. Step-spawned tasks merge via the directive's PR completion. ### SWR cache (`useDirectives.ts`) - Module-level `listCache` and per-id `detailCache` (mirrors the pattern in `useUserSettings.ts`). Mounting the hook renders the cache value immediately if present and kicks a background refresh; subscribers see the new value when it lands. Cuts perceived navigation latency to near-zero on warm cache hits. ### QuickSwitcher (`QuickSwitcher.tsx`, new) - IntelliJ-style double-Shift command palette mounted at app root. - Listens at the document level for two `Shift` keydowns within 300ms with no other key in between; ignores while focus is in an input/textarea so capitalising letters doesn't pop the palette. - Searches across directives + their tasks (orchestrator/completion/ steps/ephemerals) + orphan tmp tasks. Fuzzy matches on title. - Eagerly loads task details for the first 20 directives on open so searches don't block on per-directive fetches. ### Routing (`main.tsx` + `exec-redirect.tsx` + `tmp.tsx`) - New `ExecRedirect` wrapper at `/exec/:id`: when documentMode is on AND the task has a `directiveId`, replaces the URL with `/directives/<directiveId>?task=<taskId>`. Otherwise renders the legacy `MeshPage` as before. - New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for orphan tasks, with the masthead and a `tmp / <slug>` breadcrumb. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server/handlers/directives.rs')
-rw-r--r--makima/src/server/handlers/directives.rs161
1 files changed, 161 insertions, 0 deletions
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs
index 7a7aff4..7b13f1c 100644
--- a/makima/src/server/handlers/directives.rs
+++ b/makima/src/server/handlers/directives.rs
@@ -12,6 +12,7 @@ use crate::db::models::{
CleanupResponse, CreateDirectiveRequest, CreateTaskRequest,
CreateDirectiveStepRequest, Directive, DirectiveListResponse,
DirectiveRevision, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse,
+ Task,
UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest,
CreateDirectiveOrderGroupRequest, DirectiveOrderGroup,
DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest,
@@ -2194,3 +2195,163 @@ pub async fn list_directive_revisions(
}
}
}
+
+// =============================================================================
+// Ephemeral tasks under a directive
+//
+// A directive can spin up ad-hoc one-off tasks that are NOT part of its DAG.
+// They live in the `tasks` table with `directive_id` set and
+// `directive_step_id = NULL` — the existing schema already supports this; we
+// just expose the create path through a directive-scoped endpoint and
+// inherit repo/branch from the parent directive when the caller doesn't
+// override.
+// =============================================================================
+
+/// Request body for creating an ephemeral task under a directive.
+#[derive(Debug, serde::Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateDirectiveTaskRequest {
+ /// Human-readable name (used for branch/commit names).
+ pub name: String,
+ /// Plan / instructions for the Claude Code session.
+ pub plan: String,
+ /// Optional override for the directive's repository.
+ pub repository_url: Option<String>,
+ /// Optional override for the directive's base branch.
+ pub base_branch: Option<String>,
+}
+
+/// List the ephemeral tasks (no directive_step_id) attached to a directive.
+#[utoipa::path(
+ get,
+ path = "/api/v1/directives/{id}/tasks",
+ params(("id" = Uuid, Path, description = "Directive ID")),
+ responses(
+ (status = 200, description = "List of ephemeral tasks", body = crate::db::models::TaskListResponse),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directives"
+)]
+pub async fn list_directive_tasks(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_ephemeral_directive_tasks_for_owner(pool, auth.owner_id, id).await {
+ Ok(tasks) => {
+ let total = tasks.len() as i64;
+ Json(crate::db::models::TaskListResponse { tasks, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list ephemeral tasks: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LIST_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create an ephemeral task attached to a directive.
+#[utoipa::path(
+ post,
+ path = "/api/v1/directives/{id}/tasks",
+ params(("id" = Uuid, Path, description = "Directive ID")),
+ request_body = CreateDirectiveTaskRequest,
+ responses(
+ (status = 201, description = "Ephemeral task created", body = Task),
+ (status = 404, description = "Directive not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directives"
+)]
+pub async fn create_directive_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CreateDirectiveTaskRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Look up the parent directive so we can inherit its repository/base branch
+ // when the caller doesn't override them. Also gates the request on
+ // ownership: if the directive isn't visible to this owner, 404.
+ let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
+ Ok(Some(d)) => d,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to load directive for ephemeral task: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", &e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ let repo_url = req
+ .repository_url
+ .or_else(|| directive.repository_url.clone());
+ let base_branch = req.base_branch.or_else(|| directive.base_branch.clone());
+
+ let create_req = CreateTaskRequest {
+ contract_id: None,
+ name: req.name,
+ description: None,
+ plan: req.plan,
+ parent_task_id: None,
+ is_supervisor: false,
+ priority: 0,
+ repository_url: repo_url,
+ base_branch,
+ target_branch: None,
+ merge_mode: None,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ checkpoint_sha: None,
+ branched_from_task_id: None,
+ conversation_history: None,
+ supervisor_worktree_task_id: None,
+ directive_id: Some(directive.id),
+ // No directive_step_id — this is what makes the task "ephemeral":
+ // it lives under the directive folder but isn't part of the DAG.
+ directive_step_id: None,
+ };
+
+ match repository::create_task_for_owner(pool, auth.owner_id, create_req).await {
+ Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create ephemeral directive task: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("CREATE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}