Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs-web/content/docs/developer-http-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List. |
| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List tasks. Rated tasks include optional latest `selfReflectionRating`; unrated tasks omit it. |
| `POST` | `/api/projects/:projectId/tasks` | Create. |
| `PATCH` | `/api/tasks/:taskId` | Update. |
| `DELETE` | `/api/tasks/:taskId` | Delete. |
Expand Down
2 changes: 1 addition & 1 deletion docs-web/developer/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List. |
| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List tasks. Rated tasks include optional latest `selfReflectionRating`; unrated tasks omit it. |
| `POST` | `/api/projects/:projectId/tasks` | Create. |
| `PATCH` | `/api/tasks/:taskId` | Update. |
| `DELETE` | `/api/tasks/:taskId` | Delete. |
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/project-management-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ The dashboard now has project-scoped CRUD endpoints:

`GET /api/projects` is the single data source for Projects page cards. The project summary payload includes the source kind, repository metadata, local base path, creation and update timestamps, and the latest project-scoped run activity derived from `sprint_runs` and `task_runs`.

`GET /api/projects/:projectId/tasks` returns each task with optional `latestReview` and `selfReflectionRating` fields. `selfReflectionRating` is omitted for unrated tasks and, when present, is the latest persisted task-run self-reflection rating selected by captured timestamp and persisted row order.

Legacy runtime endpoints still exist for the old live runtime/status surfaces:
- `GET /api/status`
- `GET /api/live-activities`
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard/dashboard-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ Legacy runtime:
- The organic sprint bubble cells use the same live start/stop control path as the registry list, so the hover play/stop action is now functional instead of decorative
- Organic sprint bubble cell shadows use the shared project-cell organic shadow underlay from `organic-cell-styles.ts`, keeping sprint and project gallery cells at the same ambient depth in light and dark themes.
- Sprint cells now surface a QA-reviewed indicator with an expandable overlay section inside the created column, and allow marking sprints completed directly from the cell menu
- Task rows and Live task cards now surface task-level QA review badges from the latest task QA run, including a running indicator while QA review is in progress.
- Task rows and Live task cards now surface task-level QA review badges from the latest task QA run, including a running indicator while QA review is in progress. The same task records carry an optional latest `selfReflectionRating` payload when a completed task run persisted one; unrated tasks omit the field.
- The Tasks page sprint scope selector uses a keyboard-accessible listbox pattern with selected, open, loading, and empty option state, arrow/Home/End navigation, Escape close, outside-click close, and trigger focus restoration. Task board status and priority filters keep the current cards visible during the short filter transition, then announce the settled result count through a polite live region. Task board lanes render as named regions with count summaries, drop-target feedback, reduced-motion drag-disabled copy, and status regions for loading, empty, and error states; Kanban cards expose task id/title/status/priority, dependency blockers, optimistic saving, session, preview, PR, live runtime, rerun availability, duration, QA review context, and screen-reader drag guidance in stable accessible text while keeping drag-and-drop pointer-only. Creating or editing a task opens a named editor viewbox inside the task workspace: it sits to the right of the board on wide screens, becomes the primary full-width region on narrow screens, keeps the selected sprint scope plus active board filters intact, and can persist a task-level `agentPresetId` worker-agent override. Card quick actions sit in the bottom footer below metadata/dependency indicators; fine-pointer layouts reserve the action tray and reveal it on hover or keyboard focus, while touch/coarse-pointer layouts keep actions visible.
- Rendered markdown previews use near-black body, heading, list, blockquote, and table text in light mode while preserving slate/white dark-mode text and signal-colored links/code.
- Live task cards now include `Edit` and `Force complete` actions:
Expand Down
2 changes: 2 additions & 0 deletions src/contracts/app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { InstructionTemplateId } from "../instructions/instruction-template
import type { ProviderInvocationPurpose, TokenUsageSource } from "./execution-types.js";
import type { ExecutionInvocationRecord } from "./invocation-types.js";
import type { MemorySettings } from "./memory-types.js";
import type { TaskSelfReflectionRating } from "./task-self-reflection-types.js";

