From 32d00fb6893b012ba3779b2c2c6ac53e099961be Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Sat, 25 Apr 2026 15:12:08 +0900 Subject: [PATCH 1/2] feat(phase6b): planning workspace UX overhaul + role-dispatch task loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Planning workspace Two-panel layout: 240px requirement sidebar + flex main workspace. Empty project shows centered onboarding view (WorkspaceOnboardingPanel); non-empty projects show the two-panel layout with inline requirement intake. What's Next flow: - Empty-state onboarding panel surfaces a "Run What's Next →" button when a planning provider is configured. - Auto-select fallback: when no user requirements exist, the hook selects the most recent analysis requirement so What's Next results persist across page reloads. - Run results (PlanningRunList + CandidateReviewPanel) render below the onboarding panel in the empty state when an analysis requirement is selected. Candidate review UX simplification: - Action bar reduced from six buttons to three: Apply, Skip, Save edits (conditional). Approve, Return to Draft, Reject, Reset, Status dropdown all removed. - Apply auto-saves dirty title/description edits before applying. - Skip marks candidate as rejected and auto-advances to the next non-rejected candidate in rank order. - "N skipped ▾" collapsed section at bottom of candidate list. - canApplySelectedCandidate no longer requires status=approved; backend updated to block only rejected candidates (not draft). Requirement lifecycle: - Discard (hard delete) vs Archive (soft delete) based on task lineage. - DELETE /api/requirements/:id with two backend guards: HasAppliedTasks (409) and HasActiveRun (409). - RequirementQueue shows inline confirmation overlay for Discard. - Collapsed "N archived ▾" section at bottom of sidebar. Connector status badge: - PlanningLauncher connector badge shows "● Running job…" (amber) when dispatch_status=leased, "⏳ Queued…" (blue) when queued. Backend fixes: - adapter_type validation no longer overwrites 'whatsnext' with provider ID. - local_connector is prepended (not appended) to available_execution_modes so it is the default when a connector is online. - CLI config path sends connector_id + cli_config_id instead of legacy account_binding_id. ## Role-dispatch task execution loop (Phase 6b) Migration 029: adds dispatch_status + execution_result columns to tasks. Tasks with source starting with role_dispatch: are queued automatically. Two new connector-authenticated endpoints: POST /connector/claim-next-task — atomic claim via BEGIN IMMEDIATE POST /connector/tasks/:id/execution-result — submit result Connector service integrates RunOnceTask into the existing polling loop. Frontend DispatchStatusBadge renders inline next to task title; completed state is expandable to show file paths. Tests: 143 frontend pass (23 files); go build ./... clean. Co-Authored-By: Claude Sonnet 4.6 --- DECISIONS.md | 32 ++ backend/cmd/server/main.go | 5 +- .../db/migrations/029_task_dispatch.down.sql | 5 + backend/db/migrations/029_task_dispatch.sql | 10 + backend/internal/connector/client.go | 18 + backend/internal/connector/dispatch.go | 38 ++ backend/internal/connector/service.go | 184 ++++++++ .../internal/handlers/connector_dispatch.go | 185 ++++++++ .../handlers/connector_dispatch_test.go | 341 +++++++++++++++ backend/internal/handlers/local_connectors.go | 11 + backend/internal/handlers/planning_runs.go | 26 +- backend/internal/handlers/requirements.go | 40 ++ backend/internal/models/task.go | 36 +- backend/internal/router/router.go | 4 + .../internal/store/backlog_candidate_store.go | 58 ++- backend/internal/store/requirement_store.go | 34 ++ backend/internal/store/task_store.go | 275 +++++++++++- docs/api-surface.md | 4 + docs/data-model.md | 11 +- docs/phase6b-plan.md | 193 +++++++++ frontend/src/api/client.ts | 4 + frontend/src/index.css | 154 +++++++ .../src/pages/ProjectDetail/PlanningTab.tsx | 405 ++++++++++-------- .../src/pages/ProjectDetail/TasksTab.test.tsx | 42 +- frontend/src/pages/ProjectDetail/TasksTab.tsx | 125 ++++++ .../planning/CandidateReviewPanel.test.tsx | 28 +- .../planning/CandidateReviewPanel.tsx | 142 +++--- .../planning/PlanningLauncher.test.tsx | 73 ++-- .../planning/PlanningLauncher.tsx | 68 +-- .../planning/RequirementIntake.tsx | 125 +++--- .../planning/RequirementQueue.test.tsx | 3 +- .../planning/RequirementQueue.tsx | 260 ++++++++--- .../WorkspaceOnboardingPanel.test.tsx | 18 +- .../planning/WorkspaceOnboardingPanel.tsx | 108 ++--- .../hooks/usePlanningWorkspaceData.ts | 202 +++++++-- frontend/src/types/index.ts | 4 + go.work | 3 + 37 files changed, 2697 insertions(+), 577 deletions(-) create mode 100644 backend/db/migrations/029_task_dispatch.down.sql create mode 100644 backend/db/migrations/029_task_dispatch.sql create mode 100644 backend/internal/connector/dispatch.go create mode 100644 backend/internal/handlers/connector_dispatch.go create mode 100644 backend/internal/handlers/connector_dispatch_test.go create mode 100644 docs/phase6b-plan.md create mode 100644 go.work diff --git a/DECISIONS.md b/DECISIONS.md index ddb58eb..317c258 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -4,6 +4,30 @@ Active architectural and behavioral decisions for Agent Native PM. When this file exceeds 50 entries or 30 KB, archive older entries to `DECISIONS_ARCHIVE.md`. The most recent archival pass was on 2026-04-22. +## 2026-04-25: Requirement discard, analysis filtering, and connector run-status badge [agent:application-implementer] + +- **Context**: Three interconnected UX improvements: (1) Requirements with no applied tasks are permanently deletable (not just archivable); (2) `source=analysis` / `source=system` requirements should not appear in the sidebar or affect the isEmpty check; (3) The connector status badge should reflect when a run is actively leased or queued. +- **Decision**: (1) Added `DELETE /api/requirements/:id` endpoint. Backend enforces two guards: 409 if any `task_lineage` row references the requirement; 409 if any planning run is in an active state (queued/running/leased). Store adds `HasAppliedTasks`, `HasActiveRun`, and `Delete` methods to `RequirementStore`. Frontend adds `deleteRequirement` to `api/client.ts`. Hook adds `handleDiscardRequirement` which calls the endpoint and calls `loadAppliedLineageMeta` on success. (2) `PlanningTab` derives `sidebarRequirements = requirements.filter(r => r.source !== 'analysis' && r.source !== 'system')` and passes it to `RequirementQueue`; `isEmpty` now uses `sidebarRequirements.length === 0` so analysis-only projects still show the onboarding panel. Auto-select in the hook skips `source=analysis` and `source=system`. (3) `usePlanningWorkspaceData` derives `activeRunDispatchStatus` from `planningRuns`; `PlanningLauncher` receives it and shows "● Running job…" (amber) for `leased` and "⏳ Queued…" (blue) for `queued` instead of "● Online" (green). (4) `RequirementQueue` rework: `activeRequirements` and `archivedRequirements` split; archived requirements collapse into a toggle at the bottom; action button chooses between Discard (no lineage, handler provided, lineage data loaded) and Archive (has lineage or handler not provided); inline Discard confirmation overlay prevents accidental deletes. `loadAppliedLineageMeta` fetches `/projects/:id/task-lineage` on mount and after apply/discard to keep the lineage set current. +- **Alternatives considered**: (1) Soft-delete with a `deleted_at` column — rejected; unnecessary complexity for requirements that have never been applied to tasks. (2) Showing Discard always and catching the 409 error in UI — rejected; the backend guard is a safety net, not the primary UX signal. The lineage set gives real-time context. (3) Filtering `source=analysis` on the backend — rejected; the frontend needs the full list for other purposes (e.g. selecting a requirement after What's Next creates one). (4) Showing the archived requirements inline with an opacity — rejected; the collapsed section keeps the sidebar clean for operators with many archived requirements. +- **Constraints introduced**: (a) `requirementIdsWithAppliedTasks` starts as `undefined` (not yet loaded) and transitions to a `Set` after the first fetch. `RequirementQueue` treats `undefined` as "show Archive" (safe fallback). (b) `DELETE /requirements/:id` is blocked if either guard fires; there is no force-delete bypass. (c) `activeRunDispatchStatus` is derived from `planningRuns` which are scoped to the currently selected requirement — the badge only shows the status of the selected requirement's active run, not globally. (d) The PlanningLauncher button label remains "Advanced" (matching existing tests T-6a-A2-1/A2-2/A2-3). A worktree-level inconsistency was found where the button had been changed to "Details" without updating tests; this was reverted in this pass. +- **Source**: User implementation request 2026-04-25. Tests: 143 frontend pass (23 files); `go build ./...` clean; `go test ./...` all packages pass; `make lint` clean. + +## 2026-04-25: Planning tab — two-panel layout redesign (sidebar + workspace) [agent:application-implementer] + +- **Context**: The Planning tab used a "vertical stack of cards" layout: AttentionRow + PlanningStepper + RequirementIntake (card) + RequirementQueue (card) + planning-workspace-card. This became hard to scan as projects grew; selecting a requirement required scrolling past the queue to reach the workspace. +- **Decision**: Replaced the vertical stack with two distinct states: (A) empty project → centered `planning-welcome-view` (max 640px, no card wrappers) rendered via a redesigned `WorkspaceOnboardingPanel`; (B) non-empty project → `planning-two-panel` grid (240px sidebar + flex main). The sidebar holds the requirement list (`RequirementQueue compact` mode — no card wrapper, no verbose header) and a "+ New" toggle that shows `RequirementIntake variant="inline"` (no card wrapper). The main panel shows the workspace components (PlanningLauncher, PlanningRunList, CandidateReviewPanel, AppliedLineage) when a requirement is selected, or an empty-state prompt + "Run What's Next" button when none is selected. `AttentionRow` and `PlanningStepper` are no longer rendered (their functionality moves to sidebar count badges and the main-panel empty state). `openDriftCount` and `onNavigateToDrift` props are kept in the interface for parent compatibility but are not rendered; `void` expressions suppress the lint error. +- **Alternatives considered**: (1) Keep vertical stack, add anchor links — rejected; doesn't solve the "too much scrolling" core problem. (2) Remove AttentionRow entirely vs. move to sidebar — moved count badges to sidebar footer which is always visible without scroll. +- **Constraints introduced**: (a) `RequirementQueue` now accepts `compact?: boolean`; when true omits the `.card` wrapper and header, renders a slim `.planning-req-compact` div. Existing non-compact usages are unaffected. (b) `RequirementIntake` now accepts `variant?: 'card' | 'inline'`; when `'inline'` renders inside `.planning-inline-form` with no header, no badge, and no toggle button — toggle is always externally controlled. (c) `WorkspaceOnboardingPanel` props interface is unchanged; `onWhatsnext` was never in the interface. (d) `PlanningTab` `openDriftCount` and `onNavigateToDrift` props remain in the interface for backward compatibility with `ProjectDetail.tsx`. +- **Source**: User design request 2026-04-25. Tests: 143 pass (23 files); `npm run build` and `make lint` clean. + +## 2026-04-25: Planning launcher — migrate CLI selection from legacy account_bindings to per-connector cli_configs [agent:implementer] + +- **Context**: Phase 6a moved CLI configuration from `account_bindings` (provider_id starting with `cli:`) to `local_connectors.metadata.cli_configs`. The planning launcher and its hook were still using the Phase 3 legacy path (`listAccountBindings` → filter `cli:*`), sending `account_binding_id` in planning run create payloads. +- **Decision**: `usePlanningWorkspaceData` now calls `listLocalConnectors` then `listConnectorCliConfigs` per non-revoked connector. Results are flattened into `CliConfigOption[]` (composite key `${connectorId}:${configId}`). Auto-selection prefers primary+online > primary > first. `handleCreatePlanningRun` and `handleRunWhatsnext` send `{ connector_id, cli_config_id }` instead of `{ account_binding_id }`. The `CliConfigOption` interface is exported from the hook for use by `PlanningLauncher.tsx` and its tests. `PlanningTab.tsx` `onRunCreated` callback now also calls `onReload()` after `onSelectLineage` so requirements refresh immediately after onboarding panel creates a run. +- **Alternatives considered**: Keeping `account_binding_id` as a fallback — rejected; the backend already supports `connector_id` + `cli_config_id` as the preferred path (per `CreatePlanningRunPayload` comment in types); using the legacy path creates confusing dual-path behavior. +- **Constraints introduced**: (a) `CliConfigOption.key` is the composite string `${connectorId}:${configId}`; splitting on `:` at send-time assumes neither ID contains a colon — this matches the UUID format used by the backend. (b) Connectors with `status === 'revoked'` are excluded; all other statuses (including `offline`) are included so the user can still select an offline connector's config. (c) `AccountBinding` import removed from `PlanningLauncher.tsx` and its test file. (d) `PlanningLauncher.test.tsx` updated: `makeBinding` helper replaced by `makeCliConfig`; test assertions updated for new label format `"{connectorLabel} — {configLabel} [{modelId}]"` and new link text "Set up CLIs in My Connector". +- **Source**: Dogfooding fix request 2026-04-25. Tests: 140 pass; `npm run build` clean. + ## 2026-04-24: ModelSettingsHub UX redesign — Directions A/B/C/D [agent:implementer] - **Context**: Follow-on UX pass on ModelSettingsHub post Phase-6a. Four directions in one rewrite: Direction A (live "Your current setup" banner), Direction B (simplified card titles and descriptions), Direction C (live connector status list in Option C card), Direction D (moved "Still unsure?" guidance into the banner, shown only when setupStatus === 'none'). @@ -351,3 +375,11 @@ When this file exceeds 50 entries or 30 KB, archive older entries to `DECISIONS_ - **Alternatives considered**: Keep pushing users toward account bindings and local OpenAI-compatible presets only — rejected because it does not solve the subscription-only use case. Add a full connector dispatch system immediately — rejected because it expands scope before pairing and registry are proven. Reuse bearer session tokens for connectors — rejected because connector identity must remain separate from user sessions. - **Constraints introduced**: Pairing codes are stored only as hashes and must be single-use with short TTL. Connector presence uses `X-Connector-Token`, not user bearer auth. Batch 1 does not promise subscription execution yet; it only establishes the control-plane seam required for later dispatch. - **Source**: [agent:documentation-architect] + +## 2026-04-25: Phase 6b — role-dispatch task execution loop closed + +- **Context**: Phase 5 established `execution_role` on `backlog_candidates` as a hint for which prompt role a connector should run. Phase 6b closes the loop by wiring up `dispatch_status`/`execution_result` columns on `tasks` and two new connector-authenticated endpoints that let a paired connector atomically claim a queued task, invoke the CLI, and submit the result back. +- **Decision**: Add `dispatch_status TEXT NOT NULL DEFAULT 'none'` and `execution_result JSONB` to `tasks` (migration 029). `dispatch_status = 'queued'` is set at task creation when `source` starts with `role_dispatch:`. Ownership is enforced via the `project_members` table throughout (there is no `projects.user_id` column). The atomic claim uses `BEGIN IMMEDIATE` (SQLite) / `FOR UPDATE SKIP LOCKED` (Postgres) via the existing `database.Dialect` pattern. The connector service `RunOnceTask` method integrates into the existing polling loop alongside `RunOnce` (planning run poll). Frontend `DispatchStatusBadge` renders inline next to the task title; `completed` state is expandable to show file paths. `error_kind` validation reuses the existing `AllowedErrorKinds` allowlist from planning runs. +- **Alternatives considered**: Lease-expiry TTL for tasks (matching planning runs) — deferred; task execution is typically short-lived and the connector loop already handles timeouts via context cancellation. A separate `task_dispatch_log` table for audit history — deferred; `execution_result` JSON on the task row is sufficient for Phase 6b. Role catalog enforcement in the claim handler — deferred; `prompts.Exists` check is in `RunOnceTask` on the connector side, not the server side, because the server does not hold the prompts directory. +- **Constraints introduced**: Only tasks with `dispatch_status = 'running'` may have results submitted; any other state returns 400. Ownership check uses `project_members` JOIN (not `projects.user_id`). The `NewTaskStoreWithDialect` constructor must be used when dispatch methods are needed; `NewTaskStore` (no dialect) continues to work for code that does not use dispatch. `main.go` uses `NewTaskStoreWithDialect` to ensure the dispatch path is always available in production. +- **Source**: [agent:backend-architect] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 26e05dd..24385a8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -62,7 +62,7 @@ func main() { // Phase 1 stores projectStore := store.NewProjectStore(db) requirementStore := store.NewRequirementStore(db) - taskStore := store.NewTaskStore(db) + taskStore := store.NewTaskStoreWithDialect(db, dialect) documentStore := store.NewDocumentStore(db) // Phase 2 stores @@ -145,7 +145,8 @@ func main() { WithProjectStore(projectStore). WithNotificationStore(notificationStore). WithContextBuilder(planning.NewProjectContextBuilder(taskStore, documentStore, driftSignalStore, syncRunStore, agentRunStore)). - WithAccountBindingStore(accountBindingStore) + WithAccountBindingStore(accountBindingStore). + WithTaskStore(taskStore) // Phase 4 handlers notificationBroker := events.NewBroker() diff --git a/backend/db/migrations/029_task_dispatch.down.sql b/backend/db/migrations/029_task_dispatch.down.sql new file mode 100644 index 0000000..141c37f --- /dev/null +++ b/backend/db/migrations/029_task_dispatch.down.sql @@ -0,0 +1,5 @@ +-- SQLite >= 3.35 required for DROP COLUMN (released 2021-03-12). +-- Operators on older SQLite must rebuild the table manually to roll back. +DROP INDEX IF EXISTS idx_tasks_dispatch_status; +ALTER TABLE tasks DROP COLUMN execution_result; +ALTER TABLE tasks DROP COLUMN dispatch_status; diff --git a/backend/db/migrations/029_task_dispatch.sql b/backend/db/migrations/029_task_dispatch.sql new file mode 100644 index 0000000..b06c113 --- /dev/null +++ b/backend/db/migrations/029_task_dispatch.sql @@ -0,0 +1,10 @@ +-- Phase 6b: add dispatch_status + execution_result to tasks. +-- dispatch_status tracks the connector execution lifecycle: +-- none → task was not created via role_dispatch (default) +-- queued → role_dispatch task waiting to be claimed +-- running → claimed by a connector +-- completed → connector returned success result +-- failed → connector returned failure +ALTER TABLE tasks ADD COLUMN dispatch_status TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE tasks ADD COLUMN execution_result JSONB; +CREATE INDEX idx_tasks_dispatch_status ON tasks(dispatch_status); diff --git a/backend/internal/connector/client.go b/backend/internal/connector/client.go index ec5de99..161f651 100644 --- a/backend/internal/connector/client.go +++ b/backend/internal/connector/client.go @@ -66,6 +66,24 @@ func (c *Client) SubmitRunResult(ctx context.Context, planningRunID string, req return &run, nil } +// ClaimNextTask calls POST /api/connector/claim-next-task and returns the +// next queued role_dispatch task for this connector's user, or nil when the +// queue is empty. Phase 6b. +func (c *Client) ClaimNextTask(ctx context.Context) (*ClaimNextTaskResponse, error) { + var resp ClaimNextTaskResponse + if err := c.doJSON(ctx, http.MethodPost, "/api/connector/claim-next-task", c.ConnectorToken, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// SubmitTaskResult calls POST /api/connector/tasks/:task_id/execution-result. +// Phase 6b. +func (c *Client) SubmitTaskResult(ctx context.Context, taskID string, req SubmitTaskResultRequest) error { + path := "/api/connector/tasks/" + strings.TrimSpace(taskID) + "/execution-result" + return c.doJSON(ctx, http.MethodPost, path, c.ConnectorToken, req, nil) +} + func (c *Client) doJSON(ctx context.Context, method, path, connectorToken string, requestBody any, responseBody any) error { if c == nil { return fmt.Errorf("connector client is required") diff --git a/backend/internal/connector/dispatch.go b/backend/internal/connector/dispatch.go new file mode 100644 index 0000000..637d185 --- /dev/null +++ b/backend/internal/connector/dispatch.go @@ -0,0 +1,38 @@ +package connector + +// dispatch.go defines the Phase 6b role_dispatch wire types used by the +// connector client (client.go) and the connector service (service.go). +// They mirror the handler types in internal/handlers/connector_dispatch.go but +// live in this package so the connector binary compiles without importing the +// HTTP handler layer. + +import ( + "encoding/json" + + "github.com/screenleon/agent-native-pm/internal/models" +) + +// ClaimNextTaskResponse is the payload returned by POST /api/connector/claim-next-task. +// Task is nil when the queue is empty. +type ClaimNextTaskResponse struct { + Task *models.Task `json:"task"` + Requirement *ConnectorRequirementSummary `json:"requirement,omitempty"` + ProjectContext string `json:"project_context,omitempty"` +} + +// ConnectorRequirementSummary is the slim requirement view included in the +// claim-next-task response so the connector can build the role prompt context. +type ConnectorRequirementSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` +} + +// SubmitTaskResultRequest is the request body for +// POST /api/connector/tasks/:task_id/execution-result. +type SubmitTaskResultRequest struct { + Success bool `json:"success"` + Result json.RawMessage `json:"result,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` +} diff --git a/backend/internal/connector/service.go b/backend/internal/connector/service.go index d938ce1..7b70e80 100644 --- a/backend/internal/connector/service.go +++ b/backend/internal/connector/service.go @@ -13,6 +13,7 @@ import ( "time" "github.com/screenleon/agent-native-pm/internal/models" + "github.com/screenleon/agent-native-pm/internal/prompts" ) // cliInterpreterBlocklist is the set of bare command names that must NOT be @@ -140,6 +141,16 @@ func (s *Service) Run(ctx context.Context) error { } else { lastError = "" } + // Phase 6b: when the planning-run queue is idle, also try the task + // dispatch queue. Both loops share the same poll/sleep cadence. + if !worked { + workedTask, taskErr := s.RunOnceTask(ctx) + if taskErr != nil { + lastError = taskErr.Error() + fmt.Fprintf(s.Stderr, "task dispatch loop error: %s\n", lastError) + } + worked = workedTask + } if worked { continue } @@ -151,6 +162,179 @@ func (s *Service) Run(ctx context.Context) error { } } +// RunOnceTask implements the Phase 6b role_dispatch execution loop. It claims +// one queued task, renders the role prompt, invokes the CLI, and submits the +// result. Returns (true, nil) when a task was processed (regardless of +// success/failure of the task itself), (false, nil) when the queue is empty, +// and (false, err) on infrastructure errors. +func (s *Service) RunOnceTask(ctx context.Context) (bool, error) { + resp, err := s.Client.ClaimNextTask(ctx) + if err != nil { + return false, err + } + if resp == nil || resp.Task == nil { + return false, nil + } + task := resp.Task + + // Parse role_id from source ("role_dispatch:backend-architect" → "backend-architect"). + roleID := "" + if after, ok := strings.CutPrefix(task.Source, "role_dispatch:"); ok { + roleID = strings.TrimSpace(after) + } + if roleID == "" { + fmt.Fprintf(s.Stderr, "task %s has invalid source %q — missing role_id\n", task.ID, task.Source) + _ = s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: false, + ErrorMessage: fmt.Sprintf("invalid task source %q: missing role_id", task.Source), + ErrorKind: "unknown", + }) + return true, nil + } + + // Catalog enforcement: role must exist in the embedded prompt library. + if !prompts.Exists("roles/" + roleID) { + fmt.Fprintf(s.Stderr, "task %s: role %q not found in catalog\n", task.ID, roleID) + _ = s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: false, + ErrorMessage: fmt.Sprintf("role %q not found in catalog", roleID), + ErrorKind: "unknown", + }) + return true, nil + } + + fmt.Fprintf(s.Stdout, "claimed task %s (role=%s title=%q)\n", task.ID, roleID, task.Title) + + // Resolve CLI. Use the connector's primary CLI config if available (the + // connector state does not carry a binding for task dispatch, so we + // construct a nil selection and let resolveBuiltinCLI fall back to env / + // PATH lookup, which is the correct behaviour for role_dispatch). + // The model is read from task or env; there is no per-task ModelID field + // in Phase 6b so we pass nil run. + _, cliPath, cliModel, _, resolveErr := resolveBuiltinCLI(nil, nil) + if resolveErr != "" { + fmt.Fprintf(s.Stderr, "task %s: CLI resolve failed: %s\n", task.ID, resolveErr) + _ = s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: false, + ErrorMessage: resolveErr, + ErrorKind: "adapter_timeout", + }) + return true, nil + } + + // Build template vars. + vars := map[string]string{ + "TASK_TITLE": strings.TrimSpace(task.Title), + "TASK_DESCRIPTION": strings.TrimSpace(task.Description), + "REQUIREMENT": buildConnectorRequirementContext(resp.Requirement), + "PROJECT_CONTEXT": strings.TrimSpace(resp.ProjectContext), + } + + // Render role prompt. + rendered, renderErr := prompts.Render("roles/"+roleID, vars) + if renderErr != nil { + fmt.Fprintf(s.Stderr, "task %s: prompt render failed: %v\n", task.ID, renderErr) + _ = s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: false, + ErrorMessage: fmt.Sprintf("prompt render error: %v", renderErr), + ErrorKind: "unknown", + }) + return true, nil + } + + // Read timeout. + timeoutSec := builtinDefaultTimeoutSec + + // Invoke CLI. + output, runErrMsg := invokeBuiltinCLI(ctx, resolveAgentFromBinary(cliPath), cliPath, cliModel, rendered, timeoutSec) + if runErrMsg != "" { + errKind := classifyRunError(runErrMsg) + fmt.Fprintf(s.Stderr, "task %s: CLI failed: %s\n", task.ID, runErrMsg) + _ = s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: false, + ErrorMessage: runErrMsg, + ErrorKind: errKind, + }) + return true, nil + } + + output = stripANSI(output) + + // Extract JSON from output. + parsed, extractErr := extractJSONFromOutput(output) + if extractErr != nil { + snippet := strings.TrimSpace(output) + if len(snippet) > 240 { + snippet = snippet[:240] + } + errMsg := fmt.Sprintf("could not parse output as JSON: %v; first 240 chars: %s", extractErr, snippet) + fmt.Fprintf(s.Stderr, "task %s: %s\n", task.ID, errMsg) + _ = s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: false, + ErrorMessage: errMsg, + ErrorKind: "unknown", + }) + return true, nil + } + + // Re-marshal the full parsed map as the result payload. + resultBytes, _ := json.Marshal(parsed) + + if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{ + Success: true, + Result: json.RawMessage(resultBytes), + }); err != nil { + return true, fmt.Errorf("submit task result for %s: %w", task.ID, err) + } + fmt.Fprintf(s.Stdout, "completed task %s (role=%s)\n", task.ID, roleID) + return true, nil +} + +// buildConnectorRequirementContext formats the requirement summary for prompt injection. +func buildConnectorRequirementContext(req *ConnectorRequirementSummary) string { + if req == nil { + return "" + } + var parts []string + if t := strings.TrimSpace(req.Title); t != "" { + parts = append(parts, "Title: "+t) + } + if s := strings.TrimSpace(req.Summary); s != "" { + parts = append(parts, "Summary: "+s) + } + return strings.Join(parts, "\n") +} + +// resolveAgentFromBinary infers the agent name from the binary path. +func resolveAgentFromBinary(binary string) string { + base := strings.ToLower(filepath.Base(binary)) + switch { + case strings.HasPrefix(base, "claude"): + return "claude" + case strings.HasPrefix(base, "codex"): + return "codex" + default: + return "claude" + } +} + +// classifyRunError maps common error substrings to error_kind values. +func classifyRunError(msg string) string { + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "session") && strings.Contains(lower, "expired"): + return "session_expired" + case strings.Contains(lower, "rate limit"): + return "rate_limited" + case strings.Contains(lower, "context") && strings.Contains(lower, "overflow"): + return "context_overflow" + case strings.Contains(lower, "timed out"): + return "adapter_timeout" + default: + return "unknown" + } +} + func (s *Service) RunOnce(ctx context.Context) (bool, error) { claim, err := s.Client.ClaimNextRun(ctx) if err != nil { diff --git a/backend/internal/handlers/connector_dispatch.go b/backend/internal/handlers/connector_dispatch.go new file mode 100644 index 0000000..52d9670 --- /dev/null +++ b/backend/internal/handlers/connector_dispatch.go @@ -0,0 +1,185 @@ +package handlers + +// ConnectorDispatchHandler handles the Phase 6b task dispatch API: +// POST /api/connector/claim-next-task +// POST /api/connector/tasks/:task_id/execution-result +// +// Both endpoints require X-Connector-Token authentication (same as claim-next-run). +// The handler is wired onto LocalConnectorHandler so it reuses authenticatedConnector. + +import ( + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/screenleon/agent-native-pm/internal/models" + "github.com/screenleon/agent-native-pm/internal/store" +) + +// ClaimNextTaskResponse is returned by POST /api/connector/claim-next-task. +type ClaimNextTaskResponse struct { + Task *models.Task `json:"task"` + Requirement *RequirementSummary `json:"requirement,omitempty"` + ProjectContext string `json:"project_context,omitempty"` +} + +// RequirementSummary is a slim view of the requirement sent alongside the task. +type RequirementSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` +} + +// SubmitTaskResultRequest is the request body for POST …/execution-result. +type SubmitTaskResultRequest struct { + Success bool `json:"success"` + Result json.RawMessage `json:"result,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` +} + +// ClaimNextTask implements POST /api/connector/claim-next-task. +func (h *LocalConnectorHandler) ClaimNextTask(w http.ResponseWriter, r *http.Request) { + connector, ok := h.authenticatedConnector(w, r) + if !ok { + return + } + + if h.taskStore == nil { + writeError(w, http.StatusInternalServerError, "task dispatch store not configured") + return + } + + task, req, err := h.taskStore.ClaimNextDispatchTask(connector.ID, connector.UserID) + if err != nil { + log.Printf("claim-next-task: error for connector %s: %v", connector.ID, err) + writeError(w, http.StatusInternalServerError, "failed to claim next dispatch task") + return + } + if task == nil { + // Queue empty — return {"task": null}. + writeSuccess(w, http.StatusOK, ClaimNextTaskResponse{}, nil) + return + } + + resp := ClaimNextTaskResponse{Task: task} + if req != nil { + resp.Requirement = &RequirementSummary{ + ID: req.ID, + Title: req.Title, + Summary: req.Summary, + } + resp.ProjectContext = buildDispatchProjectContext(req) + } + + writeSuccess(w, http.StatusOK, resp, nil) +} + +// SubmitTaskResult implements POST /api/connector/tasks/:task_id/execution-result. +func (h *LocalConnectorHandler) SubmitTaskResult(w http.ResponseWriter, r *http.Request) { + connector, ok := h.authenticatedConnector(w, r) + if !ok { + return + } + + taskID := chi.URLParam(r, "task_id") + if strings.TrimSpace(taskID) == "" { + writeError(w, http.StatusBadRequest, "task_id is required") + return + } + + if h.taskStore == nil { + writeError(w, http.StatusInternalServerError, "task dispatch store not configured") + return + } + + var req SubmitTaskResultRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Verify connector has ownership over this task. + task, err := h.taskStore.GetTaskForConnector(taskID, connector.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify task ownership") + return + } + if task == nil { + writeError(w, http.StatusNotFound, "task not found") + return + } + + // Validate that the task is currently running (not already settled). + if task.DispatchStatus != models.TaskDispatchStatusRunning { + writeError(w, http.StatusBadRequest, "task is not in running state") + return + } + + // Normalize error_kind (same allowlist as planning-runs). + errorKind := strings.TrimSpace(req.ErrorKind) + if errorKind != "" && !models.AllowedErrorKinds[errorKind] { + errorKind = models.ErrorKindUnknown + } + + if req.Success { + if err := h.taskStore.CompleteDispatchTask(taskID, connector.UserID, req.Result); err != nil { + if errors.Is(err, store.ErrDispatchOwnership) { + writeError(w, http.StatusForbidden, "connector does not own this task") + return + } + writeError(w, http.StatusInternalServerError, "failed to complete task") + return + } + } else { + errMsg := strings.TrimSpace(req.ErrorMessage) + if errMsg == "" { + errMsg = "execution failed" + } + if errorKind != "" && errorKind != models.ErrorKindUnknown { + errMsg = errMsg + " [" + errorKind + "]" + } + if err := h.taskStore.FailDispatchTask(taskID, connector.UserID, errMsg); err != nil { + if errors.Is(err, store.ErrDispatchOwnership) { + writeError(w, http.StatusForbidden, "connector does not own this task") + return + } + writeError(w, http.StatusInternalServerError, "failed to fail task") + return + } + } + + // Return the updated task. + updated, err := h.taskStore.GetByID(taskID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to reload task") + return + } + writeSuccess(w, http.StatusOK, updated, nil) +} + +// buildDispatchProjectContext produces a compact text summary of the requirement +// that the role prompt can inject as PROJECT_CONTEXT. +func buildDispatchProjectContext(req *models.Requirement) string { + if req == nil { + return "" + } + var parts []string + if t := strings.TrimSpace(req.Title); t != "" { + parts = append(parts, "Requirement: "+t) + } + if d := strings.TrimSpace(req.Description); d != "" { + parts = append(parts, "Description: "+d) + } + if a := strings.TrimSpace(req.Audience); a != "" { + parts = append(parts, "Audience: "+a) + } + if s := strings.TrimSpace(req.SuccessCriteria); s != "" { + parts = append(parts, "Success criteria: "+s) + } + return strings.Join(parts, "\n") +} diff --git a/backend/internal/handlers/connector_dispatch_test.go b/backend/internal/handlers/connector_dispatch_test.go new file mode 100644 index 0000000..ae88e0f --- /dev/null +++ b/backend/internal/handlers/connector_dispatch_test.go @@ -0,0 +1,341 @@ +package handlers_test + +// connector_dispatch_test.go covers the Phase 6b task dispatch endpoints: +// POST /api/connector/claim-next-task +// POST /api/connector/tasks/:task_id/execution-result +// +// Test IDs are prefixed T-6b to match the Phase 6b DoD. + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/screenleon/agent-native-pm/internal/database" + "github.com/screenleon/agent-native-pm/internal/handlers" + "github.com/screenleon/agent-native-pm/internal/middleware" + "github.com/screenleon/agent-native-pm/internal/models" + "github.com/screenleon/agent-native-pm/internal/planning" + "github.com/screenleon/agent-native-pm/internal/router" + "github.com/screenleon/agent-native-pm/internal/store" + "github.com/screenleon/agent-native-pm/internal/testutil" +) + +// hashConnectorToken mimics the store's hashSecret function for test token insertion. +func hashConnectorToken(token string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(token))) + return hex.EncodeToString(sum[:]) +} + +// dispatchFixture wires up all the stores needed to test the dispatch endpoints. +type dispatchFixture struct { + srv http.Handler + db *sql.DB + dialect database.Dialect + taskStore *store.TaskStore + localConnectorStore *store.LocalConnectorStore + requirementStore *store.RequirementStore + candidateStore *store.BacklogCandidateStore + projectStore *store.ProjectStore + // ownerUserID is the project/task owner (linked to connector). + ownerUserID string + // otherUserID owns nothing. + otherUserID string + projectID string +} + +func newDispatchFixture(t *testing.T) *dispatchFixture { + t.Helper() + db := testutil.OpenTestDB(t) + dialect := testutil.TestDialect() + + // Seed two users. + mustExec(t, db, `INSERT INTO users (id, username, email, password_hash, role, is_active) + VALUES ('owner-user', 'owner', 'owner@test.com', '', 'member', TRUE)`) + mustExec(t, db, `INSERT INTO users (id, username, email, password_hash, role, is_active) + VALUES ('other-user', 'other', 'other@test.com', '', 'member', TRUE)`) + + // Owner creates the project, then is added as a project_member. + projects := store.NewProjectStore(db) + project, err := projects.Create(models.CreateProjectRequest{Name: "Dispatch Test Project"}) + if err != nil { + t.Fatalf("create project: %v", err) + } + // Add owner-user as project member (project_members table controls ownership). + memberID := fmt.Sprintf("pm-%s", project.ID) + mustExec(t, db, + `INSERT INTO project_members (id, project_id, user_id, role) VALUES ($1, $2, $3, 'owner')`, + memberID, project.ID, "owner-user", + ) + + rs := store.NewRequirementStore(db) + ts := store.NewTaskStoreWithDialect(db, dialect) + bcs := store.NewBacklogCandidateStore(db, dialect) + connectors := store.NewLocalConnectorStore(db, dialect) + agentRuns := store.NewAgentRunStore(db) + + planningRuns := store.NewPlanningRunStore(db, dialect) + planner := stubPlanner{} + planningHandler := handlers.NewPlanningRunHandler(planningRuns, bcs, projects, rs, agentRuns, planner). + WithLocalConnectorStore(connectors) + + localConnHandler := handlers.NewLocalConnectorHandler(connectors, planningRuns, rs, bcs, agentRuns). + WithTaskStore(ts) + + srv := router.New(router.Deps{ + PlanningRunHandler: planningHandler, + LocalConnectorHandler: localConnHandler, + AuthMiddleware: func(next http.Handler) http.Handler { + return next + }, + LocalModeMiddleware: middleware.InjectLocalAdmin, + }) + + return &dispatchFixture{ + srv: srv, + db: db, + dialect: dialect, + taskStore: ts, + localConnectorStore: connectors, + requirementStore: rs, + candidateStore: bcs, + projectStore: projects, + ownerUserID: "owner-user", + otherUserID: "other-user", + projectID: project.ID, + } +} + +func mustExec(t *testing.T, db *sql.DB, query string, args ...interface{}) { + t.Helper() + if _, err := db.Exec(query, args...); err != nil { + t.Fatalf("mustExec %q: %v", query, err) + } +} + +// seedConnectorForUser inserts a local connector with the given plaintext token for the given user. +// The token is stored as sha256 hash matching the store's hashSecret function. +func (fx *dispatchFixture) seedConnectorForUser(t *testing.T, connectorID, userID, token string) { + t.Helper() + now := time.Now().UTC() + mustExec(t, fx.db, + `INSERT INTO local_connectors (id, user_id, label, platform, client_version, status, capabilities, protocol_version, token_hash, last_seen_at, last_error, created_at, updated_at) + VALUES ($1, $2, $3, '', '', $4, '{}', 1, $5, $6, '', $6, $6)`, + connectorID, userID, "test-connector", models.LocalConnectorStatusOnline, hashConnectorToken(token), now, + ) +} + +// seedQueuedTask inserts a role_dispatch task in the queued state for the owner's project. +func (fx *dispatchFixture) seedQueuedTask(t *testing.T, title, roleID string) string { + t.Helper() + id := fmt.Sprintf("task-%d", time.Now().UnixNano()) + now := time.Now().UTC() + source := "role_dispatch:" + roleID + mustExec(t, fx.db, + `INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, dispatch_status, created_at, updated_at) + VALUES ($1, $2, $3, 'desc', 'todo', 'medium', '', $4, $5, $6, $6)`, + id, fx.projectID, title, source, models.TaskDispatchStatusQueued, now, + ) + return id +} + +func (fx *dispatchFixture) doClaimTask(connectorToken string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/connector/claim-next-task", nil) + req.Header.Set("X-Connector-Token", connectorToken) + rec := httptest.NewRecorder() + fx.srv.ServeHTTP(rec, req) + return rec +} + +func (fx *dispatchFixture) doSubmitResult(connectorToken, taskID string, body interface{}) *httptest.ResponseRecorder { + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, + "/api/connector/tasks/"+taskID+"/execution-result", + bytes.NewReader(raw), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Connector-Token", connectorToken) + rec := httptest.NewRecorder() + fx.srv.ServeHTTP(rec, req) + return rec +} + +// T-6b-1: claim with empty queue returns 200 task:null. +func TestClaimNextTask_EmptyQueue(t *testing.T) { + fx := newDispatchFixture(t) + fx.seedConnectorForUser(t, "conn-owner", fx.ownerUserID, "tok-owner") + + rec := fx.doClaimTask("tok-owner") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var env struct { + Data handlers.ClaimNextTaskResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Data.Task != nil { + t.Errorf("expected task=null, got task.ID=%s", env.Data.Task.ID) + } +} + +// T-6b-2: connector owned by the project owner claims a queued task → dispatch_status=running. +func TestClaimNextTask_OwnerClaimsTask(t *testing.T) { + fx := newDispatchFixture(t) + fx.seedConnectorForUser(t, "conn-owner2", fx.ownerUserID, "tok-owner2") + taskID := fx.seedQueuedTask(t, "Implement API", "backend-architect") + + rec := fx.doClaimTask("tok-owner2") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var env struct { + Data handlers.ClaimNextTaskResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Data.Task == nil { + t.Fatal("expected a task, got null") + } + if env.Data.Task.ID != taskID { + t.Errorf("expected task %s, got %s", taskID, env.Data.Task.ID) + } + + // Verify dispatch_status updated in DB. + task, err := fx.taskStore.GetByID(taskID) + if err != nil || task == nil { + t.Fatalf("reload task: %v", err) + } + if task.DispatchStatus != models.TaskDispatchStatusRunning { + t.Errorf("expected dispatch_status=running, got %s", task.DispatchStatus) + } +} + +// T-6b-3: connector owned by a different user → 200 task:null (no ownership). +func TestClaimNextTask_NonMemberGetsNull(t *testing.T) { + fx := newDispatchFixture(t) + // other-user has no project. + fx.seedConnectorForUser(t, "conn-other", fx.otherUserID, "tok-other") + fx.seedQueuedTask(t, "Implement UI", "ui-scaffolder") + + rec := fx.doClaimTask("tok-other") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var env struct { + Data handlers.ClaimNextTaskResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Data.Task != nil { + t.Errorf("expected task=null for non-member connector, got task.ID=%s", env.Data.Task.ID) + } +} + +// T-6b-4: submit success result → dispatch_status=completed. +func TestSubmitTaskResult_Success(t *testing.T) { + fx := newDispatchFixture(t) + fx.seedConnectorForUser(t, "conn-s4", fx.ownerUserID, "tok-s4") + taskID := fx.seedQueuedTask(t, "Write tests", "test-writer") + + // Claim first. + claimRec := fx.doClaimTask("tok-s4") + if claimRec.Code != http.StatusOK { + t.Fatalf("claim: %d %s", claimRec.Code, claimRec.Body.String()) + } + + result := map[string]interface{}{"files": []string{"foo_test.go"}} + rec := fx.doSubmitResult("tok-s4", taskID, handlers.SubmitTaskResultRequest{ + Success: true, + Result: mustMarshal(t, result), + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + task, _ := fx.taskStore.GetByID(taskID) + if task.DispatchStatus != models.TaskDispatchStatusCompleted { + t.Errorf("expected completed, got %s", task.DispatchStatus) + } + if len(task.ExecutionResult) == 0 { + t.Error("expected execution_result to be non-empty") + } +} + +// T-6b-5: submit failure → dispatch_status=failed. +func TestSubmitTaskResult_Failure(t *testing.T) { + fx := newDispatchFixture(t) + fx.seedConnectorForUser(t, "conn-s5", fx.ownerUserID, "tok-s5") + taskID := fx.seedQueuedTask(t, "Review code", "code-reviewer") + + claimRec := fx.doClaimTask("tok-s5") + if claimRec.Code != http.StatusOK { + t.Fatalf("claim: %d %s", claimRec.Code, claimRec.Body.String()) + } + + rec := fx.doSubmitResult("tok-s5", taskID, handlers.SubmitTaskResultRequest{ + Success: false, + ErrorMessage: "CLI crashed", + ErrorKind: "unknown", + }) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + task, _ := fx.taskStore.GetByID(taskID) + if task.DispatchStatus != models.TaskDispatchStatusFailed { + t.Errorf("expected failed, got %s", task.DispatchStatus) + } +} + +// T-6b-6: submit result for task not in running state → 400. +func TestSubmitTaskResult_NotRunningReturns400(t *testing.T) { + fx := newDispatchFixture(t) + fx.seedConnectorForUser(t, "conn-s6", fx.ownerUserID, "tok-s6") + // Insert task with dispatch_status=queued (not running) directly. + taskID := fx.seedQueuedTask(t, "DB schema", "db-schema-designer") + // Do NOT claim it — task stays queued. + + rec := fx.doSubmitResult("tok-s6", taskID, handlers.SubmitTaskResultRequest{ + Success: true, + Result: json.RawMessage(`{"ok":true}`), + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// T-6b-7: invalid connector token → 401. +func TestClaimNextTask_InvalidToken(t *testing.T) { + fx := newDispatchFixture(t) + rec := fx.doClaimTask("invalid-token-xyz") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +// --- helpers --- + +func mustMarshal(t *testing.T, v interface{}) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return json.RawMessage(b) +} + +// Ensure planning.DraftPlanner is satisfied — stubPlanner is defined in +// handlers_test.go in this same package. +var _ planning.DraftPlanner = stubPlanner{} diff --git a/backend/internal/handlers/local_connectors.go b/backend/internal/handlers/local_connectors.go index ec20c24..b2c67ee 100644 --- a/backend/internal/handlers/local_connectors.go +++ b/backend/internal/handlers/local_connectors.go @@ -31,6 +31,9 @@ type LocalConnectorHandler struct { // CLI binding row so the connector receives cli_command + model_id. Wired // in main.go via WithAccountBindingStore. bindings *store.AccountBindingStore + // taskStore is optional; when set the Phase 6b dispatch endpoints are + // functional. Wired in main.go via WithTaskStore. + taskStore *store.TaskStore } // WithAccountBindingStore allows the probe-binding handler to look up the @@ -42,6 +45,14 @@ func (h *LocalConnectorHandler) WithAccountBindingStore(bindings *store.AccountB return h } +// WithTaskStore wires the task store so the Phase 6b dispatch endpoints +// (claim-next-task, execution-result) are functional. When nil those two +// endpoints return 500. +func (h *LocalConnectorHandler) WithTaskStore(tasks *store.TaskStore) *LocalConnectorHandler { + h.taskStore = tasks + return h +} + func NewLocalConnectorHandler(s *store.LocalConnectorStore, planningRuns *store.PlanningRunStore, requirements *store.RequirementStore, candidates *store.BacklogCandidateStore, agentRuns *store.AgentRunStore) *LocalConnectorHandler { return &LocalConnectorHandler{store: s, planningRuns: planningRuns, requirements: requirements, candidates: candidates, agentRuns: agentRuns} } diff --git a/backend/internal/handlers/planning_runs.go b/backend/internal/handlers/planning_runs.go index 56f1507..c428dde 100644 --- a/backend/internal/handlers/planning_runs.go +++ b/backend/internal/handlers/planning_runs.go @@ -244,21 +244,18 @@ func (h *PlanningRunHandler) resolvePathBBinding(w http.ResponseWriter, r *http. Label: cfg.Label, IsPrimary: cfg.IsPrimary, } - // Reject mismatched adapter_type / model_override rather than - // silently overwriting them. A row that says - // "adapter_type=whatsnext" but persists a binding_snapshot with - // provider_id=cli:claude is internally inconsistent and confuses - // downstream auditing (Critic SHOULD-FIX #3 / Copilot review on - // PR #23). - if at := strings.TrimSpace(req.AdapterType); at != "" && at != cfg.ProviderID { - writeError(w, http.StatusBadRequest, "adapter_type does not match the cli_config provider_id") - return nil, nil, false + // adapter_type is a semantic planning type ("backlog", "whatsnext") + // and is independent of cfg.ProviderID ("cli:claude", "cli:codex"). + // Preserve the caller's intent; default to "backlog" if omitted. + // The provider identity is captured in the binding_snapshot.ProviderID + // — no need to overwrite adapter_type with it. + if strings.TrimSpace(req.AdapterType) == "" { + req.AdapterType = "backlog" } if mo := strings.TrimSpace(req.ModelOverride); mo != "" && cfg.ModelID != "" && mo != cfg.ModelID { writeError(w, http.StatusBadRequest, "model_override does not match the cli_config model_id") return nil, nil, false } - req.AdapterType = cfg.ProviderID req.ModelOverride = cfg.ModelID // Clear any caller-supplied account_binding_id so it does not get // persisted alongside the cli_config snapshot — otherwise the @@ -557,7 +554,14 @@ func (h *PlanningRunHandler) decorateProviderOptions(r *http.Request, options mo } options.PairedConnectorAvailable = true options.ActiveConnectorLabel = connector.Label - options.AvailableExecutionModes = append(options.AvailableExecutionModes, models.PlanningExecutionModeLocalConnector) + // local_connector goes first: for subscription-only users it is the + // only usable mode, so it should be the default (index 0). + // Users who have both a connector and an API key benefit from the + // local path too since it uses their existing CLI subscription. + options.AvailableExecutionModes = append( + []string{models.PlanningExecutionModeLocalConnector}, + options.AvailableExecutionModes..., + ) return options, nil } diff --git a/backend/internal/handlers/requirements.go b/backend/internal/handlers/requirements.go index be796e0..93d378c 100644 --- a/backend/internal/handlers/requirements.go +++ b/backend/internal/handlers/requirements.go @@ -105,6 +105,46 @@ func (h *RequirementHandler) Create(w http.ResponseWriter, r *http.Request) { writeSuccess(w, http.StatusCreated, requirement, nil) } +func (h *RequirementHandler) Delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + req, err := h.store.GetByID(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify requirement") + return + } + if req == nil { + writeError(w, http.StatusNotFound, "requirement not found") + return + } + if !requestAllowsProject(r, req.ProjectID) { + writeError(w, http.StatusForbidden, "api key not allowed for this project") + return + } + hasLineage, err := h.store.HasAppliedTasks(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check requirement lineage") + return + } + if hasLineage { + writeError(w, http.StatusConflict, "requirement has applied tasks and cannot be deleted; use archive instead") + return + } + hasActive, err := h.store.HasActiveRun(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check active runs") + return + } + if hasActive { + writeError(w, http.StatusConflict, "requirement has an active planning run; wait for it to complete before deleting") + return + } + if err := h.store.Delete(id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete requirement") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *RequirementHandler) Update(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") requirement, err := h.store.GetByID(id) diff --git a/backend/internal/models/task.go b/backend/internal/models/task.go index a34394b..027f64c 100644 --- a/backend/internal/models/task.go +++ b/backend/internal/models/task.go @@ -1,18 +1,32 @@ package models -import "time" +import ( + "encoding/json" + "time" +) + +// Task dispatch status constants. Phase 6b. +const ( + TaskDispatchStatusNone = "none" + TaskDispatchStatusQueued = "queued" + TaskDispatchStatusRunning = "running" + TaskDispatchStatusCompleted = "completed" + TaskDispatchStatusFailed = "failed" +) type Task struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Title string `json:"title"` - Description string `json:"description"` - Status string `json:"status"` - Priority string `json:"priority"` - Assignee string `json:"assignee"` - Source string `json:"source"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + ProjectID string `json:"project_id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Source string `json:"source"` + DispatchStatus string `json:"dispatch_status"` + ExecutionResult json.RawMessage `json:"execution_result,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateTaskRequest struct { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b2135c0..9e936ed 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -92,6 +92,9 @@ func New(deps Deps) http.Handler { r.Post("/connector/heartbeat", deps.LocalConnectorHandler.Heartbeat) r.Post("/connector/claim-next-run", deps.LocalConnectorHandler.ClaimNextRun) r.Post("/connector/planning-runs/{id}/result", deps.LocalConnectorHandler.SubmitPlanningRunResult) + // Phase 6b: role_dispatch task execution loop. + r.Post("/connector/claim-next-task", deps.LocalConnectorHandler.ClaimNextTask) + r.Post("/connector/tasks/{task_id}/execution-result", deps.LocalConnectorHandler.SubmitTaskResult) } // ── Auth (public) ────────────────────────────────────────────── @@ -121,6 +124,7 @@ func New(deps Deps) http.Handler { r.Post("/projects/{id}/requirements", deps.RequirementHandler.Create) r.Get("/requirements/{id}", deps.RequirementHandler.Get) r.Patch("/requirements/{id}", deps.RequirementHandler.Update) + r.Delete("/requirements/{id}", deps.RequirementHandler.Delete) } if deps.PlanningRunHandler != nil { r.Get("/projects/{id}/planning-provider-options", deps.PlanningRunHandler.ProviderOptions) diff --git a/backend/internal/store/backlog_candidate_store.go b/backend/internal/store/backlog_candidate_store.go index b3243c0..961ce23 100644 --- a/backend/internal/store/backlog_candidate_store.go +++ b/backend/internal/store/backlog_candidate_store.go @@ -384,7 +384,7 @@ func (s *BacklogCandidateStore) ApplyToTaskWithMode(id, executionMode string) (* } } - if candidate.Status != models.BacklogCandidateStatusApproved && candidate.Status != models.BacklogCandidateStatusApplied { + if candidate.Status == models.BacklogCandidateStatusRejected { return nil, ErrBacklogCandidateNotApproved } @@ -593,13 +593,25 @@ func cloneEvidenceDetail(detail models.PlanningEvidenceDetail) models.PlanningEv func scanTask(row rowScanner) (*models.Task, error) { var task models.Task - err := row.Scan(&task.ID, &task.ProjectID, &task.Title, &task.Description, &task.Status, &task.Priority, &task.Assignee, &task.Source, &task.CreatedAt, &task.UpdatedAt) + var executionResultRaw sql.NullString + err := row.Scan( + &task.ID, &task.ProjectID, &task.Title, &task.Description, + &task.Status, &task.Priority, &task.Assignee, &task.Source, + &task.DispatchStatus, &executionResultRaw, + &task.CreatedAt, &task.UpdatedAt, + ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } + if executionResultRaw.Valid && executionResultRaw.String != "" { + task.ExecutionResult = json.RawMessage(executionResultRaw.String) + } + if task.DispatchStatus == "" { + task.DispatchStatus = models.TaskDispatchStatusNone + } return &task, nil } @@ -729,7 +741,8 @@ func getTaskLineageByCandidateID(tx *sql.Tx, candidateID string) (*models.TaskLi func getTaskByID(tx *sql.Tx, id string) (*models.Task, error) { return scanTask( tx.QueryRow(` - SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at + SELECT id, project_id, title, description, status, priority, assignee, source, + dispatch_status, execution_result, created_at, updated_at FROM tasks WHERE id = $1 `, id), @@ -773,7 +786,8 @@ func (s *BacklogCandidateStore) lockCandidateApplyKey(tx *sql.Tx, projectID, nor func findOpenTaskByNormalizedTitle(tx *sql.Tx, projectID, normalizedTitle string) (*models.Task, error) { return scanTask( tx.QueryRow(` - SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at + SELECT id, project_id, title, description, status, priority, assignee, source, + dispatch_status, execution_result, created_at, updated_at FROM tasks WHERE project_id = $1 AND status IN ('todo', 'in_progress') @@ -791,6 +805,9 @@ func createAppliedCandidateTask(tx *sql.Tx, projectID, title, description string // createAppliedCandidateTaskWithSource is the Phase-5-aware variant. // Phase 4 callers go through createAppliedCandidateTask which pins the // source to the pre-existing AppliedCandidateTaskSource sentinel. +// +// Phase 6b: when source starts with "role_dispatch" the task is given +// dispatch_status = 'queued' so the connector polling loop can claim it. func createAppliedCandidateTaskWithSource(tx *sql.Tx, projectID, title, description, source string) (*models.Task, error) { id := uuid.New().String() now := time.Now().UTC() @@ -801,25 +818,32 @@ func createAppliedCandidateTaskWithSource(tx *sql.Tx, projectID, title, descript trimmedSource = appliedCandidateTaskSource } + // Phase 6b: role_dispatch tasks enter the connector queue immediately. + dispatchStatus := models.TaskDispatchStatusNone + if strings.HasPrefix(trimmedSource, "role_dispatch") { + dispatchStatus = models.TaskDispatchStatusQueued + } + _, err := tx.Exec(` - INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) - `, id, projectID, trimmedTitle, trimmedDescription, "todo", "medium", "", trimmedSource, now) + INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, dispatch_status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) + `, id, projectID, trimmedTitle, trimmedDescription, "todo", "medium", "", trimmedSource, dispatchStatus, now) if err != nil { return nil, err } return &models.Task{ - ID: id, - ProjectID: projectID, - Title: trimmedTitle, - Description: trimmedDescription, - Status: "todo", - Priority: "medium", - Assignee: "", - Source: trimmedSource, - CreatedAt: now, - UpdatedAt: now, + ID: id, + ProjectID: projectID, + Title: trimmedTitle, + Description: trimmedDescription, + Status: "todo", + Priority: "medium", + Assignee: "", + Source: trimmedSource, + DispatchStatus: dispatchStatus, + CreatedAt: now, + UpdatedAt: now, }, nil } diff --git a/backend/internal/store/requirement_store.go b/backend/internal/store/requirement_store.go index c652dbc..4b4065d 100644 --- a/backend/internal/store/requirement_store.go +++ b/backend/internal/store/requirement_store.go @@ -171,3 +171,37 @@ func (s *RequirementStore) PromoteToPlannedIfDraft(id string) error { `, now, id) return err } + +// HasAppliedTasks returns true if any task_lineage row references this requirement. +func (s *RequirementStore) HasAppliedTasks(id string) (bool, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM task_lineage WHERE requirement_id = $1`, id).Scan(&count) + return count > 0, err +} + +// HasActiveRun returns true if any planning run for this requirement is in an active state +// (status = queued/running or dispatch_status = queued/leased). +func (s *RequirementStore) HasActiveRun(id string) (bool, error) { + var count int + err := s.db.QueryRow(` + SELECT COUNT(*) FROM planning_runs + WHERE requirement_id = $1 + AND (status IN ('queued', 'running') OR dispatch_status IN ('queued', 'leased')) + `, id).Scan(&count) + return count > 0, err +} + +// Delete permanently removes a requirement and cascades to planning_runs and backlog_candidates. +// Callers must verify HasAppliedTasks and HasActiveRun return false before calling this. +func (s *RequirementStore) Delete(id string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + _, err = tx.Exec(`DELETE FROM requirements WHERE id = $1`, id) + if err != nil { + return err + } + return tx.Commit() +} diff --git a/backend/internal/store/task_store.go b/backend/internal/store/task_store.go index 2d056ac..48baafa 100644 --- a/backend/internal/store/task_store.go +++ b/backend/internal/store/task_store.go @@ -2,26 +2,71 @@ package store import ( "database/sql" + "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" + "github.com/screenleon/agent-native-pm/internal/database" "github.com/screenleon/agent-native-pm/internal/models" ) type TaskStore struct { - db *sql.DB + db *sql.DB + dialect database.Dialect } var ErrTaskBatchNotFound = errors.New("one or more tasks not found in project") var ErrTaskBatchEmpty = errors.New("task batch update requires at least one task id") +// ErrDispatchOwnership is returned when a connector tries to operate on a task +// it does not own (via the project membership check). +var ErrDispatchOwnership = errors.New("connector does not have ownership over this task") + func NewTaskStore(db *sql.DB) *TaskStore { return &TaskStore{db: db} } +// NewTaskStoreWithDialect creates a TaskStore with an explicit dialect, required +// for the transaction-based dispatch methods. +func NewTaskStoreWithDialect(db *sql.DB, dialect database.Dialect) *TaskStore { + return &TaskStore{db: db, dialect: dialect} +} + +// taskColumns is the canonical column list for scanning a full Task row. +// Keep in sync with scanTask / scanTaskFull. +const taskColumns = `id, project_id, title, description, status, priority, assignee, source, + dispatch_status, execution_result, created_at, updated_at` + +// scanTaskFull scans all columns including the Phase 6b dispatch fields. +func scanTaskFull(row interface { + Scan(dest ...interface{}) error +}) (*models.Task, error) { + var t models.Task + var executionResultRaw sql.NullString + err := row.Scan( + &t.ID, &t.ProjectID, &t.Title, &t.Description, + &t.Status, &t.Priority, &t.Assignee, &t.Source, + &t.DispatchStatus, &executionResultRaw, + &t.CreatedAt, &t.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if executionResultRaw.Valid && executionResultRaw.String != "" { + t.ExecutionResult = json.RawMessage(executionResultRaw.String) + } + if t.DispatchStatus == "" { + t.DispatchStatus = models.TaskDispatchStatusNone + } + return &t, nil +} + func (s *TaskStore) ListByProject(projectID string, page, perPage int, sort, order string, filters models.TaskListFilters) ([]models.Task, int, error) { whereClause, filterArgs, nextPos := buildTaskListWhereClause(projectID, filters) @@ -72,9 +117,9 @@ func (s *TaskStore) ListByProject(projectID string, page, perPage int, sort, ord orderClause := fmt.Sprintf("ORDER BY %s %s", sort, order) queryArgs := append(append([]interface{}{}, filterArgs...), perPage, offset) query := fmt.Sprintf(` - SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at + SELECT %s FROM tasks %s %s LIMIT $%d OFFSET $%d - `, whereClause, orderClause, nextPos, nextPos+1) + `, taskColumns, whereClause, orderClause, nextPos, nextPos+1) rows, err := s.db.Query(query, queryArgs...) if err != nil { return nil, 0, err @@ -83,11 +128,13 @@ func (s *TaskStore) ListByProject(projectID string, page, perPage int, sort, ord var tasks []models.Task for rows.Next() { - var t models.Task - if err := rows.Scan(&t.ID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.Priority, &t.Assignee, &t.Source, &t.CreatedAt, &t.UpdatedAt); err != nil { + t, err := scanTaskFull(rows) + if err != nil { return nil, 0, err } - tasks = append(tasks, t) + if t != nil { + tasks = append(tasks, *t) + } } return tasks, total, rows.Err() } @@ -117,18 +164,9 @@ func buildTaskListWhereClause(projectID string, filters models.TaskListFilters) } func (s *TaskStore) GetByID(id string) (*models.Task, error) { - var t models.Task - err := s.db.QueryRow(` - SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at - FROM tasks WHERE id = $1 - `, id).Scan(&t.ID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.Priority, &t.Assignee, &t.Source, &t.CreatedAt, &t.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &t, nil + return scanTaskFull(s.db.QueryRow( + `SELECT `+taskColumns+` FROM tasks WHERE id = $1`, id, + )) } func (s *TaskStore) Create(projectID string, req models.CreateTaskRequest) (*models.Task, error) { @@ -262,11 +300,13 @@ func (s *TaskStore) BatchUpdate(projectID string, taskIDs []string, changes mode byID := make(map[string]models.Task, len(normalizedIDs)) for rows.Next() { - var task models.Task - if err := rows.Scan(&task.ID, &task.ProjectID, &task.Title, &task.Description, &task.Status, &task.Priority, &task.Assignee, &task.Source, &task.CreatedAt, &task.UpdatedAt); err != nil { + t, err := scanTaskFull(rows) + if err != nil { return nil, err } - byID[task.ID] = task + if t != nil { + byID[t.ID] = *t + } } if err := rows.Err(); err != nil { return nil, err @@ -311,6 +351,197 @@ func (s *TaskStore) CountByProjectAndStatus(projectID string) (map[string]int, e return counts, rows.Err() } +// ─── Phase 6b: dispatch methods ────────────────────────────────────────────── + +// ClaimNextDispatchTask atomically finds the next queued role_dispatch task +// belonging to a project the connector's user owns, marks it as "running", +// and returns the task together with its requirement. +// +// Ownership check: the task's project_id must appear in a project where +// user_id = connectorUserID (checked via the projects table direct ownership +// column — this project assumes a single-owner model per project). +// +// Returns (nil, nil, nil) when the queue is empty for this connector's user. +func (s *TaskStore) ClaimNextDispatchTask(connectorID, connectorUserID string) (*models.Task, *models.Requirement, error) { + // Use BEGIN IMMEDIATE on SQLite to get a write lock immediately so no two + // connectors can race to claim the same task. On Postgres the FOR UPDATE + // inside the transaction achieves the same. + var tx *sql.Tx + var err error + if s.dialect.IsSQLite() { + tx, err = s.db.Begin() + } else { + tx, err = s.db.Begin() + } + if err != nil { + return nil, nil, fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // On SQLite force an immediate write lock before the SELECT so concurrent + // claim attempts serialise (same pattern as lockCandidateApplyKey). + if s.dialect.IsSQLite() { + if _, err := tx.Exec(`UPDATE tasks SET updated_at = updated_at WHERE 1 = 0`); err != nil { + return nil, nil, fmt.Errorf("sqlite write lock: %w", err) + } + } + + // Find the oldest queued role_dispatch task in any project the connector's + // user is a member of (via project_members). + forUpdate := s.dialect.ForUpdate() + skipLocked := s.dialect.SkipLocked() + query := fmt.Sprintf(` + SELECT t.id, t.project_id, t.title, t.description, t.status, t.priority, + t.assignee, t.source, t.dispatch_status, t.execution_result, + t.created_at, t.updated_at + FROM tasks t + INNER JOIN project_members pm ON pm.project_id = t.project_id + WHERE t.dispatch_status = $1 + AND pm.user_id = $2 + AND t.source LIKE $3 + ORDER BY t.created_at ASC, t.id ASC + LIMIT 1 + %s%s + `, forUpdate, skipLocked) + + row := tx.QueryRow(query, models.TaskDispatchStatusQueued, connectorUserID, "role_dispatch%") + task, err := scanTaskFull(row) + if err != nil { + return nil, nil, fmt.Errorf("query queued task: %w", err) + } + if task == nil { + // Queue empty — commit and return nil. + _ = tx.Commit() + return nil, nil, nil + } + + // Mark as running. + now := time.Now().UTC() + if _, err := tx.Exec( + `UPDATE tasks SET dispatch_status = $1, updated_at = $2 WHERE id = $3`, + models.TaskDispatchStatusRunning, now, task.ID, + ); err != nil { + return nil, nil, fmt.Errorf("mark task running: %w", err) + } + task.DispatchStatus = models.TaskDispatchStatusRunning + task.UpdatedAt = now + + // Load the requirement via task_lineage so we can return context. + var req *models.Requirement + req, err = getRequirementForTask(tx, task.ID) + if err != nil { + // Non-fatal: we can proceed without requirement context. + req = nil + } + + if err := tx.Commit(); err != nil { + return nil, nil, fmt.Errorf("commit claim: %w", err) + } + return task, req, nil +} + +// getRequirementForTask joins task_lineage → requirements to find the +// requirement associated with the task. +func getRequirementForTask(tx *sql.Tx, taskID string) (*models.Requirement, error) { + var req models.Requirement + var summary, description, source, audience, successCriteria sql.NullString + err := tx.QueryRow(` + SELECT r.id, r.project_id, COALESCE(r.title,''), COALESCE(r.summary,''), + COALESCE(r.description,''), COALESCE(r.status,''), COALESCE(r.source,''), + COALESCE(r.audience,''), COALESCE(r.success_criteria,''), + r.created_at, r.updated_at + FROM requirements r + INNER JOIN task_lineage tl ON tl.requirement_id = r.id + WHERE tl.task_id = $1 + ORDER BY tl.created_at ASC + LIMIT 1 + `, taskID).Scan( + &req.ID, &req.ProjectID, &req.Title, &summary, + &description, &req.Status, &source, + &audience, &successCriteria, + &req.CreatedAt, &req.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if summary.Valid { + req.Summary = summary.String + } + if description.Valid { + req.Description = description.String + } + if source.Valid { + req.Source = source.String + } + if audience.Valid { + req.Audience = audience.String + } + if successCriteria.Valid { + req.SuccessCriteria = successCriteria.String + } + return &req, nil +} + +// CompleteDispatchTask marks a task as completed and stores the result JSON. +// The connectorUserID parameter ensures cross-user protection via the project +// ownership check. +func (s *TaskStore) CompleteDispatchTask(taskID, connectorUserID string, result json.RawMessage) error { + return s.updateDispatchStatus(taskID, connectorUserID, models.TaskDispatchStatusCompleted, "", result) +} + +// FailDispatchTask marks a task as failed and records an error message in the +// result JSON. +func (s *TaskStore) FailDispatchTask(taskID, connectorUserID, errorMsg string) error { + errJSON, _ := json.Marshal(map[string]string{"error": errorMsg}) + return s.updateDispatchStatus(taskID, connectorUserID, models.TaskDispatchStatusFailed, "", json.RawMessage(errJSON)) +} + +func (s *TaskStore) updateDispatchStatus(taskID, connectorUserID, status, _ string, result json.RawMessage) error { + now := time.Now().UTC() + var resultStr *string + if len(result) > 0 { + str := string(result) + resultStr = &str + } + res, err := s.db.Exec(` + UPDATE tasks + SET dispatch_status = $1, + execution_result = $2, + updated_at = $3 + WHERE id = $4 + AND project_id IN ( + SELECT project_id FROM project_members WHERE user_id = $5 + ) + `, status, resultStr, now, taskID, connectorUserID) + if err != nil { + return fmt.Errorf("update dispatch status: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrDispatchOwnership + } + return nil +} + +// GetTaskForConnector returns a task only if it is owned by the connector's +// user (via project_members membership). Used by the execution-result handler +// to verify the connector has rights before accepting a result. +func (s *TaskStore) GetTaskForConnector(taskID, connectorUserID string) (*models.Task, error) { + return scanTaskFull(s.db.QueryRow(` + SELECT t.id, t.project_id, t.title, t.description, t.status, t.priority, + t.assignee, t.source, t.dispatch_status, t.execution_result, + t.created_at, t.updated_at + FROM tasks t + INNER JOIN project_members pm ON pm.project_id = t.project_id + WHERE t.id = $1 AND pm.user_id = $2 + `, taskID, connectorUserID)) +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + func normalizeTaskIDs(taskIDs []string) []string { seen := make(map[string]bool, len(taskIDs)) normalized := make([]string, 0, len(taskIDs)) @@ -370,7 +601,7 @@ func buildTaskBatchSelectQuery(projectID string, taskIDs []string) (string, []in args = append(args, id) } return fmt.Sprintf(` - SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at + SELECT `+taskColumns+` FROM tasks WHERE project_id = $1 AND id IN (%s) `, buildPositionalPlaceholders(2, len(taskIDs))), args } diff --git a/docs/api-surface.md b/docs/api-surface.md index 2224366..e515200 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -353,6 +353,8 @@ Source: `[agent:backend-architect]` | POST | `/api/connector/heartbeat` | Refresh connector presence using `X-Connector-Token` | | POST | `/api/connector/claim-next-run` | Lease the next queued local-connector planning run for the connector owner | | POST | `/api/connector/planning-runs/:id/result` | Return success or failure for one leased planning run | +| POST | `/api/connector/claim-next-task` | (Phase 6b) Claim the next queued role-dispatch task for the connector owner | +| POST | `/api/connector/tasks/:task_id/execution-result` | (Phase 6b) Submit execution result for a claimed task | Behavior: @@ -369,6 +371,8 @@ Behavior: - `POST /api/me/local-connectors/:id/probe-binding` (Phase 4 P4-4) is authenticated and takes `{ binding_id }`. The referenced binding MUST belong to the same user and MUST have a `cli:*` provider_id; otherwise 400/404 is returned. The server enqueues a pending-probe entry on the named connector's `metadata.pending_cli_probe_requests[]`. If a probe for the same binding is already in-flight on that connector, the existing `probe_id` is returned (idempotent). Returns `{ probe_id }`. If the connector's pending-probe list is already at the hard cap (64 entries, see `DECISIONS.md` 2026-04-24 "P4-4 probe pipeline"), the handler returns HTTP 429. - `GET /api/me/local-connectors/:id/probe-binding/:probe_id` (Phase 4 P4-4) returns `{ status: "pending" | "completed" | "not_found", result? }`. The `result` block is populated only when `status == "completed"` and matches the `CliProbeResult` shape. Stored results are retained for 24 hours; callers that poll past that window receive `not_found`. - Connector tokens are distinct from session tokens and API keys. +- `POST /api/connector/claim-next-task` (Phase 6b) requires `X-Connector-Token`. Claims one task with `dispatch_status = 'queued'` whose project has the connector's owner as a `project_members` row. Returns `{ task, requirement }` where `task` is null when the queue is empty. Sets the task's `dispatch_status = 'running'` atomically. +- `POST /api/connector/tasks/:task_id/execution-result` (Phase 6b) requires `X-Connector-Token`. Accepts `{ success, result?, error_message?, error_kind? }`. The task MUST already be in `dispatch_status = 'running'` (owned by the connector's user); otherwise 400. On success: `dispatch_status = 'completed'`, `execution_result` stored as JSON. On failure: `dispatch_status = 'failed'`. `error_kind` is validated against the same allowlist as planning runs (`session_expired`, `rate_limited`, `context_overflow`, `adapter_timeout`, `unknown`); values outside the list are normalised to `"unknown"`. ### Planning Settings diff --git a/docs/data-model.md b/docs/data-model.md index 8cc07ae..25bcc66 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -87,10 +87,19 @@ Notes: | `status` | TEXT | NOT NULL DEFAULT 'todo' | `todo`, `in_progress`, `done`, `cancelled` | | `priority` | TEXT | DEFAULT 'medium' | `low`, `medium`, `high` | | `assignee` | TEXT | DEFAULT '' | Human name or agent identifier | -| `source` | TEXT | DEFAULT '' | `human` or `agent:` | +| `source` | TEXT | DEFAULT '' | `human` or `agent:` or `role_dispatch:` | +| `dispatch_status` | TEXT | NOT NULL DEFAULT 'none' | (Phase 6b, migration 029) `none`, `queued`, `running`, `completed`, `failed` | +| `execution_result` | JSONB | | (Phase 6b, migration 029) Raw JSON result from connector execution; null until completed or failed | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | `updated_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | +Notes (tasks): +- `dispatch_status = 'queued'` is set automatically at task creation when `source` starts with `role_dispatch:`. +- `dispatch_status = 'running'` is set when a connector claims the task via `POST /api/connector/claim-next-task`. +- Terminal states (`completed`, `failed`) are set by `POST /api/connector/tasks/:task_id/execution-result`. +- Ownership check uses the `project_members` table (not `projects.user_id`); only connectors whose owner is a member of the task's project can claim or submit results. +- Added by migration `029_task_dispatch.sql`. + ## Planning Foundation Tables ### Table: `planning_settings` diff --git a/docs/phase6b-plan.md b/docs/phase6b-plan.md new file mode 100644 index 0000000..d5d2822 --- /dev/null +++ b/docs/phase6b-plan.md @@ -0,0 +1,193 @@ +# Phase 6b 計畫 — 關閉執行迴圈(Role Dispatch) + +**Status**: draft · 2026-04-25 · `[agent:feature-planner]` +**前置條件**: Phase 6a(PR #23, #24)已合併到 `main`。 + +--- + +## 1. 問題陳述 + +Phase 1 ~ 6a 完成了「要件 → Planning Run → 候補清單 → 審查 → Apply → Task」的前半迴圈。 + +Apply 時若選 `execution_mode: "role_dispatch"`,Task 的 `source` 被打上 `"role_dispatch:backend-architect"` 標記,但沒有任何程式去「執行」它。 + +Phase 5 DECISIONS.md 的明確約束: +> "Phase 6 MUST introduce catalog enforcement for execution_role before shipping auto-dispatch." +> "Phase 6 blocker: the dispatcher that consumes role prompts MUST treat {{TASK_DESCRIPTION}} and {{PROJECT_CONTEXT}} as untrusted." + +**結論**:Phase 6b 必要,是因為目前 `role_dispatch` task 是一個只有標籤、沒有執行的死路。不關閉這個迴圈,「AI agents 直接參與開發流程」的核心價值主張就沒有完成。 + +--- + +## 2. End State + +完成後的使用者操作路徑: +1. 在候補清單審查頁面,將某個候補設定 `execution_role = "backend-architect"` +2. Apply 時選擇 `execution_mode = "role_dispatch"` +3. Task 建立,source = `"role_dispatch:backend-architect"` +4. Connector 在輪詢 claim-next-run(planning run)之外,**額外輪詢** `claim-next-task` +5. Connector 拿到 task,讀取 `source` 得知角色,用 `prompts.Render("roles/backend-architect", vars)` 組 prompt +6. Connector 呼叫 Claude CLI 執行 prompt +7. Claude 回傳結構化 JSON(`files`, `test_instructions`, `risks`, `followups`) +8. Connector 把結果 POST 回伺服器 +9. 伺服器將 task `dispatch_status` 更新為 `completed`,並寫入 `execution_result` + `agent_runs` 記錄 +10. Task 卡片顯示執行結果(可展開的 result panel) +11. 人類審核後手動將 task 推進到 `done` + +--- + +## 3. Slice 計畫 + +### Slice B1:DB schema(0.5 天) + +**Scope**:Migration 029,在 `tasks` 表新增兩個欄位。 + +```sql +-- 029_task_dispatch.sql +ALTER TABLE tasks ADD COLUMN dispatch_status TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE tasks ADD COLUMN execution_result JSONB; +CREATE INDEX idx_tasks_dispatch_status ON tasks(dispatch_status); +``` + +`dispatch_status` 狀態機:`none` → `queued`(apply 時若 role_dispatch)→ `running`(claim 時)→ `completed`/`failed` + +**DoD**:Migration 通過 SQLite + PostgreSQL 測試,現有 task row 的 `dispatch_status = 'none'`。 + +--- + +### Slice B2:後端 Task Dispatch 端點(1.5 天) + +**新增端點**: + +``` +POST /api/connector/claim-next-task +POST /api/connector/tasks/:id/execution-result +``` + +**claim-next-task Response**: +```jsonc +{ + "task": { + "id": "uuid", + "title": "...", + "description": "...", + "source": "role_dispatch:backend-architect", + "dispatch_status": "running" + }, + "requirement": { "id": "uuid", "title": "...", "summary": "..." }, + "project_context": "", + "cli_binding": { ... } // connector primary cli_config snapshot +} +``` + +**execution-result Request**: +```jsonc +{ + "success": true, + "error_message": "", + "error_kind": "session_expired", + "result": { "files": [...], "test_instructions": "...", "risks": [...], "followups": [...] } +} +``` + +**DoD**(9 test cases): + +| T-6b-B2-1 | claim:queue 為空 | 回傳 `{ task: null }` | +| T-6b-B2-2 | claim:有 queued task,connector 是 project member | task claimed,dispatch_status=running | +| T-6b-B2-3 | claim:connector user 不是 project member | 回傳空 | +| T-6b-B2-4 | claim:execution_role 不在 catalog | 回傳空(skip) | +| T-6b-B2-5 | execution-result success | dispatch_status=completed,execution_result 儲存 | +| T-6b-B2-6 | execution-result failure | dispatch_status=failed | +| T-6b-B2-7 | execution-result unknown error_kind | normalized to "unknown" | +| T-6b-B2-8 | execution-result:task 不屬於 connector user | 404 | +| T-6b-B2-9 | execution-result:task dispatch_status != "running" | 400 | + +--- + +### Slice B3:Connector Task 執行 loop(2 天) + +在 `connector/service.go` 的主 loop 中,新增 `RunOnceTask(ctx)` 方法: + +1. 呼叫 `client.ClaimNextTask()` +2. 解析 `task.source` 得到 `role_id`(`"role_dispatch:backend-architect"` → `"backend-architect"`) +3. Catalog enforcement:`prompts.Exists("roles/" + roleID)`,若不存在則送失敗結果 +4. 呼叫 `resolveBuiltinCLI()`(複用現有函式) +5. 建構 vars:`TASK_TITLE`, `TASK_DESCRIPTION`, `REQUIREMENT`, `PROJECT_CONTEXT` +6. 呼叫 `prompts.Render("roles/"+roleID, vars)` +7. 呼叫 `invokeBuiltinCLI()`(複用現有函式) +8. 呼叫 `extractJSONFromOutput()` 解析輸出 +9. POST 回 `tasks/:id/execution-result` + +**DoD**(6 test cases):RunOnceTask 的各種 CLI 結果、catalog missing、JSON parse 失敗等路徑。 + +--- + +### Slice B4:前端 Task card 執行結果 panel(1 天) + +- `GET /api/projects/:id/tasks` 回傳新欄位(`dispatch_status`, `execution_result`) +- TasksTab: + - `dispatch_status = "running"` → "In progress" badge + - `dispatch_status = "completed"` → result panel(files 清單、test_instructions、risks) + - `dispatch_status = "failed"` → error message + +--- + +### Slice B5:Apply UI 啟用 role_dispatch(0.5 天) + +- `CandidateReviewPanel` 移除 role_dispatch radio 的 `disabled` 狀態 +- 只有當 `execution_role` 有值且存在於 role catalog 時才可選 +- Apply 後 task `dispatch_status = "queued"` + +--- + +## 4. 實作順序 + +``` +B1 → B2(handler + store)→ B5(apply 端)→ B3(connector)→ B4(前端 result panel) +``` + +--- + +## 5. 非目標(Non-Goals) + +- Docker / sandbox(deferred) +- API key mode(只支援 local_connector) +- Per-task connector 選擇 +- Real-time push(SSE/WebSocket) +- Role prompt 輸出的 server-side schema 驗證 +- Task result 的 git 自動寫入 +- Retry logic +- 新增 role prompt + +--- + +## 6. 風險 + +| 風險 | 可能性 | 影響 | 緩解 | +|---|---|---|---| +| R1:PROJECT_CONTEXT 資訊密度不夠 | 中 | 中 | 重用 PlanningContextV1;Phase 6c 細化 | +| R2:claim-next-task ownership check 邏輯複雜 | 中 | 高 | 明確單元測試;參考 planning run ownership check | +| R3:role prompt 輸出各角色格式不一致 | 低 | 低 | server 儲存原始 JSONB,前端顯示通用欄位 | +| R4:connector 雙重 poll 負載 | 低 | 低 | RunOnceTask 只在 planning run idle 時才呼叫 | +| R5:TASK_DESCRIPTION prompt injection(Phase 5 D7 約束) | 低 | 高 | 明確記錄限制;不做 sandbox(本 phase) | + +--- + +## 7. Open Questions + +- Q1(阻擋 B3):`PROJECT_CONTEXT` 變數的組裝方式 — 重用 `PlanningContextV1` 還是輕量 summary?建議:重用 PlanningContextV1。 +- Q2:`dispatch_status = "queued"` 卡住時不做 expiry(Phase 6b 不做,人類手動 cancel)。 +- Q3:cli_binding 使用 connector primary cli_config(不支援 per-task 選擇)。 +- Q4:catalog enforcement 兩側都做(server skip + connector Exists 檢查)。 + +--- + +## 8. 狀態追蹤 + +| Slice | 狀態 | PR | +|---|---|---| +| B1 — DB migration 029 | pending | — | +| B2 — API endpoints | pending | — | +| B3 — Connector loop | pending | — | +| B4 — 前端 result panel | pending | — | +| B5 — Apply UI 啟用 | pending | — | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 24d4124..56faed4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -251,6 +251,10 @@ export async function updateRequirement(id: string, data: { status: string }) { }); } +export async function deleteRequirement(id: string): Promise { + await request(`/requirements/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + export async function getPlanningProviderOptions(projectId: string) { return request(`/projects/${encodeURIComponent(projectId)}/planning-provider-options`); } diff --git a/frontend/src/index.css b/frontend/src/index.css index b168f33..705a172 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -597,6 +597,160 @@ a:hover { margin-top: 1rem; } +/* Two-panel planning layout */ +.planning-welcome-view { + max-width: 640px; + padding: 2rem 0; + display: grid; + gap: 1.75rem; +} + +.planning-welcome-how { + display: grid; + gap: 0.4rem; +} + +.planning-welcome-steps { + margin: 0; + padding-left: 1.25rem; + display: grid; + gap: 0.35rem; + color: var(--text-muted); + font-size: 0.88rem; +} + +.planning-welcome-steps li strong { + color: var(--text); +} + +.planning-welcome-demo { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 0.5rem; + font-size: 0.88rem; +} + +.planning-welcome-input-area { + display: grid; + gap: 0.5rem; +} + +.planning-welcome-input-row { + display: flex; + gap: 0.6rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.planning-welcome-whatsnext { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 0.5rem; + font-size: 0.88rem; + color: var(--text-muted); +} + +.planning-two-panel { + display: grid; + grid-template-columns: 240px 1fr; + gap: 0; + align-items: start; +} + +.planning-sidebar { + border-right: 1px solid var(--border); + padding-right: 1rem; + display: flex; + flex-direction: column; + gap: 0; +} + +.planning-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.75rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.planning-sidebar-counts { + border-top: 1px solid var(--border); + padding-top: 0.6rem; + margin-top: 0.5rem; + display: grid; + gap: 0.35rem; +} + +.planning-sidebar-count { + display: flex; + align-items: center; + gap: 0.4rem; + background: none; + border: none; + cursor: pointer; + padding: 0.2rem 0; + font-size: 0.82rem; + color: var(--text-muted); + text-align: left; +} + +.planning-sidebar-count:hover { + color: var(--text); +} + +.planning-main { + padding-left: 1.5rem; + display: grid; + gap: 1rem; +} + +.planning-main-empty { + padding: 2rem 0; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; +} + +/* Inline form inside sidebar — removes card wrapper styling */ +.planning-inline-form { + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); + margin-bottom: 0.5rem; +} + +.planning-inline-form .form-group { + margin-bottom: 0.5rem; +} + +@media (max-width: 1000px) { + .planning-two-panel { + grid-template-columns: 1fr; + } + + .planning-sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: 1rem; + margin-bottom: 1rem; + } + + .planning-main { + padding-left: 0; + } +} + .planning-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); diff --git a/frontend/src/pages/ProjectDetail/PlanningTab.tsx b/frontend/src/pages/ProjectDetail/PlanningTab.tsx index f9d5756..b49d92c 100644 --- a/frontend/src/pages/ProjectDetail/PlanningTab.tsx +++ b/frontend/src/pages/ProjectDetail/PlanningTab.tsx @@ -1,7 +1,5 @@ +import { Link } from 'react-router-dom' import type { Requirement, Task } from '../../types' -import Jargon from '../../components/Jargon' -import { PlanningStepper } from './PlanningStepper' -import { AttentionRow } from './planning/AttentionRow' import { RequirementIntake } from './planning/RequirementIntake' import { RequirementQueue } from './planning/RequirementQueue' import { PlanningLauncher } from './planning/PlanningLauncher' @@ -37,9 +35,11 @@ interface PlanningTabProps { /** * Planning Workspace shell. Composes presentational siblings under * `pages/ProjectDetail/planning/`; all planning-domain state + effects + - * handlers live in `usePlanningWorkspaceData`. Per Phase 2 S1 acceptance - * criteria (§8 of docs/phase2-planning-workspace-design.md) this shell - * stays under 200 LOC; growth signals an architectural drift. + * handlers live in `usePlanningWorkspaceData`. + * + * Layout: + * - Empty project (requirements.length === 0): centered welcome view via WorkspaceOnboardingPanel + * - Non-empty project: two-panel layout (240px sidebar + flex main) */ export function PlanningTab({ projectId, @@ -56,6 +56,11 @@ export function PlanningTab({ onViewDocumentById, onViewDriftSignal, }: PlanningTabProps) { + // openDriftCount and onNavigateToDrift are kept in the props interface for + // parent compatibility (ProjectDetail passes them). They were previously used + // by AttentionRow which was removed in the two-panel redesign. + void openDriftCount + void onNavigateToDrift const ws = usePlanningWorkspaceData({ projectId, requirements, @@ -88,189 +93,241 @@ export function PlanningTab({ const el = document.querySelector(selector) as HTMLElement | null if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }) } - function focusSelector(selector: string) { - const el = document.querySelector(selector) as HTMLElement | null - if (el) el.focus() - } const jumpToRequirements = () => scrollToSelector('.requirement-list') const jumpToCandidates = () => scrollToSelector('.planning-candidate-panel') - const jumpToWorkspace = () => scrollToSelector('.planning-workspace-card') - const jumpToIntake = () => focusSelector('.planning-foundation-grid input') + + const sidebarRequirements = requirements.filter( + r => r.source !== 'analysis' && r.source !== 'system' + ) + const isEmpty = sidebarRequirements.length === 0 return (
- + {planningLoadError && ( +
{planningLoadError}
+ )} - + {isEmpty ? ( + <> + { + ws.onSelectLineage(requirementId, runId) + await onReload() + }} + planningRunsCount={ws.planningRuns.length} + planningRunReady={ws.planningRunReady} + onRunWhatsnext={ws.onRunWhatsnext} + runningWhatsnext={ws.runningWhatsnext} + /> + {ws.selectedRequirement && ( +
+ + +
+ )} + + ) : ( +
+ - {ws.selectedRequirement ? ( -
- ws.selectedRequirement && ws.loadPlanningRuns(ws.selectedRequirement.id)} - onRunWhatsnext={ws.onRunWhatsnext} - /> +
+ {ws.selectedRequirement ? ( + <> + ws.selectedRequirement && ws.loadPlanningRuns(ws.selectedRequirement.id)} + onRunWhatsnext={ws.onRunWhatsnext} + activeRunDispatchStatus={ws.activeRunDispatchStatus} + /> - + - + - { - ws.onSelectLineage(requirementId, runId, candidateId) - // Scroll priority: candidate > requirement. If the click - // carried a candidate id we want the review panel visible; - // otherwise the requirement queue is the correct landing. - if (candidateId) { - jumpToCandidates() - } else { - jumpToRequirements() - } - }} - onJumpToTasks={onNavigateToTasks} - /> -
- ) : requirements.length === 0 ? ( - ws.onSelectLineage(requirementId, runId)} - onWhatsnext={ws.onRunWhatsnext} - planningRunsCount={ws.planningRuns.length} - /> - ) : ( -
-
-

Start here

-

- Select a requirement above to plan a specific feature, or run a full project health check to surface the most urgent open work across tasks, drift signals, and stale docs. -

-
- {ws.planningRunReady ? ( - + { + ws.onSelectLineage(requirementId, runId, candidateId) + if (candidateId) { + jumpToCandidates() + } else { + jumpToRequirements() + } + }} + onJumpToTasks={onNavigateToTasks} + /> + ) : ( -

- Configure a planning provider in Model Settings or connect a local connector to enable health-check runs. -

+
+

+ Select a requirement on the left to plan a specific feature. +

+ {ws.planningRunReady ? ( + + ) : ( +

+ Configure a planning provider or connect a local connector to enable health-check runs. +

+ )} +
)} -
- )} -
+ +
+ )} ) } diff --git a/frontend/src/pages/ProjectDetail/TasksTab.test.tsx b/frontend/src/pages/ProjectDetail/TasksTab.test.tsx index 993951d..b1fa8dc 100644 --- a/frontend/src/pages/ProjectDetail/TasksTab.test.tsx +++ b/frontend/src/pages/ProjectDetail/TasksTab.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import type { Task } from '../../types' import { TasksTab } from './TasksTab' @@ -61,4 +61,44 @@ describe('', () => { const [badge] = screen.getAllByText('in progress', { selector: 'span.badge' }) expect(badge).toBeInTheDocument() }) + + // --- Phase 6b: dispatch_status display --- + + it('T-6b-UI-1: renders queued badge for dispatch_status=queued', () => { + const tasks = [makeTask({ id: 'q1', title: 'Queued task', dispatch_status: 'queued', source: 'role_dispatch:backend-architect' })] + render() + expect(screen.getByTestId('dispatch-badge-queued')).toBeInTheDocument() + expect(screen.getByTestId('dispatch-badge-queued').textContent).toBe('待執行') + }) + + it('T-6b-UI-2: renders running badge for dispatch_status=running', () => { + const tasks = [makeTask({ id: 'r1', title: 'Running task', dispatch_status: 'running', source: 'role_dispatch:backend-architect' })] + render() + expect(screen.getByTestId('dispatch-badge-running')).toBeInTheDocument() + expect(screen.getByTestId('dispatch-badge-running').textContent).toBe('執行中…') + }) + + it('T-6b-UI-3: renders completed badge with expandable result block; clicking shows file paths', () => { + const result = { files: ['src/api.go', 'src/store.go'] } + const tasks = [ + makeTask({ + id: 'c1', + title: 'Completed task', + dispatch_status: 'completed', + execution_result: result as Record, + source: 'role_dispatch:backend-architect', + }), + ] + render() + const toggle = screen.getByTestId('dispatch-badge-completed') + expect(toggle).toBeInTheDocument() + // Result block not visible before click + expect(screen.queryByTestId('dispatch-result-block')).toBeNull() + fireEvent.click(toggle) + // Result block visible after click + const block = screen.getByTestId('dispatch-result-block') + expect(block).toBeInTheDocument() + expect(block.textContent).toContain('src/api.go') + expect(block.textContent).toContain('src/store.go') + }) }) diff --git a/frontend/src/pages/ProjectDetail/TasksTab.tsx b/frontend/src/pages/ProjectDetail/TasksTab.tsx index bfcf207..ec9d094 100644 --- a/frontend/src/pages/ProjectDetail/TasksTab.tsx +++ b/frontend/src/pages/ProjectDetail/TasksTab.tsx @@ -2,6 +2,128 @@ import { useState, useEffect } from 'react' import type { Task, ProjectSummary } from '../../types' import { createTask, updateTask, deleteTask, batchUpdateTasks, listProjectTaskLineage, type AppliedLineageEntry } from '../../api/client' +// --------------------------------------------------------------------------- +// DispatchStatusBadge — inline indicator for role_dispatch execution lifecycle +// --------------------------------------------------------------------------- +interface DispatchStatusBadgeProps { task: Task } + +function DispatchStatusBadge({ task }: DispatchStatusBadgeProps) { + const [expanded, setExpanded] = useState(false) + const ds = task.dispatch_status + + if (!ds || ds === 'none') return null + + const labelMap: Record = { + queued: '待執行', + running: '執行中…', + completed: '已完成', + failed: '失敗', + } + const colorMap: Record = { + queued: 'var(--text-muted)', + running: 'var(--color-info, #6366f1)', + completed: 'var(--color-success, #22c55e)', + failed: 'var(--color-danger, #ef4444)', + } + const label = labelMap[ds] ?? ds + const color = colorMap[ds] ?? 'var(--text-muted)' + + if (ds === 'completed' && task.execution_result) { + const files: string[] = [] + try { + const raw = task.execution_result as Record + if (Array.isArray(raw['files'])) { + for (const f of raw['files'] as unknown[]) { + if (typeof f === 'string') files.push(f) + } + } + } catch { /* ignore */ } + return ( + + + {expanded && ( + e.stopPropagation()} + style={{ + display: 'block', + marginTop: '4px', + fontSize: '0.72rem', + color: 'var(--text-muted)', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + }} + data-testid="dispatch-result-block" + > + {files.length > 0 + ? files.map(f => {f}) + : JSON.stringify(task.execution_result, null, 2)} + + )} + + ) + } + + if (ds === 'failed') { + const errMsg: string = (() => { + try { + const raw = task.execution_result as Record | null | undefined + if (raw && typeof raw['error_message'] === 'string') return raw['error_message'] + } catch { /* ignore */ } + return '' + })() + return ( + + + {label} + + {errMsg && ( + + {errMsg} + + )} + + ) + } + + return ( + + {label} + + ) +} + type TaskFilterState = { status: '' | Task['status']; priority: '' | Task['priority']; assignee: string } type BatchTaskFormState = { status: '' | Task['status']; priority: '' | Task['priority']; assignee: string; clearAssignee: boolean } @@ -350,6 +472,9 @@ export function TasksTab({ {lineageByTask[task.id].lineage_kind === 'applied_candidate' ? '← Plan' : '← Req'} )} + {task.dispatch_status && task.dispatch_status !== 'none' && ( + + )} {task.status.replace('_', ' ')} {task.priority} diff --git a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx index 7a66936..db6e60f 100644 --- a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx +++ b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx @@ -102,6 +102,7 @@ function renderPanel(overrides: Partial', () => { renderPanel() // Title appears in both the list and the detail form input expect(screen.getAllByText('Persist recovery options').length).toBeGreaterThan(0) - expect(screen.getByRole('button', { name: /Approve/i })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Reject/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Skip/i })).toBeInTheDocument() }) it('disables Apply to Tasks until canApplySelectedCandidate is true', () => { renderPanel({ canApplySelectedCandidate: false }) - expect(screen.getByRole('button', { name: /Apply To Tasks/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /^Apply$/i })).toBeDisabled() }) it('renders document evidence as a clickable link when onViewDocumentById is provided and the document has an id', async () => { @@ -277,26 +278,31 @@ describe('', () => { expect(screen.queryByText(/Role: /i)).not.toBeInTheDocument() }) - // T-P5-B3-7: Manual / Auto-dispatch radio group renders only when the - // onSelectedExecutionModeChange callback is provided. Auto-dispatch is - // disabled with the "(coming in Phase 6)" label. + // T-P5-B3-7 (updated Phase 6b): Manual / Auto-dispatch radio group renders + // only when the onSelectedExecutionModeChange callback is provided. + // Auto-dispatch is disabled when the selected candidate has no execution_role. it('renders the Manual + Auto-dispatch radio group only when onSelectedExecutionModeChange is provided', () => { const onChange = vi.fn() renderPanel({ selectedExecutionMode: 'manual', onSelectedExecutionModeChange: onChange }) expect(screen.getByLabelText(/Manual/i)).toBeInTheDocument() - expect(screen.getByText(/coming in Phase 6/i)).toBeInTheDocument() + // Phase 6b: "coming in Phase 6" text removed; radio is dynamically enabled by execution_role. + expect(screen.queryByText(/coming in Phase 6/i)).not.toBeInTheDocument() + // Auto-dispatch radio exists but is disabled when candidate has no execution_role (default fixture). + const autoRadio = screen.getByRole('radio', { name: /Auto-dispatch/i }) + expect(autoRadio).toBeInTheDocument() }) - it('disables the Auto-dispatch radio even when selected, reserving real dispatch for Phase 6', () => { + it('disables the Auto-dispatch radio when candidate has no execution_role (Phase 6b)', () => { const onChange = vi.fn() - renderPanel({ selectedExecutionMode: 'role_dispatch', onSelectedExecutionModeChange: onChange }) - // Query the Auto-dispatch radio specifically by its nested text label. + // Default renderPanel fixture has no execution_role on selectedCandidate. + renderPanel({ selectedExecutionMode: 'manual', onSelectedExecutionModeChange: onChange }) const autoRadio = screen.getByRole('radio', { name: /Auto-dispatch/i }) expect(autoRadio).toBeDisabled() }) it('hides the execution mode radio group when onSelectedExecutionModeChange is not wired', () => { renderPanel() - expect(screen.queryByText(/coming in Phase 6/i)).not.toBeInTheDocument() + // No radio group rendered when callback not provided. + expect(screen.queryByRole('radio', { name: /Auto-dispatch/i })).not.toBeInTheDocument() }) }) diff --git a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx index 18e30d1..f1d99f0 100644 --- a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx +++ b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { BacklogCandidate, PlanningProviderOptions, PlanningRun } from '../../../types' import { formatDateTime, formatRelativeTime } from '../../../utils/formatters' import Jargon from '../../../components/Jargon' @@ -88,7 +89,8 @@ interface CandidateReviewPanelProps { onPersistReview: (nextStatus?: 'draft' | 'approved' | 'rejected') => void onApplyCandidate: () => void - onResetCandidateForm: () => void + onSkipCandidate: () => void + onResetCandidateForm?: () => void // Phase 5 B3: execution mode radio group. `manual` is the Phase 4 // behaviour; `role_dispatch` is a forward-looking marker. Both props @@ -139,12 +141,13 @@ export function CandidateReviewPanel({ providerOptions, onPersistReview, onApplyCandidate, - onResetCandidateForm, + onSkipCandidate, selectedExecutionMode, onSelectedExecutionModeChange, onViewDocumentById, onViewDriftSignal, }: CandidateReviewPanelProps) { + const [showSkipped, setShowSkipped] = useState(false) const providerLabel = makeProviderLabeler(providerOptions) const modelLabel = makeModelLabeler(providerOptions) @@ -182,7 +185,10 @@ export function CandidateReviewPanel({

)} - {selectedRun && {candidates.length} candidate{candidates.length === 1 ? '' : 's'}} + {selectedRun && (() => { + const activeCount = candidates.filter(c => c.status !== 'rejected').length + return {activeCount} candidate{activeCount === 1 ? '' : 's'} + })()} {candidatesError &&
{candidatesError}
} @@ -229,7 +235,7 @@ export function CandidateReviewPanel({ ) : (
- {candidates.map(candidate => ( + {candidates.filter(c => c.status !== 'rejected').map(candidate => ( ))} + + {(() => { + const skipped = candidates.filter(c => c.status === 'rejected') + if (skipped.length === 0) return null + return ( +
+ + {showSkipped && skipped.map(c => ( + + ))} +
+ ) + })()}
@@ -302,19 +336,6 @@ export function CandidateReviewPanel({ />
-
- - -
- {selectedCandidate.rationale &&
{selectedCandidate.rationale}
} {selectedCandidate.validation_criteria && ( @@ -533,47 +554,62 @@ export function CandidateReviewPanel({ /> Manual - + {(() => { + const hasRole = !!(selectedCandidate?.execution_role && selectedCandidate.execution_role.trim() !== '') + return ( + + ) + })()}
)}
- - - - - + )} + -
diff --git a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx index d731817..e964215 100644 --- a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx +++ b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' -import type { AccountBinding, PlanningProviderOptions, Requirement } from '../../../types' +import type { PlanningProviderOptions, Requirement } from '../../../types' +import type { CliConfigOption } from './hooks/usePlanningWorkspaceData' import { PlanningLauncher } from './PlanningLauncher' function makeRequirement(overrides: Partial = {}): Requirement { @@ -19,24 +20,16 @@ function makeRequirement(overrides: Partial = {}): Requirement { } } -function makeBinding(overrides: Partial = {}): AccountBinding { +function makeCliConfig(overrides: Partial = {}): CliConfigOption { return { - id: 'b1', - user_id: 'u1', - provider_id: 'cli:claude', - label: 'My Claude', - base_url: '', - model_id: 'claude-sonnet-4-6', - configured_models: [], - api_key_configured: false, - is_active: true, - cli_command: 'claude', - is_primary: true, - created_at: '2026-04-22T00:00:00Z', - updated_at: '2026-04-22T00:00:00Z', - last_probe_at: null, - last_probe_ok: null, - last_probe_ms: null, + key: 'connector1:config1', + connectorId: 'connector1', + connectorLabel: 'My Machine', + configId: 'config1', + configLabel: 'My Claude', + modelId: 'claude-sonnet-4-6', + isPrimary: true, + isConnectorOnline: true, ...overrides, } } @@ -49,10 +42,10 @@ function renderLauncher(overrides: Partial', () => { expect(btn).toBeDisabled() }) - it('shows "No CLI binding configured" when cliBindings is empty and connector is online', () => { + it('shows "No CLI configured on this machine" when cliConfigs is empty and connector is online', () => { localStorage.setItem('anpm_launcher_advanced_open', '1') const providerOptions = { providers: [], @@ -192,12 +185,12 @@ describe('', () => { resolved_binding_source: 'shared', resolved_binding_label: '', } as unknown as PlanningProviderOptions - renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliBindings: [], selectedCliBindingId: null }) - expect(screen.getByText(/No CLI binding configured/i)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /Set up a CLI binding/i })).toBeInTheDocument() + renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliConfigs: [], selectedCliConfigKey: null }) + expect(screen.getByText(/No CLI configured on this machine/i)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Set up CLIs in My Connector/i })).toBeInTheDocument() }) - it('shows CLI binding select with correct options when bindings exist', () => { + it('shows CLI config select with correct options when configs exist', () => { localStorage.setItem('anpm_launcher_advanced_open', '1') const providerOptions = { providers: [], @@ -212,15 +205,15 @@ describe('', () => { resolved_binding_source: 'shared', resolved_binding_label: '', } as unknown as PlanningProviderOptions - const binding = makeBinding({ id: 'b1', label: 'My Claude', model_id: 'claude-sonnet-4-6', is_primary: true }) - renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliBindings: [binding], selectedCliBindingId: 'b1' }) - expect(screen.getByText('My Claude [claude-sonnet-4-6] (primary)')).toBeInTheDocument() - expect(screen.getByLabelText(/CLI binding for this run/i)).toBeInTheDocument() + const config = makeCliConfig({ key: 'connector1:config1', connectorLabel: 'My Machine', configLabel: 'My Claude', modelId: 'claude-sonnet-4-6', isPrimary: true }) + renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliConfigs: [config], selectedCliConfigKey: 'connector1:config1' }) + expect(screen.getByText('My Machine — My Claude [claude-sonnet-4-6] (primary)')).toBeInTheDocument() + expect(screen.getByLabelText(/CLI for this run/i)).toBeInTheDocument() }) - it('calls onCliBindingChange when binding is changed', async () => { + it('calls onCliConfigChange when CLI config is changed', async () => { localStorage.setItem('anpm_launcher_advanced_open', '1') - const onCliBindingChange = vi.fn() + const onCliConfigChange = vi.fn() const { default: userEvent } = await import('@testing-library/user-event') const providerOptions = { providers: [], @@ -235,18 +228,18 @@ describe('', () => { resolved_binding_source: 'shared', resolved_binding_label: '', } as unknown as PlanningProviderOptions - const binding1 = makeBinding({ id: 'b1', label: 'My Claude', model_id: 'claude-sonnet-4-6', is_primary: true }) - const binding2 = makeBinding({ id: 'b2', label: 'My Codex', model_id: 'codex-mini-latest', is_primary: false, provider_id: 'cli:codex' }) - renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliBindings: [binding1, binding2], selectedCliBindingId: 'b1', onCliBindingChange }) - const bindingSelect = screen.getByLabelText(/CLI binding for this run/i) - await userEvent.selectOptions(bindingSelect, 'b2') - expect(onCliBindingChange).toHaveBeenCalledWith('b2') + const config1 = makeCliConfig({ key: 'connector1:config1', connectorLabel: 'My Machine', configLabel: 'My Claude', modelId: 'claude-sonnet-4-6', isPrimary: true }) + const config2 = makeCliConfig({ key: 'connector1:config2', connectorLabel: 'My Machine', configLabel: 'My Codex', modelId: 'codex-mini-latest', isPrimary: false }) + renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliConfigs: [config1, config2], selectedCliConfigKey: 'connector1:config1', onCliConfigChange }) + const configSelect = screen.getByLabelText(/CLI for this run/i) + await userEvent.selectOptions(configSelect, 'connector1:config2') + expect(onCliConfigChange).toHaveBeenCalledWith('connector1:config2') }) it('T-6a-A2-1: default render hides advanced controls', () => { renderLauncher() expect(screen.queryByLabelText(/Execution mode/i)).not.toBeInTheDocument() - expect(screen.queryByLabelText(/CLI binding for this run/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/CLI for this run/i)).not.toBeInTheDocument() expect(screen.queryByLabelText(/Model override for this run/i)).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /Advanced/i })).toBeInTheDocument() }) diff --git a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx index ab636b8..e1007c8 100644 --- a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx +++ b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Link } from 'react-router-dom' -import type { AccountBinding, PlanningExecutionMode, PlanningProviderOptions, Requirement } from '../../../types' +import type { PlanningExecutionMode, PlanningProviderOptions, Requirement } from '../../../types' +import type { CliConfigOption } from './hooks/usePlanningWorkspaceData' import { formatRelativeTime } from '../../../utils/formatters' import Jargon from '../../../components/Jargon' import { @@ -19,10 +20,10 @@ interface PlanningLauncherProps { executionMode: PlanningExecutionMode onExecutionModeChange: (mode: PlanningExecutionMode) => void - cliBindings: AccountBinding[] - cliBindingsLoading: boolean - selectedCliBindingId: string | null - onCliBindingChange: (id: string) => void + cliConfigs: CliConfigOption[] + cliConfigsLoading: boolean + selectedCliConfigKey: string | null + onCliConfigChange: (key: string) => void planningModelOverride: string onPlanningModelOverrideChange: (value: string) => void @@ -36,6 +37,8 @@ interface PlanningLauncherProps { onStartRun: () => void onRefreshRuns: () => void onRunWhatsnext: () => void + /** Dispatch status of the current active run (if any), used to show connector badge state */ + activeRunDispatchStatus?: string | null } /** @@ -54,10 +57,10 @@ export function PlanningLauncher({ providerOptionsError, executionMode, onExecutionModeChange, - cliBindings, - cliBindingsLoading, - selectedCliBindingId, - onCliBindingChange, + cliConfigs, + cliConfigsLoading, + selectedCliConfigKey, + onCliConfigChange, planningModelOverride, onPlanningModelOverrideChange, creatingRun, @@ -68,6 +71,7 @@ export function PlanningLauncher({ onStartRun, onRefreshRuns, onRunWhatsnext, + activeRunDispatchStatus, }: PlanningLauncherProps) { const [advanced, setAdvanced] = useState(() => localStorage.getItem('anpm_launcher_advanced_open') === '1') @@ -118,10 +122,10 @@ export function PlanningLauncher({
- Decomposition Settings + Run via
- {!advanced && ( + {!advanced && !providerOptionsLoading && (
{planningExecutionModeLabel(executionMode)} - {' · '} - {cliBindings.find(b => b.id === selectedCliBindingId)?.label ?? 'auto'}
)} @@ -148,7 +150,7 @@ export function PlanningLauncher({ onCliBindingChange(e.target.value)} + value={selectedCliConfigKey ?? ''} + onChange={e => onCliConfigChange(e.target.value)} disabled={creatingRun} style={{ padding: '0.4rem 0.6rem', fontSize: '0.88rem', background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '0.375rem', color: 'var(--text)' }} > - {cliBindings.map(b => ( - ))} - Manage bindings in My Bindings. + Manage CLIs in My Connector. )} @@ -260,7 +270,7 @@ export function PlanningLauncher({ diff --git a/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx b/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx index 1cde6a8..6a127fc 100644 --- a/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx +++ b/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx @@ -16,6 +16,12 @@ interface RequirementIntakeProps { onToggleForm: () => void onSubmit: (e: FormEvent) => void onReset: () => void + /** + * 'card' (default) renders with the full card wrapper, header, and toggle button. + * 'inline' renders just the form fields inside a slim wrapper — the toggle is + * controlled externally (e.g. by the sidebar header button). + */ + variant?: 'card' | 'inline' } /** @@ -26,6 +32,62 @@ interface RequirementIntakeProps { * a "+ New Requirement" toggle. Sequential disclosure is part of the * 2026-04-21 progressive-disclosure decision. */ +const intakeForm = ( + form: RequirementIntakeForm, + onFormChange: (f: RequirementIntakeForm) => void, + creating: boolean, + onSubmit: (e: FormEvent) => void, + onReset: () => void, +) => ( +
+
+ + onFormChange({ ...form, title: e.target.value })} + placeholder="Improve sync failure recovery UX" + /> +
+
+ + onFormChange({ ...form, summary: e.target.value })} + placeholder="One-line planning summary" + /> +
+
+ +