export interface JulesSource {
name: string;
Expand Down Expand Up @@ -130,6 +131,7 @@ export interface Subtask {
reviewer: string | null;
finishedAt: string | null;
};
selfReflectionRating?: TaskSelfReflectionRating;
is_merged?: boolean;
merge_indicator?: SubtaskMergeIndicator;
intervention_owner?: InterventionOwner;
Expand Down
2 changes: 2 additions & 0 deletions src/contracts/project-management-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentRoutingMode, VirtualWorkerProvider } from "./app-types.js";
import type { ProjectSettingsOverride } from "./settings-scope-types.js";
import type { TaskSelfReflectionRating } from "./task-self-reflection-types.js";
import type { ProjectWorkerAssignmentRecord } from "./worker-types.js";

export type ProjectStatus = "running" | "failed" | "intervention" | "idle";
Expand Down Expand Up @@ -285,6 +286,7 @@ export interface TaskRecord {
[key: string]: any;
};
latestReview?: SprintReviewSummary;
selfReflectionRating?: TaskSelfReflectionRating;
mergeIndicator: string | null;
sourceType: string | null;
sourcePath: string | null;
Expand Down
9 changes: 7 additions & 2 deletions src/repositories/project-management-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { loadProjectSummaryAggregationMap, projectSummaryQuery, type ProjectSumm
import { loadSprintSummaryAggregationMap, sprintSummaryQuery, type SprintSummaryAggregation } from "./project-management/sprint-summary-query.js";
import { validateTaskDependencies } from "./project-management/task-dependency-graph.js";
import { getHomeCodeUxPath } from "../shared/config/code-ux-paths.js";
import { TaskSelfReflectionRatingRepository } from "./task-self-reflection-rating-repository.js";

const SELECTED_PROJECT_KEY = "selected_project_id";
const GENERATED_SPRINT_NAME_PREFIX = "Untitled sprint";
Expand Down Expand Up @@ -153,7 +154,8 @@ export class ProjectManagementRepository {
private readonly realtimeNotifier?: DashboardRealtimeMutationNotifier,
private readonly settingsRepository: SettingsRepository = new SettingsRepository(),
private readonly projectWorkerAssignmentRepository: ProjectWorkerAssignmentRepository = new ProjectWorkerAssignmentRepository(storage),
private readonly logger: Logger = createLogger({ bindings: { component: "ProjectManagementRepository" } })
private readonly logger: Logger = createLogger({ bindings: { component: "ProjectManagementRepository" } }),
private readonly taskSelfReflectionRatingRepository: TaskSelfReflectionRatingRepository = new TaskSelfReflectionRatingRepository(storage)
) {
this.db = storage.getDatabase();
}
Expand Down Expand Up @@ -1105,7 +1107,9 @@ export class ProjectManagementRepository {
dependencyMap.set(row.task_id, current);
}

const reviewMap = this.getLatestTaskReviewSummaryMap(rows.map((row) => row.id));
const taskIds = rows.map((row) => row.id);
const reviewMap = this.getLatestTaskReviewSummaryMap(taskIds);
const selfReflectionRatingMap = this.taskSelfReflectionRatingRepository.getLatestByTaskIds(taskIds);

return rows.map((row) => ({
id: row.id,
Expand All @@ -1125,6 +1129,7 @@ export class ProjectManagementRepository {
isIndependent: toBoolean(row.is_independent),
isMerged: toBoolean(row.is_merged),
latestReview: reviewMap.get(row.id),
selfReflectionRating: selfReflectionRatingMap.get(row.id),
mergeIndicator: row.merge_indicator,
sourceType: row.source_type,
sourcePath: row.source_path,
Expand Down
12 changes: 10 additions & 2 deletions src/repositories/project-runtime/runtime-status-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { DatabaseAdapter } from "../db/database-adapter.js";
import { AppDbStorage } from "../app-db-storage.js";
import type { DashboardStatus, JulesActivity, Subtask, SubtaskStatus } from "../../contracts/app-types.js";
import type { SprintReviewSummary } from "../../contracts/project-management-types.js";
import type { TaskSelfReflectionRating } from "../../contracts/task-self-reflection-types.js";
import { mapPlanningStatusToRuntimeStatus, toMergeIndicator } from "../../services/subtask-state-mapper.js";
import { RuntimeContextPayload } from "./runtime-context-store.js";
import { toNumber, toBoolean, parsePayloadJson } from "../repository-utils.js";
import { TaskSelfReflectionRatingRepository } from "../task-self-reflection-rating-repository.js";

export type PlanningTaskStatus = "pending" | "in_progress" | "coding_completed" | "completed" | "QA_REVIEW_FAILED";
export type ProjectStatus = "running" | "failed" | "intervention" | "idle";
Expand Down Expand Up @@ -85,6 +87,7 @@ export interface MappedTask {
row: TaskRow;
dependsOnTaskIds: string[];
latestReview?: SprintReviewSummary;
selfReflectionRating?: TaskSelfReflectionRating;
}

interface RecentActivitiesCacheEntry {
Expand Down Expand Up @@ -131,7 +134,8 @@ export class RuntimeStatusProjection {

constructor(
private readonly storage: AppDbStorage,
private readonly db: DatabaseAdapter
private readonly db: DatabaseAdapter,
private readonly taskSelfReflectionRatingRepository: TaskSelfReflectionRatingRepository = new TaskSelfReflectionRatingRepository(storage),
) {}

buildProjectStatus(
Expand Down Expand Up @@ -165,6 +169,7 @@ export class RuntimeStatusProjection {
activities: recentActivitiesByTaskId.get(task.row.id),
is_independent: toBoolean(task.row.is_independent),
latestReview: task.latestReview,
selfReflectionRating: task.selfReflectionRating,
is_merged: merged,
merge_indicator: toMergeIndicator(task.row.merge_indicator),
};
Expand Down Expand Up @@ -218,12 +223,15 @@ export class RuntimeStatusProjection {
dependencyMap.set(row.task_id, current);
}

const reviewMap = this.getLatestTaskReviewSummaryMap(taskRows.map((row) => row.id));
const taskIds = taskRows.map((row) => row.id);
const reviewMap = this.getLatestTaskReviewSummaryMap(taskIds);
const selfReflectionRatingMap = this.taskSelfReflectionRatingRepository.getLatestByTaskIds(taskIds);

return taskRows.map((row) => ({
row,
dependsOnTaskIds: dependencyMap.get(row.id) || [],
latestReview: reviewMap.get(row.id),
selfReflectionRating: selfReflectionRatingMap.get(row.id),
}));
}

Expand Down
2 changes: 1 addition & 1 deletion src/repositories/task-self-reflection-rating-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class TaskSelfReflectionRatingRepository {
r.*,
ROW_NUMBER() OVER (
PARTITION BY r.task_id
ORDER BY r.captured_at DESC, r.updated_at DESC, r.id DESC
ORDER BY r.captured_at DESC, r.rowid DESC
) AS row_number
FROM task_self_reflection_ratings r
WHERE r.task_id
Expand Down
80 changes: 80 additions & 0 deletions tests/backend/repositories/project-management-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../../../src/repositories/project-management-repository.js";
import { ExecutionRepository } from "../../../src/repositories/execution-repository.js";
import { SprintMarkdownService } from "../../../src/services/sprint-markdown-service.js";
import { TaskSelfReflectionRatingRepository } from "../../../src/repositories/task-self-reflection-rating-repository.js";

const tempDirs: string[] = [];

Expand Down Expand Up @@ -932,6 +933,85 @@ describe("ProjectManagementRepository", () => {
});
});

it("includes latest task self-reflection ratings in listTasks and omits unrated tasks", async () => {
const { storage, repository, executionRepository } = await createRepository();
const ratingRepository = new TaskSelfReflectionRatingRepository(storage);

const project = repository.createProject({
name: "Task Self Reflection Project",
sourceType: "local",
sourceRef: "/tmp/task-self-reflection",
});
const sprint = repository.createSprint(project.id, {
name: "Task Self Reflection Sprint",
goal: "Expose task self-reflection state",
});
const ratedTask = repository.createTask(project.id, {
sprintId: sprint.id,
taskKey: "T1",
title: "Rated task",
});
const unratedTask = repository.createTask(project.id, {
sprintId: sprint.id,
taskKey: "T2",
title: "Unrated task",
});
const olderRun = executionRepository.createTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
provider: "codex",
state: "COMPLETED",
});
const latestRun = executionRepository.createTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
provider: "codex",
state: "COMPLETED",
});

ratingRepository.upsertForTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
sourceTaskRunId: olderRun.id,
overallRating: 2,
sections: [
{ label: "Implementation", normalizedLabel: "implementation", rating: 2, note: "Earlier run" },
],
capturedAt: "2026-06-01T10:00:00.000Z",
});
ratingRepository.upsertForTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
sourceTaskRunId: latestRun.id,
overallRating: 4,
sections: [
{ label: "Tests", normalizedLabel: "tests", rating: 5, note: "Covered" },
],
capturedAt: "2026-06-01T10:00:00.000Z",
});

const tasks = repository.listTasks(project.id, sprint.id);
const mappedRated = tasks.find((task) => task.id === ratedTask.id);
const mappedUnrated = tasks.find((task) => task.id === unratedTask.id);

expect(mappedUnrated?.selfReflectionRating).toBeUndefined();
expect(mappedRated?.selfReflectionRating).toMatchObject({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
sourceTaskRunId: latestRun.id,
overallRating: 4,
sections: [
{ label: "Tests", normalizedLabel: "tests", rating: 5, note: "Covered" },
],
capturedAt: "2026-06-01T10:00:00.000Z",
});
});

it("handles originalPrompt in sprints and supports clearing tasks", async () => {
const { repository } = await createRepository();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppDbStorage } from "../../../../src/repositories/app-db-storage.js";
import { RuntimeStatusProjection } from "../../../../src/repositories/project-runtime/runtime-status-projection.js";
import { ProjectManagementRepository } from "../../../../src/repositories/project-management-repository.js";
import { ExecutionRepository } from "../../../../src/repositories/execution-repository.js";
import { TaskSelfReflectionRatingRepository } from "../../../../src/repositories/task-self-reflection-rating-repository.js";

const tempDirs: string[] = [];

Expand All @@ -14,6 +15,7 @@ async function createProjection(): Promise<{
projection: RuntimeStatusProjection;
projectRepository: ProjectManagementRepository;
executionRepository: ExecutionRepository;
ratingRepository: TaskSelfReflectionRatingRepository;
}> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-status-projection-test-"));
tempDirs.push(dir);
Expand All @@ -23,6 +25,7 @@ async function createProjection(): Promise<{
projection: new RuntimeStatusProjection(storage, storage.getDatabase()),
projectRepository: new ProjectManagementRepository(storage),
executionRepository: new ExecutionRepository(storage),
ratingRepository: new TaskSelfReflectionRatingRepository(storage),
};
}

Expand Down Expand Up @@ -222,4 +225,76 @@ describe("RuntimeStatusProjection", () => {

expect(refreshedStatus.subtasks[0]?.activities?.map((activity) => activity.id)).toEqual(["act-1", "act-2"]);
});

it("projects latest task self-reflection ratings for live status and omits unrated tasks", async () => {
const { projection, projectRepository, executionRepository, ratingRepository } = await createProjection();

const project = projectRepository.createProject({ name: "Proj", sourceType: "local", sourceRef: "/path" });
const sprint = projectRepository.createSprint(project.id, { name: "Sprint 1", number: 1 });
const ratedTask = projectRepository.createTask(project.id, {
sprintId: sprint.id,
taskKey: "T1",
title: "Rated task",
status: "completed",
});
const unratedTask = projectRepository.createTask(project.id, {
sprintId: sprint.id,
taskKey: "T2",
title: "Unrated task",
status: "pending",
});
const olderRun = executionRepository.createTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
provider: "codex",
state: "COMPLETED",
});
const latestRun = executionRepository.createTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
provider: "codex",
state: "COMPLETED",
});

ratingRepository.upsertForTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
sourceTaskRunId: olderRun.id,
overallRating: 1,
sections: [
{ label: "Quality", normalizedLabel: "quality", rating: 1, note: "Older" },
],
capturedAt: "2026-06-01T10:00:00.000Z",
});
ratingRepository.upsertForTaskRun({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
sourceTaskRunId: latestRun.id,
overallRating: 5,
sections: [
{ label: "Quality", normalizedLabel: "quality", rating: 5, note: "Latest" },
],
capturedAt: "2026-06-01T10:05:00.000Z",
});

const status = projection.buildProjectStatus(project.id, sprint.id, null);
const mappedRated = status.subtasks.find((task) => task.record_id === ratedTask.id);
const mappedUnrated = status.subtasks.find((task) => task.record_id === unratedTask.id);

expect(mappedUnrated?.selfReflectionRating).toBeUndefined();
expect(mappedRated?.selfReflectionRating).toMatchObject({
projectId: project.id,
sprintId: sprint.id,
taskId: ratedTask.id,
sourceTaskRunId: latestRun.id,
overallRating: 5,
sections: [
{ label: "Quality", normalizedLabel: "quality", rating: 5, note: "Latest" },
],
});
});
});
Loading