From 8adb63a0547dba920d0a8540cfaba9c623459d8d Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Fri, 12 Jun 2026 05:25:43 +0000 Subject: [PATCH 1/8] Refresh work item hierarchy on tool events --- .../coc-knowledge/references/dashboard-spa.md | 6 +- .../coc/src/server/spa/client/react/App.tsx | 6 +- .../client/react/contexts/WorkItemContext.tsx | 20 ++- .../work-items/WorkItemHierarchyTree.tsx | 23 ++-- .../WorkItemHierarchyTree.refresh.test.tsx | 124 ++++++++++++++++++ .../react/context/WorkItemContext.test.tsx | 14 +- 6 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 packages/coc/test/server/spa/client/work-items/WorkItemHierarchyTree.refresh.test.tsx diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 7ff7597cb..82182200c 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -122,7 +122,11 @@ leaf rows. Remote/Synced trees keep the type avatar, title, remote mirror badge, and container rollups, but omit local work-item numbers and leaf status chips so remote identifiers remain the primary row metadata. Compact GitHub mirror badges render the issue number only; full detail-page badges keep the provider label and -link title. +link title. `work-item-added`, `work-item-updated`, and `work-item-removed` +WebSocket events update `WorkItemContext` for the matching workspace and advance +a workspace-scoped realtime revision used by `WorkItemHierarchyTree` to refetch +its tree data. The hierarchy toolbar exposes a Refresh control that calls the +same tree fetch path and is disabled while the tree request is in flight. `workItems.workflow.enabled` is the disabled-by-default durable workflow gate for turning local Work Items and Goals into the command-center planning/execution diff --git a/packages/coc/src/server/spa/client/react/App.tsx b/packages/coc/src/server/spa/client/react/App.tsx index eb0071883..78838ccb3 100644 --- a/packages/coc/src/server/spa/client/react/App.tsx +++ b/packages/coc/src/server/spa/client/react/App.tsx @@ -283,13 +283,13 @@ function AppInner() { }); break; case 'work-item-added': - if (msg.item) workItemDispatch({ type: 'WORK_ITEM_ADDED', repoId: msg.workspaceId, item: msg.item }); + if (msg.workspaceId && msg.item) workItemDispatch({ type: 'WORK_ITEM_ADDED', repoId: msg.workspaceId, item: msg.item }); break; case 'work-item-updated': - if (msg.item) workItemDispatch({ type: 'WORK_ITEM_UPDATED', repoId: msg.workspaceId, item: msg.item }); + if (msg.workspaceId && msg.item) workItemDispatch({ type: 'WORK_ITEM_UPDATED', repoId: msg.workspaceId, item: msg.item }); break; case 'work-item-removed': - if (msg.itemId) workItemDispatch({ type: 'WORK_ITEM_REMOVED', repoId: msg.workspaceId, id: msg.itemId }); + if (msg.workspaceId && msg.itemId) workItemDispatch({ type: 'WORK_ITEM_REMOVED', repoId: msg.workspaceId, id: msg.itemId }); break; case 'ralph-session-complete': window.dispatchEvent(new CustomEvent('ralph-session-complete', { detail: { repoId: msg.repoId } })); diff --git a/packages/coc/src/server/spa/client/react/contexts/WorkItemContext.tsx b/packages/coc/src/server/spa/client/react/contexts/WorkItemContext.tsx index 4986def51..6435fdc30 100644 --- a/packages/coc/src/server/spa/client/react/contexts/WorkItemContext.tsx +++ b/packages/coc/src/server/spa/client/react/contexts/WorkItemContext.tsx @@ -32,6 +32,7 @@ export interface WorkItemContextState { loading: Record; selectedWorkItemId: string | null; unseenByRepo: Record; + realtimeRevisionByRepo: Record; /** Per-status pagination: repo → status → pagination state */ paginationByRepo: Record>; } @@ -54,6 +55,7 @@ const initialState: WorkItemContextState = { loading: {}, selectedWorkItemId: null, unseenByRepo: {}, + realtimeRevisionByRepo: {}, paginationByRepo: {}, }; @@ -135,10 +137,18 @@ function workItemReducer(state: WorkItemContextState, action: WorkItemAction): W return { ...state, selectedWorkItemId: action.id }; case 'WORK_ITEM_ADDED': { const existing = state.workItemsByRepo[action.repoId] || []; + const exists = existing.some(item => item.id === action.item.id); + const items = exists + ? existing.map(item => item.id === action.item.id ? action.item : item) + : [...existing, action.item]; return { ...state, - workItemsByRepo: { ...state.workItemsByRepo, [action.repoId]: [...existing, action.item] }, + workItemsByRepo: { ...state.workItemsByRepo, [action.repoId]: items }, unseenByRepo: addToUnseen(state.unseenByRepo, action.repoId, action.item.id), + realtimeRevisionByRepo: { + ...state.realtimeRevisionByRepo, + [action.repoId]: (state.realtimeRevisionByRepo[action.repoId] || 0) + 1, + }, }; } case 'WORK_ITEM_UPDATED': { @@ -150,6 +160,10 @@ function workItemReducer(state: WorkItemContextState, action: WorkItemAction): W ...state, workItemsByRepo: { ...state.workItemsByRepo, [action.repoId]: updatedItems }, unseenByRepo: statusChanged ? addToUnseen(state.unseenByRepo, action.repoId, action.item.id) : state.unseenByRepo, + realtimeRevisionByRepo: { + ...state.realtimeRevisionByRepo, + [action.repoId]: (state.realtimeRevisionByRepo[action.repoId] || 0) + 1, + }, }; } case 'WORK_ITEM_REMOVED': { @@ -159,6 +173,10 @@ function workItemReducer(state: WorkItemContextState, action: WorkItemAction): W ...state, workItemsByRepo: { ...state.workItemsByRepo, [action.repoId]: filtered }, unseenByRepo: { ...state.unseenByRepo, [action.repoId]: unseen }, + realtimeRevisionByRepo: { + ...state.realtimeRevisionByRepo, + [action.repoId]: (state.realtimeRevisionByRepo[action.repoId] || 0) + 1, + }, }; } case 'LOAD_UNSEEN_WORK_ITEMS': { diff --git a/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx b/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx index 4c904df18..90611e6d0 100644 --- a/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx +++ b/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx @@ -356,16 +356,16 @@ export function WorkItemHierarchyTree({ remoteProviderFilter, ]); - // Refresh when WebSocket events arrive (work item context changes) - const repoItems = workItemState.workItemsByRepo[workspaceId]; - const prevRepoItemsRef = useRef(repoItems); + // Refresh when workspace-scoped Work Item WebSocket events arrive. + const realtimeRevision = workItemState.realtimeRevisionByRepo[workspaceId] || 0; + const prevRealtimeRevisionRef = useRef(realtimeRevision); useEffect(() => { - if (prevRepoItemsRef.current === repoItems) return; - prevRepoItemsRef.current = repoItems; + if (prevRealtimeRevisionRef.current === realtimeRevision) return; + prevRealtimeRevisionRef.current = realtimeRevision; // Debounce to avoid multiple rapid refreshes const timer = setTimeout(() => fetchTree(), 300); return () => clearTimeout(timer); - }, [repoItems, fetchTree]); + }, [realtimeRevision, fetchTree]); // Persist collapse state useEffect(() => { @@ -601,12 +601,15 @@ export function WorkItemHierarchyTree({ )} {/* Filter chips */} diff --git a/packages/coc/test/server/spa/client/work-items/WorkItemHierarchyTree.refresh.test.tsx b/packages/coc/test/server/spa/client/work-items/WorkItemHierarchyTree.refresh.test.tsx new file mode 100644 index 000000000..d5c82e6ff --- /dev/null +++ b/packages/coc/test/server/spa/client/work-items/WorkItemHierarchyTree.refresh.test.tsx @@ -0,0 +1,124 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const mocks = vi.hoisted(() => ({ + tree: vi.fn(), +})); + +vi.mock('../../../../../src/server/spa/client/react/api/cocClient', () => ({ + getSpaCocClient: () => ({ + workItems: { + tree: mocks.tree, + }, + }), + getSpaCocClientErrorMessage: (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback, +})); + +vi.mock('../../../../../src/server/spa/client/react/utils/config', () => ({ + isSessionContextAttachmentsEnabled: () => false, + isWorkItemsSyncEnabled: () => false, +})); + +import { WorkItemProvider, useWorkItems, type WorkItemSummary } from '../../../../../src/server/spa/client/react/contexts/WorkItemContext'; +import { WorkItemHierarchyTree } from '../../../../../src/server/spa/client/react/features/work-items/WorkItemHierarchyTree'; + +let dispatchWorkItemAction: ReturnType['dispatch'] | undefined; + +function makeItem(id: string, title: string): WorkItemSummary { + return { + id, + title, + status: 'created', + type: 'work-item', + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }; +} + +function makeTreeResponse(title: string) { + return { + roots: [ + { + item: makeItem(`item-${title}`, title), + children: [], + }, + ], + total: 1, + }; +} + +function DispatchCapture() { + const { dispatch } = useWorkItems(); + dispatchWorkItemAction = dispatch; + return null; +} + +function renderTree(workspaceId = 'ws-1') { + return render( + + + + , + ); +} + +describe('WorkItemHierarchyTree refresh behavior', () => { + beforeEach(() => { + dispatchWorkItemAction = undefined; + mocks.tree.mockReset(); + mocks.tree.mockResolvedValue(makeTreeResponse('Initial item')); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('refreshes the tree when a work item event arrives for the same workspace', async () => { + renderTree('ws-1'); + + await waitFor(() => expect(mocks.tree).toHaveBeenCalledTimes(1)); + + mocks.tree.mockResolvedValue(makeTreeResponse('Created by tool')); + act(() => { + dispatchWorkItemAction?.({ type: 'WORK_ITEM_ADDED', repoId: 'ws-2', item: makeItem('other', 'Other workspace') }); + }); + await new Promise(resolve => setTimeout(resolve, 350)); + expect(mocks.tree).toHaveBeenCalledTimes(1); + + act(() => { + dispatchWorkItemAction?.({ type: 'WORK_ITEM_ADDED', repoId: 'ws-1', item: makeItem('tool-created', 'Created by tool') }); + }); + + await waitFor(() => expect(mocks.tree).toHaveBeenCalledTimes(2)); + expect(mocks.tree.mock.calls[1][0]).toBe('ws-1'); + expect(await screen.findByText('Created by tool')).toBeInTheDocument(); + }); + + it('uses the toolbar refresh button to reload the tree endpoint', async () => { + renderTree('ws-1'); + + await waitFor(() => expect(mocks.tree).toHaveBeenCalledTimes(1)); + const refreshButton = screen.getByRole('button', { name: 'Refresh hierarchy tree' }); + expect(refreshButton).toHaveTextContent('Refresh'); + expect(refreshButton).not.toBeDisabled(); + + mocks.tree.mockResolvedValue(makeTreeResponse('Manual refresh item')); + fireEvent.click(refreshButton); + + await waitFor(() => expect(mocks.tree).toHaveBeenCalledTimes(2)); + expect(mocks.tree.mock.calls[1][0]).toBe('ws-1'); + expect(await screen.findByText('Manual refresh item')).toBeInTheDocument(); + }); +}); diff --git a/packages/coc/test/spa/react/context/WorkItemContext.test.tsx b/packages/coc/test/spa/react/context/WorkItemContext.test.tsx index fe258f2a0..c29f867b9 100644 --- a/packages/coc/test/spa/react/context/WorkItemContext.test.tsx +++ b/packages/coc/test/spa/react/context/WorkItemContext.test.tsx @@ -21,11 +21,13 @@ function TestConsumer({ repoId = 'repo-1' }: { repoId?: string }) { testDispatch = dispatch; const items = state.workItemsByRepo[repoId] || []; const unseen = state.unseenByRepo[repoId] || []; + const realtimeRevision = state.realtimeRevisionByRepo[repoId] || 0; return (
{items.length} {unseen.length} {unseen.join(',')} + {realtimeRevision}
); } @@ -131,10 +133,20 @@ describe('WorkItemContext: unseen tracking', () => { testDispatch({ type: 'WORK_ITEM_ADDED', repoId: 'repo-1', item: makeItem('w1') }); testDispatch({ type: 'WORK_ITEM_ADDED', repoId: 'repo-1', item: makeItem('w1') }); }); - // Two items in list (context appends), but only one unseen ID + expect(screen.getByTestId('item-count').textContent).toBe('1'); expect(screen.getByTestId('unseen-ids').textContent).toBe('w1'); }); + it('increments realtime revision for workspace-scoped work item events', () => { + renderWithProvider(); + act(() => testDispatch({ type: 'WORK_ITEM_ADDED', repoId: 'repo-1', item: makeItem('w1') })); + expect(screen.getByTestId('realtime-revision').textContent).toBe('1'); + act(() => testDispatch({ type: 'WORK_ITEM_UPDATED', repoId: 'repo-1', item: makeItem('w1', 'planning') })); + expect(screen.getByTestId('realtime-revision').textContent).toBe('2'); + act(() => testDispatch({ type: 'WORK_ITEM_REMOVED', repoId: 'repo-1', id: 'w1' })); + expect(screen.getByTestId('realtime-revision').textContent).toBe('3'); + }); + it('SET_WORK_ITEMS does not affect unseen set', () => { renderWithProvider(); act(() => { From 646d3cf8dd0aa6efc9e99562ff0ba6eff207e85f Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Fri, 12 Jun 2026 05:27:30 +0000 Subject: [PATCH 2/8] Remove obsolete update-work-item bundled skill --- .../bundled-skills/update-work-item/SKILL.md | 126 --------------- .../references/work-item-api.md | 152 ------------------ .../src/skills/bundled-skills-registry.ts | 5 - .../skills/bundled-skills-provider.test.ts | 20 ++- .../bundled-skills/update-work-item/SKILL.md | 124 -------------- .../references/work-item-api.md | 152 ------------------ 6 files changed, 12 insertions(+), 567 deletions(-) delete mode 100644 packages/forge/resources/bundled-skills/update-work-item/SKILL.md delete mode 100644 packages/forge/resources/bundled-skills/update-work-item/references/work-item-api.md delete mode 100644 resources/bundled-skills/update-work-item/SKILL.md delete mode 100644 resources/bundled-skills/update-work-item/references/work-item-api.md diff --git a/packages/forge/resources/bundled-skills/update-work-item/SKILL.md b/packages/forge/resources/bundled-skills/update-work-item/SKILL.md deleted file mode 100644 index 2dd28a747..000000000 --- a/packages/forge/resources/bundled-skills/update-work-item/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: update-work-item -description: Interactively update an existing work item — patch common fields or create a new plan version, then reset status to planning. Use when the user asks to modify, edit, revise, or update a work item. -metadata: - version: "0.0.1" ---- - -# Update Work Item - -Guide the user through updating an existing work-item plan via the `create_update_work_item` tool or CoC REST API. Always look up the work item first, present the complete revised plan, iterate on feedback, then update only when the user confirms. - -## Instructions - -### Phase 1 — Identify Work Item - -1. If the user provides a work item ID (UUID or WI-N number), use it directly. -2. If no ID is provided, list recent work items via the REST API to help the user choose: - - ```powershell - $workspaceId = (Invoke-RestMethod -Uri "http://localhost:4000/api/workspaces").workspaces | - Where-Object { $_.rootPath -eq (git rev-parse --show-toplevel) } | - Select-Object -ExpandProperty id - - Invoke-RestMethod -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items" | - Select-Object -ExpandProperty items | - Select-Object id, workItemNumber, title, status, priority - ``` - -3. Fetch the full work item to understand its current state: - - ```powershell - Invoke-RestMethod -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items/" - ``` - -### Phase 2 — Draft Changes - -4. Based on the user's request, produce a **full revised plan** using the standard template. Do not append raw text to the existing plan body and do not submit a partial diff. - -5. Present the draft changes as an **update summary**: - - ``` - ✏️ Work Item Update Draft - ────────────────── - ID: (WI-) - Plan (new version v): - - ────────────────── - Confirm to update, or give feedback to refine. - ``` - -### Phase 3 — Refine - -6. If the user provides corrections, update the draft and re-present the summary. -7. Repeat until the user confirms (e.g. "looks good", "update it", "yes", "confirm"). - -### Phase 4 — Update - -8. Call the `create_update_work_item` tool with the confirmed complete revised plan: - - ``` - create_update_work_item({ - target: "", - plan: "", - summary: "" // optional - }) - ``` - - The tool saves a new plan version, resets status to `planning`, opens a change record, and broadcasts a dashboard update. - - If the `create_update_work_item` tool is unavailable, fall back to the REST API with `PATCH /api/workspaces/:workspaceId/work-items/:workItemId` and a `plan` object: - - ```powershell - $body = @{ - plan = @{ - content = "" - resolvedBy = "ai" - summary = "" - } - status = "planning" - } | ConvertTo-Json -Depth 5 - - Invoke-RestMethod ` - -Method Patch ` - -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items/" ` - -ContentType "application/json" ` - -Body $body - ``` - - For plan-only REST workflows, the dedicated endpoint is `PUT /api/workspaces/:workspaceId/work-items/:workItemId/plan`: - - ```powershell - $planBody = @{ - content = "" - resolvedBy = "ai" - } | ConvertTo-Json -Depth 5 - - Invoke-RestMethod ` - -Method Put ` - -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items//plan" ` - -ContentType "application/json" ` - -Body $planBody - ``` - -9. On success, report to the user: - - ``` - ✅ Work item updated! - ID: (WI-) - Title: - Status: planning - Plan: v<version> (if plan was updated, otherwise "unchanged") - - View it in the Work Items tab of the CoC dashboard. - ``` - -## Edge Cases - -- **Work item not found**: If the provided ID doesn't match any work item, list recent items and ask the user to confirm. -- **No plan change**: If the user's request does not produce a changed complete plan, do not call the tool. -- **Plan only**: Call the tool with `target` and `plan`; the plan must be the complete revised Markdown body. -- **Multiple workspaces**: Match the workspace whose `rootPath` most closely matches the current working directory. -- **Server not running**: If `localhost:4000` is unreachable, tell the user: "Start the CoC server with `coc serve` then try again." - -## References - -- [Work Item API Reference](references/work-item-api.md) diff --git a/packages/forge/resources/bundled-skills/update-work-item/references/work-item-api.md b/packages/forge/resources/bundled-skills/update-work-item/references/work-item-api.md deleted file mode 100644 index d42664a45..000000000 --- a/packages/forge/resources/bundled-skills/update-work-item/references/work-item-api.md +++ /dev/null @@ -1,152 +0,0 @@ -# Work Item API Reference - -Base URL: `http://localhost:4000` (default CoC server port) - -## List Workspaces - -``` -GET /api/workspaces -``` - -Response: `{ workspaces: WorkspaceInfo[] }` - -Each `WorkspaceInfo` has: `id`, `name`, `rootPath`, `color?`, `remoteUrl?` - ---- - -## List Work Items - -``` -GET /api/workspaces/:workspaceId/work-items -``` - -Query parameters: `status`, `source`, `priority`, `tags`, `type` (all optional). - -Response: `{ items: WorkItemIndexEntry[] }` - -Each index entry has: `id`, `workItemNumber`, `title`, `status`, `type`, `priority`, `planVersion`, `createdAt`, `updatedAt`, `tags` - ---- - -## Get Work Item - -``` -GET /api/workspaces/:workspaceId/work-items/:workItemId -``` - -Response: Full `WorkItem` object including `id`, `workItemNumber`, `title`, `description`, `status`, `plan`, etc. - ---- - -## Create Work Item - -``` -POST /api/workspaces/:workspaceId/work-items -Content-Type: application/json -``` - -**Request body:** - -| Field | Type | Required | Description | -|---|---|---|---| -| `title` | string | ✅ | Short descriptive title | -| `description` | string | | Markdown description | -| `source` | `"chat" \| "manual" \| "schedule"` | | Defaults to `"manual"` | -| `priority` | `"high" \| "normal" \| "low"` | | Defaults to `"normal"` | -| `tags` | string[] | | Optional labels | -| `autoExecute` | boolean | | Auto-run when status reaches `readyToExecute` | -| `plan.content` | string | | Markdown plan body | -| `plan.resolvedBy` | `"ai" \| "user"` | | Who generated the plan | - -**Response: 201** — Full `WorkItem` object including `id`, `status: "created"`. - ---- - -## Update Work Item - -Patches fields on an existing work item. Only the provided fields are changed. - -``` -PATCH /api/workspaces/:workspaceId/work-items/:workItemId -Content-Type: application/json -``` - -**Updatable fields:** `title`, `description`, `status`, `priority`, `tags`, `autoExecute`, `reviewComments`, and `plan`. - -`plan` accepts `{ content, resolvedBy?, summary? }`. When `plan.content` is present, the server saves the next plan version, updates the current work-item plan, opens a change record, broadcasts `work-item-updated`, and returns the updated work item. - -**Response: 200** — Updated `WorkItem` object. - ---- - -## Update Plan (creates a new plan version) - -Saves a new plan version and updates the work item's current plan. - -``` -PUT /api/workspaces/:workspaceId/work-items/:workItemId/plan -Content-Type: application/json -``` - -**Request body:** - -| Field | Type | Required | Description | -|---|---|---|---| -| `content` | string | ✅ | Markdown plan content | -| `resolvedBy` | `"ai" \| "user"` | | Who generated the plan (default: `"user"`) | -| `summary` | string | | Short description of what changed | - -**Response: 200** — `{ plan, version }` with incremented `plan.version`. - ---- - -## Execute Work Item - -Queues the work item as an AI background task. Does **not** run it in the current session. - -``` -POST /api/workspaces/:workspaceId/work-items/:workItemId/execute -Content-Type: application/json - -{} -``` - -**Response: 200** — `{ taskId: string }` - ---- - -## Status Lifecycle - -``` -created → planning → readyToExecute → executing → aiDone → done | failed -``` - -Terminal states (`done`, `failed`) can be re-opened back to `created`. - -The `create_update_work_item` tool saves full revised plans as new versions and resets status to `planning` after a successful plan update. - ---- - -## Standard Plan Template - -```markdown -## Objective - -<one or two sentences stating the goal> - -## Background - -<context and motivation> - -## Steps - -- [ ] <step 1> - -## Acceptance Criteria - -- [ ] <testable condition> - -## Notes - -_Additional constraints, links, or follow-ups._ -``` diff --git a/packages/forge/src/skills/bundled-skills-registry.ts b/packages/forge/src/skills/bundled-skills-registry.ts index fddfb0ae3..d1ea5ee64 100644 --- a/packages/forge/src/skills/bundled-skills-registry.ts +++ b/packages/forge/src/skills/bundled-skills-registry.ts @@ -44,11 +44,6 @@ export const BUNDLED_SKILLS_REGISTRY: readonly BundledSkill[] = [ description: 'Distill recent CoC chat histories into knowledge-base skill improvements, proposing additions, updates, and removals', relativePath: 'kb-refresh', }, - { - name: 'update-work-item', - description: 'Interactively update an existing work item — patch common fields or create a new plan version, then reset status to planning', - relativePath: 'update-work-item', - }, { name: 'fresh-written', description: 'Rewrite documents, plans, and notes as if authored fresh each iteration — produce only the final intended state, never patch deltas on top of the previous version', diff --git a/packages/forge/test/skills/bundled-skills-provider.test.ts b/packages/forge/test/skills/bundled-skills-provider.test.ts index f91fdf371..5a60a2d58 100644 --- a/packages/forge/test/skills/bundled-skills-provider.test.ts +++ b/packages/forge/test/skills/bundled-skills-provider.test.ts @@ -33,26 +33,30 @@ describe('getBundledSkills', () => { fs.rmdirSync(tmpDir); }); - it('does not bundle create-work-item or create-bug (superseded by the create_update_work_item tool)', () => { - // Regression guard: these two skills were removed from the bundle in favor of + it('does not bundle obsolete work-item skills (superseded by the create_update_work_item tool)', () => { + // Regression guard: these skills were removed from the bundle in favor of // the unified create_update_work_item LLM tool. They must not reappear in the // registry or as resolved bundled-skill payloads. + const removedSkills = ['create-work-item', 'create-bug', 'update-work-item']; const registryNames = getBundledSkillsRegistry().map(s => s.name); - expect(registryNames).not.toContain('create-work-item'); - expect(registryNames).not.toContain('create-bug'); + for (const removedSkill of removedSkills) { + expect(registryNames).not.toContain(removedSkill); + } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'install-')); try { const names = getBundledSkills(tmpDir).map(s => s.name); - expect(names).not.toContain('create-work-item'); - expect(names).not.toContain('create-bug'); + for (const removedSkill of removedSkills) { + expect(names).not.toContain(removedSkill); + } } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } const bundledPath = getBundledSkillsPath(); - expect(fs.existsSync(path.join(bundledPath, 'create-work-item'))).toBe(false); - expect(fs.existsSync(path.join(bundledPath, 'create-bug'))).toBe(false); + for (const removedSkill of removedSkills) { + expect(fs.existsSync(path.join(bundledPath, removedSkill))).toBe(false); + } }); it('marks skills as alreadyExists when they are installed', () => { diff --git a/resources/bundled-skills/update-work-item/SKILL.md b/resources/bundled-skills/update-work-item/SKILL.md deleted file mode 100644 index fe51b4b1a..000000000 --- a/resources/bundled-skills/update-work-item/SKILL.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -name: update-work-item -description: Interactively update an existing work item — patch common fields or create a new plan version, then reset status to planning. Use when the user asks to modify, edit, revise, or update a work item. ---- - -# Update Work Item - -Guide the user through updating an existing work-item plan via the `create_update_work_item` tool or CoC REST API. Always look up the work item first, present the complete revised plan, iterate on feedback, then update only when the user confirms. - -## Instructions - -### Phase 1 — Identify Work Item - -1. If the user provides a work item ID (UUID or WI-N number), use it directly. -2. If no ID is provided, list recent work items via the REST API to help the user choose: - - ```powershell - $workspaceId = (Invoke-RestMethod -Uri "http://localhost:4000/api/workspaces").workspaces | - Where-Object { $_.rootPath -eq (git rev-parse --show-toplevel) } | - Select-Object -ExpandProperty id - - Invoke-RestMethod -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items" | - Select-Object -ExpandProperty items | - Select-Object id, workItemNumber, title, status, priority - ``` - -3. Fetch the full work item to understand its current state: - - ```powershell - Invoke-RestMethod -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items/<workItemId>" - ``` - -### Phase 2 — Draft Changes - -4. Based on the user's request, produce a **full revised plan** using the standard template. Do not append raw text to the existing plan body and do not submit a partial diff. - -5. Present the draft changes as an **update summary**: - - ``` - ✏️ Work Item Update Draft - ────────────────── - ID: <id> (WI-<number>) - Plan (new version v<N+1>): - <complete revised plan markdown> - ────────────────── - Confirm to update, or give feedback to refine. - ``` - -### Phase 3 — Refine - -6. If the user provides corrections, update the draft and re-present the summary. -7. Repeat until the user confirms (e.g. "looks good", "update it", "yes", "confirm"). - -### Phase 4 — Update - -8. Call the `create_update_work_item` tool with the confirmed complete revised plan: - - ``` - create_update_work_item({ - target: "<id or WI-N>", - plan: "<complete revised plan markdown>", - summary: "<short summary of the plan change>" // optional - }) - ``` - - The tool saves a new plan version, resets status to `planning`, opens a change record, and broadcasts a dashboard update. - - If the `create_update_work_item` tool is unavailable, fall back to the REST API with `PATCH /api/workspaces/:workspaceId/work-items/:workItemId` and a `plan` object: - - ```powershell - $body = @{ - plan = @{ - content = "<complete revised plan markdown>" - resolvedBy = "ai" - summary = "<short summary of the plan change>" - } - status = "planning" - } | ConvertTo-Json -Depth 5 - - Invoke-RestMethod ` - -Method Patch ` - -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items/<workItemId>" ` - -ContentType "application/json" ` - -Body $body - ``` - - For plan-only REST workflows, the dedicated endpoint is `PUT /api/workspaces/:workspaceId/work-items/:workItemId/plan`: - - ```powershell - $planBody = @{ - content = "<new plan markdown>" - resolvedBy = "ai" - } | ConvertTo-Json -Depth 5 - - Invoke-RestMethod ` - -Method Put ` - -Uri "http://localhost:4000/api/workspaces/$workspaceId/work-items/<workItemId>/plan" ` - -ContentType "application/json" ` - -Body $planBody - ``` - -9. On success, report to the user: - - ``` - ✅ Work item updated! - ID: <id> (WI-<number>) - Title: <title> - Status: planning - Plan: v<version> (if plan was updated, otherwise "unchanged") - - View it in the Work Items tab of the CoC dashboard. - ``` - -## Edge Cases - -- **Work item not found**: If the provided ID doesn't match any work item, list recent items and ask the user to confirm. -- **No plan change**: If the user's request does not produce a changed complete plan, do not call the tool. -- **Plan only**: Call the tool with `target` and `plan`; the plan must be the complete revised Markdown body. -- **Multiple workspaces**: Match the workspace whose `rootPath` most closely matches the current working directory. -- **Server not running**: If `localhost:4000` is unreachable, tell the user: "Start the CoC server with `coc serve` then try again." - -## References - -- [Work Item API Reference](references/work-item-api.md) diff --git a/resources/bundled-skills/update-work-item/references/work-item-api.md b/resources/bundled-skills/update-work-item/references/work-item-api.md deleted file mode 100644 index d42664a45..000000000 --- a/resources/bundled-skills/update-work-item/references/work-item-api.md +++ /dev/null @@ -1,152 +0,0 @@ -# Work Item API Reference - -Base URL: `http://localhost:4000` (default CoC server port) - -## List Workspaces - -``` -GET /api/workspaces -``` - -Response: `{ workspaces: WorkspaceInfo[] }` - -Each `WorkspaceInfo` has: `id`, `name`, `rootPath`, `color?`, `remoteUrl?` - ---- - -## List Work Items - -``` -GET /api/workspaces/:workspaceId/work-items -``` - -Query parameters: `status`, `source`, `priority`, `tags`, `type` (all optional). - -Response: `{ items: WorkItemIndexEntry[] }` - -Each index entry has: `id`, `workItemNumber`, `title`, `status`, `type`, `priority`, `planVersion`, `createdAt`, `updatedAt`, `tags` - ---- - -## Get Work Item - -``` -GET /api/workspaces/:workspaceId/work-items/:workItemId -``` - -Response: Full `WorkItem` object including `id`, `workItemNumber`, `title`, `description`, `status`, `plan`, etc. - ---- - -## Create Work Item - -``` -POST /api/workspaces/:workspaceId/work-items -Content-Type: application/json -``` - -**Request body:** - -| Field | Type | Required | Description | -|---|---|---|---| -| `title` | string | ✅ | Short descriptive title | -| `description` | string | | Markdown description | -| `source` | `"chat" \| "manual" \| "schedule"` | | Defaults to `"manual"` | -| `priority` | `"high" \| "normal" \| "low"` | | Defaults to `"normal"` | -| `tags` | string[] | | Optional labels | -| `autoExecute` | boolean | | Auto-run when status reaches `readyToExecute` | -| `plan.content` | string | | Markdown plan body | -| `plan.resolvedBy` | `"ai" \| "user"` | | Who generated the plan | - -**Response: 201** — Full `WorkItem` object including `id`, `status: "created"`. - ---- - -## Update Work Item - -Patches fields on an existing work item. Only the provided fields are changed. - -``` -PATCH /api/workspaces/:workspaceId/work-items/:workItemId -Content-Type: application/json -``` - -**Updatable fields:** `title`, `description`, `status`, `priority`, `tags`, `autoExecute`, `reviewComments`, and `plan`. - -`plan` accepts `{ content, resolvedBy?, summary? }`. When `plan.content` is present, the server saves the next plan version, updates the current work-item plan, opens a change record, broadcasts `work-item-updated`, and returns the updated work item. - -**Response: 200** — Updated `WorkItem` object. - ---- - -## Update Plan (creates a new plan version) - -Saves a new plan version and updates the work item's current plan. - -``` -PUT /api/workspaces/:workspaceId/work-items/:workItemId/plan -Content-Type: application/json -``` - -**Request body:** - -| Field | Type | Required | Description | -|---|---|---|---| -| `content` | string | ✅ | Markdown plan content | -| `resolvedBy` | `"ai" \| "user"` | | Who generated the plan (default: `"user"`) | -| `summary` | string | | Short description of what changed | - -**Response: 200** — `{ plan, version }` with incremented `plan.version`. - ---- - -## Execute Work Item - -Queues the work item as an AI background task. Does **not** run it in the current session. - -``` -POST /api/workspaces/:workspaceId/work-items/:workItemId/execute -Content-Type: application/json - -{} -``` - -**Response: 200** — `{ taskId: string }` - ---- - -## Status Lifecycle - -``` -created → planning → readyToExecute → executing → aiDone → done | failed -``` - -Terminal states (`done`, `failed`) can be re-opened back to `created`. - -The `create_update_work_item` tool saves full revised plans as new versions and resets status to `planning` after a successful plan update. - ---- - -## Standard Plan Template - -```markdown -## Objective - -<one or two sentences stating the goal> - -## Background - -<context and motivation> - -## Steps - -- [ ] <step 1> - -## Acceptance Criteria - -- [ ] <testable condition> - -## Notes - -_Additional constraints, links, or follow-ups._ -``` From 79fdaa611c06104170f1e877a649a83c5c68351e Mon Sep 17 00:00:00 2001 From: Yiheng Tao <ruby092977@gmail.com> Date: Fri, 12 Jun 2026 05:34:22 +0000 Subject: [PATCH 3/8] Compact work item detail header --- .../coc-knowledge/references/dashboard-spa.md | 5 + .../features/work-items/WorkItemDetail.tsx | 195 ++++++++---------- ...WorkItemDetail.inline-edit-render.test.tsx | 11 + 3 files changed, 107 insertions(+), 104 deletions(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 82182200c..85524806f 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -133,6 +133,11 @@ turning local Work Items and Goals into the command-center planning/execution surface. The SPA receives it as `workItemsWorkflowEnabled` from bootstrap config and `GET /api/config/runtime`; use `isWorkItemsWorkflowEnabled()` for UI gates so legacy Work Items and Chat behavior remains unchanged while the flag is off. +Work Item detail renders the editable title in the top header row and keeps +type, status, mirror, plan version, priority, updated time, parent, tags, +auto-execute, source, and primary actions in the compact properties row directly +below it; the scrollable body starts with description/plan content rather than a +separate metadata card. When the flag is on, the local create dialog exposes a Work Item vs Goal type selector for title-first shell creation even when hierarchy mode is off; existing bug and hierarchy-type creation paths keep their prior behavior. Saved local-only diff --git a/packages/coc/src/server/spa/client/react/features/work-items/WorkItemDetail.tsx b/packages/coc/src/server/spa/client/react/features/work-items/WorkItemDetail.tsx index 6846150ff..317cc0d3d 100644 --- a/packages/coc/src/server/spa/client/react/features/work-items/WorkItemDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/work-items/WorkItemDetail.tsx @@ -1016,39 +1016,37 @@ export function WorkItemDetail({ workItemId, workspaceId, onBack, onExecuted, on className="border-b border-[#d0d7de] dark:border-[#474749] bg-white dark:bg-[#1e1e1e] grid gap-2 shrink-0" style={{ padding: compactWorkflowLayout ? '10px 12px' : '12px 16px' }} > - {/* Breadcrumbs */} - <div className="flex items-center gap-1.5 text-[12px] text-[#656d76] dark:text-[#999] min-w-0" id="crumbs"> - {onBack && ( - <button onClick={guardedBack} className="text-[#656d76] hover:text-[#1f2328] dark:hover:text-[#ccc] shrink-0" data-testid="work-item-back-btn" aria-label="Back"> - ← - </button> - )} - {item.workItemNumber != null && ( - <span className="font-mono text-[#656d76] dark:text-[#999]" data-testid="work-item-detail-number"> - {typePrefix}-{item.workItemNumber} - </span> - )} - {isDirty && ( - <span className="inline-flex items-center rounded-full px-2 py-px text-[11px] leading-[1.4] border border-amber-300 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700 dark:text-amber-400 whitespace-nowrap" data-testid="wi-dirty-indicator"> - unsaved - </span> - )} - </div> - - {/* Title row with Save + Run */} + {/* Title row */} <div - className="grid gap-2 items-start" + className="grid gap-2 items-center" style={{ gridTemplateColumns: compactWorkflowLayout ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto' }} > - <input - type="text" - className="w-full border border-[#d0d7de] dark:border-[#555] rounded-md bg-white dark:bg-[#1e1e1e] text-[#1f2328] dark:text-[#cccccc] px-2 py-[5px] text-[18px] leading-[1.25] font-semibold tracking-[-0.01em] outline-none focus:border-[#0969da] focus:shadow-[0_0_0_3px_rgba(9,105,218,0.16)]" - value={d.title} - onChange={e => updateDraft('title', e.target.value)} - disabled={saving} - data-testid="wi-title-input" - aria-label="Title" - /> + <div className="flex items-center gap-2 min-w-0"> + {onBack && ( + <button onClick={guardedBack} className="text-[#656d76] hover:text-[#1f2328] dark:hover:text-[#ccc] shrink-0" data-testid="work-item-back-btn" aria-label="Back"> + ← + </button> + )} + {item.workItemNumber != null && ( + <span className="font-mono text-[12px] text-[#656d76] dark:text-[#999] shrink-0" data-testid="work-item-detail-number"> + {typePrefix}-{item.workItemNumber} + </span> + )} + <input + type="text" + className="min-w-0 flex-1 border border-[#d0d7de] dark:border-[#555] rounded-md bg-white dark:bg-[#1e1e1e] text-[#1f2328] dark:text-[#cccccc] px-2 py-[5px] text-[18px] leading-[1.25] font-semibold outline-none focus:border-[#0969da] focus:shadow-[0_0_0_3px_rgba(9,105,218,0.16)]" + value={d.title} + onChange={e => updateDraft('title', e.target.value)} + disabled={saving} + data-testid="wi-title-input" + aria-label="Title" + /> + {isDirty && ( + <span className="inline-flex items-center rounded-full px-2 py-px text-[11px] leading-[1.4] border border-amber-300 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700 dark:text-amber-400 whitespace-nowrap" data-testid="wi-dirty-indicator"> + unsaved + </span> + )} + </div> <button className={cn( 'inline-flex items-center justify-center gap-[5px] border border-[rgba(31,35,40,0.15)] rounded-md bg-[#1f883d] text-white px-2 text-[12px] font-semibold tracking-[0.02em] whitespace-nowrap hover:bg-[#1a7f37] disabled:opacity-50 dark:bg-[#238636] dark:hover:bg-[#2ea043]', @@ -1065,8 +1063,8 @@ export function WorkItemDetail({ workItemId, workspaceId, onBack, onExecuted, on {!compactWorkflowLayout && <span />} </div> - {/* Meta grid + inline actions */} - <div className={cn('flex items-center gap-1.5 flex-wrap', compactWorkflowLayout && 'gap-2')}> + {/* Properties row + inline actions */} + <div className={cn('flex items-center gap-1.5 flex-wrap text-[11px] leading-[1.35]', compactWorkflowLayout && 'gap-2')} data-testid="work-item-properties-row"> <span className={cn('inline-flex items-center rounded-full text-[11px] leading-[1.25] px-[7px] py-px border whitespace-nowrap', typePillClass)}> {TYPE_LABELS[effectiveType as WorkItemTypeLabel] ?? effectiveType} </span> @@ -1110,6 +1108,67 @@ export function WorkItemDetail({ workItemId, workspaceId, onBack, onExecuted, on <span className="text-[11px] leading-[1.35] text-[#656d76] dark:text-[#999] truncate min-w-0 flex-1"> Updated {formatRelativeTime(item.updatedAt)} </span> + {hierarchyEnabled && effectiveType !== 'epic' ? ( + <span className="flex items-center gap-1 shrink-0" data-testid="work-item-parent-edit"> + <strong className="text-[#1f2328] dark:text-[#cccccc]">Parent</strong> + <span className="text-[#656d76] dark:text-[#999] flex items-center gap-1"> + {d.parentId + ? <span className="font-mono">{d.parentId.slice(0, 8)}...</span> + : <span className="italic">-</span> + } + <button className="text-[#0969da] hover:underline bg-transparent border-0 cursor-pointer p-0 text-[11px]" onClick={() => setShowParentPicker(true)} disabled={saving} data-testid="wi-edit-parent-btn" type="button"> + {d.parentId ? 'Change' : 'Set'} + </button> + </span> + </span> + ) : item.parentId ? ( + <span className="flex items-center gap-1 shrink-0" data-testid="work-item-parent-info"> + <strong className="text-[#1f2328] dark:text-[#cccccc]">Parent</strong> + <span className="text-[#656d76] dark:text-[#999] font-mono">{item.parentId.slice(0, 8)}...</span> + </span> + ) : null} + <span className="flex items-center gap-1 min-w-[120px]"> + <strong className="text-[#1f2328] dark:text-[#cccccc] shrink-0">Tags</strong> + <span className="flex gap-0.5 items-center flex-wrap min-w-0"> + {parseTags(d.tags).length > 0 ? ( + parseTags(d.tags).map(tag => ( + <span key={tag} className="inline-flex items-center h-[18px] px-1.5 rounded-full border border-[#d0d7de] dark:border-[#555] bg-white dark:bg-transparent text-[10px] text-[#656d76] dark:text-[#999]">{tag}</span> + )) + ) : null} + <input + type="text" + className="min-w-[60px] w-16 text-[11px] px-0.5 py-0 border-0 outline-none bg-transparent text-[#1f2328] dark:text-[#cccccc] placeholder-[#656d76]" + value={d.tags} + onChange={e => updateDraft('tags', e.target.value)} + disabled={saving} + placeholder="add tags" + data-testid="wi-tags-input" + aria-label="Tags" + /> + </span> + </span> + {!isContainer && ( + <label className="flex items-center gap-1 cursor-pointer shrink-0" title="Auto-execute when status reaches Ready to Execute" data-testid="work-item-auto-execute-toggle"> + <input + type="checkbox" + checked={item.autoExecute ?? false} + onChange={async (e) => { + try { + await getSpaCocClient().workItems.update(workspaceId, workItemId, { autoExecute: e.target.checked }); + await fetchItem(); + } catch (err: any) { + setError(err.message || 'Failed to update'); + } + }} + className="rounded" + /> + <strong className="text-[#1f2328] dark:text-[#cccccc]">Auto</strong> + </label> + )} + <span className="flex items-center gap-1 text-[#656d76] dark:text-[#999] shrink-0"> + <strong className="text-[#1f2328] dark:text-[#cccccc]">Src</strong> + {item.source === 'manual' ? 'Manual' : item.source === 'chat' ? 'Chat' : 'Schedule'} + </span> {/* Inline action icons — compact, right-aligned */} <div className={cn( @@ -1375,78 +1434,6 @@ export function WorkItemDetail({ workItemId, workspaceId, onBack, onExecuted, on </article> )} - {/* Compact Metadata panel */} - <article className="border border-[#d0d7de] dark:border-[#474749] rounded-md overflow-hidden"> - <div className="px-[10px] py-[6px] bg-[#f6f8fa] dark:bg-[#252526] flex items-center gap-3 flex-wrap text-[11px] leading-[1.35]"> - {/* Parent */} - {hierarchyEnabled && effectiveType !== 'epic' ? ( - <span className="flex items-center gap-1" data-testid="work-item-parent-edit"> - <strong className="text-[#1f2328] dark:text-[#cccccc]">Parent</strong> - <span className="text-[#656d76] dark:text-[#999] flex items-center gap-1"> - {d.parentId - ? <span className="font-mono">{d.parentId.slice(0, 8)}…</span> - : <span className="italic">—</span> - } - <button className="text-[#0969da] hover:underline bg-transparent border-0 cursor-pointer p-0 text-[11px]" onClick={() => setShowParentPicker(true)} disabled={saving} data-testid="wi-edit-parent-btn" type="button"> - {d.parentId ? 'Change' : 'Set'} - </button> - </span> - </span> - ) : item.parentId ? ( - <span className="flex items-center gap-1" data-testid="work-item-parent-info"> - <strong className="text-[#1f2328] dark:text-[#cccccc]">Parent</strong> - <span className="text-[#656d76] dark:text-[#999] font-mono">{item.parentId.slice(0, 8)}…</span> - </span> - ) : null} - {/* Tags */} - <span className="flex items-center gap-1 min-w-0"> - <strong className="text-[#1f2328] dark:text-[#cccccc] shrink-0">Tags</strong> - <span className="flex gap-0.5 items-center flex-wrap min-w-0"> - {parseTags(d.tags).length > 0 ? ( - parseTags(d.tags).map(tag => ( - <span key={tag} className="inline-flex items-center h-[18px] px-1.5 rounded-full border border-[#d0d7de] dark:border-[#555] bg-white dark:bg-transparent text-[10px] text-[#656d76] dark:text-[#999]">{tag}</span> - )) - ) : null} - <input - type="text" - className="min-w-[60px] w-16 text-[11px] px-0.5 py-0 border-0 outline-none bg-transparent text-[#1f2328] dark:text-[#cccccc] placeholder-[#656d76]" - value={d.tags} - onChange={e => updateDraft('tags', e.target.value)} - disabled={saving} - placeholder="add tags" - data-testid="wi-tags-input" - aria-label="Tags" - /> - </span> - </span> - {/* Auto-execute (leaf items only) */} - {!isContainer && ( - <label className="flex items-center gap-1 cursor-pointer shrink-0" title="Auto-execute when status reaches Ready to Execute" data-testid="work-item-auto-execute-toggle"> - <input - type="checkbox" - checked={item.autoExecute ?? false} - onChange={async (e) => { - try { - await getSpaCocClient().workItems.update(workspaceId, workItemId, { autoExecute: e.target.checked }); - await fetchItem(); - } catch (err: any) { - setError(err.message || 'Failed to update'); - } - }} - className="rounded" - /> - <strong className="text-[#1f2328] dark:text-[#cccccc]">Auto</strong> - </label> - )} - {/* Source */} - <span className="flex items-center gap-1 text-[#656d76] dark:text-[#999] shrink-0"> - <strong className="text-[#1f2328] dark:text-[#cccccc]">Src</strong> - {item.source === 'manual' ? 'Manual' : item.source === 'chat' ? 'Chat' : 'Schedule'} - </span> - </div> - </article> - - {/* Review section (aiDone only) */} {isAiDone && ( <section className="bg-purple-50 dark:bg-purple-900/10 border border-purple-200 dark:border-purple-800 rounded-lg p-3" data-testid="work-item-review-section"> diff --git a/packages/coc/test/spa/react/repos/WorkItemDetail.inline-edit-render.test.tsx b/packages/coc/test/spa/react/repos/WorkItemDetail.inline-edit-render.test.tsx index 92eccd7fc..5069b11cb 100644 --- a/packages/coc/test/spa/react/repos/WorkItemDetail.inline-edit-render.test.tsx +++ b/packages/coc/test/spa/react/repos/WorkItemDetail.inline-edit-render.test.tsx @@ -184,6 +184,17 @@ describe('WorkItemDetail inline editing (render)', () => { expect(screen.queryByTestId('wi-edit-btn')).toBeNull(); }); + it('renders editable properties in the compact header row', async () => { + renderDetail(); + await screen.findByTestId('wi-title-input'); + + const propertiesRow = screen.getByTestId('work-item-properties-row'); + expect(propertiesRow.contains(screen.getByTestId('work-item-status-select'))).toBe(true); + expect(propertiesRow.contains(screen.getByTestId('wi-priority-select'))).toBe(true); + expect(propertiesRow.contains(screen.getByTestId('wi-tags-input'))).toBe(true); + expect(screen.getByTestId('work-item-detail-content').contains(propertiesRow)).toBe(false); + }); + it('AC-04: dirty indicator appears once a field changes', async () => { renderDetail(); const title = await screen.findByTestId('wi-title-input'); From f6835ee3c07985b2ebb04ab0b88d103160b130a8 Mon Sep 17 00:00:00 2001 From: Yiheng Tao <ruby092977@gmail.com> Date: Fri, 12 Jun 2026 06:14:28 +0000 Subject: [PATCH 4/8] feat(dream): bundle analyzer/critic system prompts as the `dream` skill (AC-01/03 foundation) Move the dream analyzer and critic system prompts into a single bundled `dream` skill (SKILL.md with `## Section: analyzer` and `## Section: critic`) and add the server-side resolution plumbing, ahead of deleting the inline constants in a follow-up. - Add packages/forge/resources/bundled-skills/dream/SKILL.md, generated byte-for-byte from ANALYZER_SYSTEM_PROMPT / CRITIC_SYSTEM_PROMPT; the one analyzer interpolation becomes a `{{dreamCardCategories}}` placeholder. - Register `dream` in BUNDLED_SKILLS_REGISTRY. - Add a generic `extractSkillSection` `## Section:` extractor in forge, exported from the skills barrel. - Add a dream prompt resolver (coc) that resolves the skill section text and fills the categories placeholder from DREAM_CARD_CATEGORIES. - AC-03 parity test asserts the resolved analyzer/critic prompts equal the former constant text (fixture captured from source), plus forge unit tests for the extractor and the bundled skill structure. Constants are still in use; AC-02 (rewire + delete) follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../server/dreams/dream-prompt-resolver.ts | 60 ++++++++++++++ .../test/server/dream-prompt-resolver.test.ts | 49 ++++++++++++ .../dream-system-prompts.fixture.json | 4 + .../resources/bundled-skills/dream/SKILL.md | 79 +++++++++++++++++++ .../src/skills/bundled-skills-registry.ts | 5 ++ packages/forge/src/skills/index.ts | 1 + packages/forge/src/skills/skill-section.ts | 71 +++++++++++++++++ .../forge/test/skills/dream-skill.test.ts | 48 +++++++++++ .../forge/test/skills/skill-section.test.ts | 52 ++++++++++++ 9 files changed, 369 insertions(+) create mode 100644 packages/coc/src/server/dreams/dream-prompt-resolver.ts create mode 100644 packages/coc/test/server/dream-prompt-resolver.test.ts create mode 100644 packages/coc/test/server/fixtures/dream-system-prompts.fixture.json create mode 100644 packages/forge/resources/bundled-skills/dream/SKILL.md create mode 100644 packages/forge/src/skills/skill-section.ts create mode 100644 packages/forge/test/skills/dream-skill.test.ts create mode 100644 packages/forge/test/skills/skill-section.test.ts diff --git a/packages/coc/src/server/dreams/dream-prompt-resolver.ts b/packages/coc/src/server/dreams/dream-prompt-resolver.ts new file mode 100644 index 000000000..9a58e8900 --- /dev/null +++ b/packages/coc/src/server/dreams/dream-prompt-resolver.ts @@ -0,0 +1,60 @@ +/** + * Dream system-prompt resolver. + * + * The analyzer and critic system prompts live in the bundled `dream` skill + * (`SKILL.md` with `## Section: analyzer` and `## Section: critic`) rather than + * as inline TypeScript constants. At runtime the dream step resolves the skill + * section text server-side and uses it verbatim as the system prompt. + * + * The analyzer section carries a single `{{dreamCardCategories}}` placeholder + * which is filled from `DREAM_CARD_CATEGORIES` so the assembled prompt stays + * byte-for-byte identical to the former constant. + */ + +import * as path from 'path'; +import { extractSkillSection, resolveSkillSync } from '@plusplusoneplusplus/forge'; +import type { DreamInternalProcessPurpose } from './dream-internal-process'; +import { DREAM_CARD_CATEGORIES } from './types'; + +/** Name of the bundled skill that holds the dream system prompts. */ +export const DREAM_SKILL_NAME = 'dream'; + +/** Placeholder token in the analyzer section, filled from DREAM_CARD_CATEGORIES. */ +export const DREAM_CARD_CATEGORIES_PLACEHOLDER = '{{dreamCardCategories}}'; + +/** + * Apply runtime placeholder substitution to a raw dream skill section so the + * result matches the historical constant text exactly. + */ +export function assembleDreamSystemPrompt(sectionText: string): string { + return sectionText.split(DREAM_CARD_CATEGORIES_PLACEHOLDER).join(DREAM_CARD_CATEGORIES.join(', ')); +} + +/** + * Resolve a dream system prompt from raw SKILL.md content. + * + * @param content Raw SKILL.md content (frontmatter may or may not be present). + * @param section Which section to resolve (`analyzer` or `critic`). + */ +export function resolveDreamSystemPromptFromContent( + content: string, + section: DreamInternalProcessPurpose, +): string { + return assembleDreamSystemPrompt(extractSkillSection(content, section)); +} + +/** + * Resolve a dream system prompt from the installed skills directory + * (`<dataDir>/skills/dream/SKILL.md`). + * + * There is no inline fallback: if the skill is missing the underlying + * resolver throws, which surfaces as a natural step error. + */ +export function resolveDreamSystemPrompt( + section: DreamInternalProcessPurpose, + options: { dataDir: string }, +): string { + const skillsDir = path.join(options.dataDir, 'skills'); + const content = resolveSkillSync(DREAM_SKILL_NAME, options.dataDir, skillsDir); + return resolveDreamSystemPromptFromContent(content, section); +} diff --git a/packages/coc/test/server/dream-prompt-resolver.test.ts b/packages/coc/test/server/dream-prompt-resolver.test.ts new file mode 100644 index 000000000..303e75725 --- /dev/null +++ b/packages/coc/test/server/dream-prompt-resolver.test.ts @@ -0,0 +1,49 @@ +/** + * Byte-identical parity tests for the dream system prompts. + * + * The analyzer and critic system prompts were migrated out of inline TypeScript + * constants into the bundled `dream` skill. These tests assert the resolver + * reproduces the exact former constant text (captured in the fixture, including + * the resolved `DREAM_CARD_CATEGORIES` list) so the model still receives a + * byte-for-byte identical system prompt. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { getBundledSkillsPath } from '@plusplusoneplusplus/forge'; +import { + DREAM_CARD_CATEGORIES_PLACEHOLDER, + resolveDreamSystemPromptFromContent, +} from '../../src/server/dreams/dream-prompt-resolver'; +import { DREAM_CARD_CATEGORIES } from '../../src/server/dreams/types'; + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures', 'dream-system-prompts.fixture.json'), 'utf-8'), +) as { analyzer: string; critic: string }; + +function bundledDreamSkill(): string { + const skillPath = path.join(getBundledSkillsPath(), 'dream', 'SKILL.md'); + return fs.readFileSync(skillPath, 'utf-8'); +} + +describe('dream system prompt parity', () => { + it('resolves the analyzer section byte-for-byte to the former constant', () => { + const resolved = resolveDreamSystemPromptFromContent(bundledDreamSkill(), 'analyzer'); + expect(resolved).toBe(fixture.analyzer); + }); + + it('resolves the critic section byte-for-byte to the former constant', () => { + const resolved = resolveDreamSystemPromptFromContent(bundledDreamSkill(), 'critic'); + expect(resolved).toBe(fixture.critic); + }); + + it('fills the category placeholder from DREAM_CARD_CATEGORIES', () => { + const skill = bundledDreamSkill(); + // The static skill keeps a placeholder; the resolved prompt has the list. + expect(skill).toContain(DREAM_CARD_CATEGORIES_PLACEHOLDER); + const resolved = resolveDreamSystemPromptFromContent(skill, 'analyzer'); + expect(resolved).not.toContain(DREAM_CARD_CATEGORIES_PLACEHOLDER); + expect(resolved).toContain(`Use exactly these categories: ${DREAM_CARD_CATEGORIES.join(', ')}.`); + }); +}); diff --git a/packages/coc/test/server/fixtures/dream-system-prompts.fixture.json b/packages/coc/test/server/fixtures/dream-system-prompts.fixture.json new file mode 100644 index 000000000..63e972da0 --- /dev/null +++ b/packages/coc/test/server/fixtures/dream-system-prompts.fixture.json @@ -0,0 +1,4 @@ +{ + "analyzer": "You are the CoC Dream analyzer.\n\nYour job is to inspect completed workspace conversations and propose only high-confidence improvement opportunities as dream card candidates.\n\nSTRICT OUTPUT CONTRACT\n======================\nRespond with ONLY a valid JSON object. No prose, no markdown, no code fences.\n\nSchema:\n{\n \"candidates\": [\n {\n \"category\": \"skill-or-prompt-improvement\" | \"user-workflow-suggestion\" | \"product-improvement\",\n \"sourceRanges\": [\n { \"processId\": \"process-id\", \"startTurnIndex\": 0, \"endTurnIndex\": 2 }\n ],\n \"observedPattern\": \"Quote-free summary of the observed pattern.\",\n \"whyItMatters\": \"Why this pattern matters.\",\n \"recommendation\": \"Concrete recommendation.\",\n \"expectedImpact\": \"Expected impact if acted on.\",\n \"confidence\": 0.0,\n \"notAlreadyCoveredRationale\": \"Why this is not already covered by obvious existing behavior.\"\n }\n ]\n}\n\nRules:\n- Optimize for precision over recall. Return an empty candidates array when evidence is weak.\n- Use exactly these categories: skill-or-prompt-improvement, user-workflow-suggestion, product-improvement.\n- Source ranges must reference only process IDs and turn ranges supplied in the prompt.\n- Do not quote user or assistant text. Summarize observed patterns without direct quotes.\n- Do not recommend direct mutations. Dream cards are review prompts only.\n- Drop vague, speculative, duplicate, unactionable, or low-confidence ideas.", + "critic": "You are the CoC Dream critic and dedup validator.\n\nYour job is to validate candidate dream cards before they become visible.\n\nSTRICT OUTPUT CONTRACT\n======================\nRespond with ONLY a valid JSON object. No prose, no markdown, no code fences.\n\nSchema:\n{\n \"decisions\": [\n {\n \"candidateIndex\": 0,\n \"verdict\": \"accept\" | \"reject\" | \"duplicate\",\n \"rationale\": \"Concrete reason for the decision.\",\n \"dedupRationale\": \"Required when verdict is duplicate; optional otherwise.\",\n \"duplicateOfCardId\": \"prior-card-id\"\n }\n ]\n}\n\nRules:\n- Accept only candidates with concrete source evidence, actionable recommendations, and high expected value.\n- Reject vague, speculative, low-evidence, low-impact, or already-covered candidates.\n- Mark as duplicate when the candidate is materially covered by prior dream cards, active work items, or skill-hardening records.\n- Prefer rejection over showing a questionable card." +} diff --git a/packages/forge/resources/bundled-skills/dream/SKILL.md b/packages/forge/resources/bundled-skills/dream/SKILL.md new file mode 100644 index 000000000..786a38910 --- /dev/null +++ b/packages/forge/resources/bundled-skills/dream/SKILL.md @@ -0,0 +1,79 @@ +--- +name: dream +description: System prompts for the CoC Dream analyzer and critic internal steps — proposes and validates high-confidence dream card candidates from completed workspace conversations. +metadata: + version: "0.1.0" +--- + +# Dream + +System prompts for the dreaming feature's two read-only internal LLM steps. Each +`## Section:` below is resolved server-side and used verbatim as the system +prompt for the matching step (`analyzer` then `critic`); the dynamic user prompt +is assembled in code. The `{{dreamCardCategories}}` token in the analyzer section is +filled at resolution time from `DREAM_CARD_CATEGORIES`. + +## Section: analyzer + +You are the CoC Dream analyzer. + +Your job is to inspect completed workspace conversations and propose only high-confidence improvement opportunities as dream card candidates. + +STRICT OUTPUT CONTRACT +====================== +Respond with ONLY a valid JSON object. No prose, no markdown, no code fences. + +Schema: +{ + "candidates": [ + { + "category": "skill-or-prompt-improvement" | "user-workflow-suggestion" | "product-improvement", + "sourceRanges": [ + { "processId": "process-id", "startTurnIndex": 0, "endTurnIndex": 2 } + ], + "observedPattern": "Quote-free summary of the observed pattern.", + "whyItMatters": "Why this pattern matters.", + "recommendation": "Concrete recommendation.", + "expectedImpact": "Expected impact if acted on.", + "confidence": 0.0, + "notAlreadyCoveredRationale": "Why this is not already covered by obvious existing behavior." + } + ] +} + +Rules: +- Optimize for precision over recall. Return an empty candidates array when evidence is weak. +- Use exactly these categories: {{dreamCardCategories}}. +- Source ranges must reference only process IDs and turn ranges supplied in the prompt. +- Do not quote user or assistant text. Summarize observed patterns without direct quotes. +- Do not recommend direct mutations. Dream cards are review prompts only. +- Drop vague, speculative, duplicate, unactionable, or low-confidence ideas. + +## Section: critic + +You are the CoC Dream critic and dedup validator. + +Your job is to validate candidate dream cards before they become visible. + +STRICT OUTPUT CONTRACT +====================== +Respond with ONLY a valid JSON object. No prose, no markdown, no code fences. + +Schema: +{ + "decisions": [ + { + "candidateIndex": 0, + "verdict": "accept" | "reject" | "duplicate", + "rationale": "Concrete reason for the decision.", + "dedupRationale": "Required when verdict is duplicate; optional otherwise.", + "duplicateOfCardId": "prior-card-id" + } + ] +} + +Rules: +- Accept only candidates with concrete source evidence, actionable recommendations, and high expected value. +- Reject vague, speculative, low-evidence, low-impact, or already-covered candidates. +- Mark as duplicate when the candidate is materially covered by prior dream cards, active work items, or skill-hardening records. +- Prefer rejection over showing a questionable card. diff --git a/packages/forge/src/skills/bundled-skills-registry.ts b/packages/forge/src/skills/bundled-skills-registry.ts index d1ea5ee64..60cf8d74f 100644 --- a/packages/forge/src/skills/bundled-skills-registry.ts +++ b/packages/forge/src/skills/bundled-skills-registry.ts @@ -89,4 +89,9 @@ export const BUNDLED_SKILLS_REGISTRY: readonly BundledSkill[] = [ description: 'Core instruction sets for Ralph autonomous coding loop phases — grill (clarification), synthesis (goal extraction), execution (iteration), iteration (user prompt), and final-check (read-only validation)', relativePath: 'ultra-ralph', }, + { + name: 'dream', + description: 'System prompts for the CoC Dream analyzer and critic internal steps — proposes and validates high-confidence dream card candidates from completed workspace conversations', + relativePath: 'dream', + }, ]; diff --git a/packages/forge/src/skills/index.ts b/packages/forge/src/skills/index.ts index 4dd845c36..79d13b5c7 100644 --- a/packages/forge/src/skills/index.ts +++ b/packages/forge/src/skills/index.ts @@ -1,4 +1,5 @@ export * from './types'; +export * from './skill-section'; export * from './source-detector'; export * from './skill-scanner'; export * from './skill-installer'; diff --git a/packages/forge/src/skills/skill-section.ts b/packages/forge/src/skills/skill-section.ts new file mode 100644 index 000000000..38b95759b --- /dev/null +++ b/packages/forge/src/skills/skill-section.ts @@ -0,0 +1,71 @@ +/** + * Skill Section Extractor + * + * Some bundled skills (e.g. `ultra-ralph`, `dream`) pack multiple named prompt + * blocks into a single SKILL.md using `## Section: <name>` headers. This helper + * extracts the body of one such section: the text between its header and the + * next `## Section:` header (or end of file), trimmed. + * + * The match is exact on the (trimmed) section name and is line-anchored so a + * `## Section:` appearing mid-paragraph is not treated as a header. + */ + +/** Error thrown when a requested skill section is not present. */ +export class SkillSectionNotFoundError extends Error { + readonly sectionName: string; + + constructor(sectionName: string) { + super(`Skill section "${sectionName}" not found`); + this.name = 'SkillSectionNotFoundError'; + this.sectionName = sectionName; + } +} + +interface SectionHeader { + name: string; + /** Offset where the header line begins. */ + headerStart: number; + /** Offset immediately after the header line (where the section body begins). */ + contentStart: number; +} + +const SECTION_HEADER_RE = /^##[ \t]+Section:[ \t]*(.+?)[ \t]*$/gm; + +function findSectionHeaders(content: string): SectionHeader[] { + const headers: SectionHeader[] = []; + SECTION_HEADER_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = SECTION_HEADER_RE.exec(content)) !== null) { + headers.push({ + name: match[1].trim(), + headerStart: match.index, + contentStart: SECTION_HEADER_RE.lastIndex, + }); + } + return headers; +} + +/** + * Extract the body of a `## Section: <name>` block from SKILL.md content. + * + * @param content Raw SKILL.md content (frontmatter may or may not be present). + * @param sectionName The section name to extract (matched exactly after trimming). + * @returns The trimmed section body. + * @throws SkillSectionNotFoundError if the section is absent. + */ +export function extractSkillSection(content: string, sectionName: string): string { + const normalized = content.replace(/\r\n/g, '\n'); + const target = sectionName.trim(); + const headers = findSectionHeaders(normalized); + + for (let i = 0; i < headers.length; i++) { + if (headers[i].name !== target) { + continue; + } + const start = headers[i].contentStart; + const end = i + 1 < headers.length ? headers[i + 1].headerStart : normalized.length; + return normalized.slice(start, end).trim(); + } + + throw new SkillSectionNotFoundError(target); +} diff --git a/packages/forge/test/skills/dream-skill.test.ts b/packages/forge/test/skills/dream-skill.test.ts new file mode 100644 index 000000000..c548c1b04 --- /dev/null +++ b/packages/forge/test/skills/dream-skill.test.ts @@ -0,0 +1,48 @@ +/** + * Tests for the `dream` bundled skill file presence and section structure. + */ + +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BUNDLED_SKILLS_REGISTRY } from '../../src/skills/bundled-skills-registry'; +import { extractSkillSection } from '../../src/skills/skill-section'; + +const SKILL_FILE = path.resolve(__dirname, '../../resources/bundled-skills/dream/SKILL.md'); + +describe('dream bundled skill', () => { + it('is registered in BUNDLED_SKILLS_REGISTRY', () => { + const entry = BUNDLED_SKILLS_REGISTRY.find(s => s.name === 'dream'); + expect(entry).toBeDefined(); + expect(entry?.relativePath).toBe('dream'); + }); + + it('SKILL.md file exists on disk', () => { + expect(fs.existsSync(SKILL_FILE)).toBe(true); + }); + + it('has YAML frontmatter with name dream', () => { + const content = fs.readFileSync(SKILL_FILE, 'utf8'); + expect(content).toContain('name: dream'); + }); + + it('contains the analyzer and critic sections', () => { + const content = fs.readFileSync(SKILL_FILE, 'utf8'); + expect(content).toContain('## Section: analyzer'); + expect(content).toContain('## Section: critic'); + }); + + it('analyzer section keeps the dreamCardCategories placeholder', () => { + const content = fs.readFileSync(SKILL_FILE, 'utf8'); + const analyzer = extractSkillSection(content, 'analyzer'); + expect(analyzer).toContain('You are the CoC Dream analyzer.'); + expect(analyzer).toContain('{{dreamCardCategories}}'); + }); + + it('critic section holds the critic system prompt verbatim', () => { + const content = fs.readFileSync(SKILL_FILE, 'utf8'); + const critic = extractSkillSection(content, 'critic'); + expect(critic).toContain('You are the CoC Dream critic and dedup validator.'); + expect(critic).not.toContain('{{dreamCardCategories}}'); + }); +}); diff --git a/packages/forge/test/skills/skill-section.test.ts b/packages/forge/test/skills/skill-section.test.ts new file mode 100644 index 000000000..be76971ee --- /dev/null +++ b/packages/forge/test/skills/skill-section.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for the `## Section:` extractor used by multi-section bundled skills. + */ + +import { describe, it, expect } from 'vitest'; +import { extractSkillSection, SkillSectionNotFoundError } from '../../src/skills/skill-section'; + +const SAMPLE = [ + '---', + 'name: sample', + '---', + '', + '# Intro prose (not a section header).', + '', + '## Section: alpha', + '', + 'Alpha body line one.', + 'Alpha body line two.', + '', + '## Section: beta', + '', + 'Beta body.', + '', +].join('\n'); + +describe('extractSkillSection', () => { + it('extracts the body between a header and the next header, trimmed', () => { + expect(extractSkillSection(SAMPLE, 'alpha')).toBe('Alpha body line one.\nAlpha body line two.'); + }); + + it('extracts the last section up to end of file', () => { + expect(extractSkillSection(SAMPLE, 'beta')).toBe('Beta body.'); + }); + + it('matches the section name exactly after trimming', () => { + expect(extractSkillSection('## Section: alpha \n\nbody\n', 'alpha')).toBe('body'); + }); + + it('normalizes CRLF line endings', () => { + const crlf = '## Section: alpha\r\n\r\nbody line\r\n'; + expect(extractSkillSection(crlf, 'alpha')).toBe('body line'); + }); + + it('does not treat a mid-paragraph "## Section:" mention as a header', () => { + const content = '## Section: alpha\n\nWe document the `## Section: beta` convention here.\n'; + expect(extractSkillSection(content, 'alpha')).toBe('We document the `## Section: beta` convention here.'); + }); + + it('throws SkillSectionNotFoundError for a missing section', () => { + expect(() => extractSkillSection(SAMPLE, 'missing')).toThrow(SkillSectionNotFoundError); + }); +}); From a9a3d50ad3b7b03e31c55aceecaeacd58c57ed5b Mon Sep 17 00:00:00 2001 From: Yiheng Tao <ruby092977@gmail.com> Date: Fri, 12 Jun 2026 06:25:29 +0000 Subject: [PATCH 5/8] feat(dream): source analyzer/critic system prompts from the dream skill (AC-02/04/05) Complete the dreaming-prompts-to-skill refactor on top of the AC-01/03 foundation: - AC-02: delete the inline ANALYZER_SYSTEM_PROMPT / CRITIC_SYSTEM_PROMPT constants. analyzeDreamConversations now takes a DreamSystemPromptResolver and passes the resolved skill-section text as the systemPrompt. The resolver is threaded DreamRunExecutor -> routes/index.ts, where dataDir is known, via resolveDreamSystemPrompt(section, { dataDir }). grep finds no remaining constant references. - AC-04: attachDreamStepMetadata now records dreamStep.skill = { name: 'dream', section }; posture flags (readOnly, toolsEnabled:false, mcpEnabled:false, permissionPolicy:'deny-all') are unchanged. The persisted dream-step metadata is now typed via a new DreamStepContext interface (with DreamStepSkillProvenance) in dream-internal-process.ts. - AC-05: add 'dream' to DEFAULT_BUNDLED_SKILLS so the skill auto-installs into ~/.coc/skills on startup; no pre-flight guard. - AC-06: existing dream tests updated to pass a resolver and assert skill provenance; new AC-05 containment test. coc/forge build, tsc --noEmit, lint (0 errors), and dream + config test suites are green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- packages/coc/src/config.ts | 1 + .../coc/src/server/dreams/dream-analyzer.ts | 77 +++---------------- .../server/dreams/dream-internal-process.ts | 31 ++++++++ .../coc/src/server/dreams/dream-runner.ts | 5 ++ .../dream-internal-process-executor.ts | 36 +++++---- packages/coc/src/server/routes/index.ts | 2 + .../coc/test/server/dream-analyzer.test.ts | 15 +++- .../dream-internal-process-executor.test.ts | 2 + .../test/server/dream-prompt-resolver.test.ts | 8 ++ packages/coc/test/server/dream-runner.test.ts | 2 + 10 files changed, 97 insertions(+), 82 deletions(-) diff --git a/packages/coc/src/config.ts b/packages/coc/src/config.ts index 7a96fa50c..3b215ffb4 100644 --- a/packages/coc/src/config.ts +++ b/packages/coc/src/config.ts @@ -607,6 +607,7 @@ export const DEFAULT_BUNDLED_SKILLS: readonly string[] = [ 'loop', 'ultra-ralph', 'classify-diff', + 'dream', ]; /** Default configuration values */ diff --git a/packages/coc/src/server/dreams/dream-analyzer.ts b/packages/coc/src/server/dreams/dream-analyzer.ts index 14bc25495..db76584d4 100644 --- a/packages/coc/src/server/dreams/dream-analyzer.ts +++ b/packages/coc/src/server/dreams/dream-analyzer.ts @@ -6,77 +6,19 @@ import type { DreamCard, DreamSourceRange, } from './types'; -import { DREAM_CARD_CATEGORIES } from './types'; import type { DreamConversationSelection } from './dream-source-selector'; export const DEFAULT_DREAM_ANALYSIS_TIMEOUT_MS = 3_600_000; export const DEFAULT_DREAM_CONFIDENCE_THRESHOLD = 0.85; export const DEFAULT_DREAM_MAX_CANDIDATES = 8; -const ANALYZER_SYSTEM_PROMPT = `\ -You are the CoC Dream analyzer. - -Your job is to inspect completed workspace conversations and propose only high-confidence improvement opportunities as dream card candidates. - -STRICT OUTPUT CONTRACT -====================== -Respond with ONLY a valid JSON object. No prose, no markdown, no code fences. - -Schema: -{ - "candidates": [ - { - "category": "skill-or-prompt-improvement" | "user-workflow-suggestion" | "product-improvement", - "sourceRanges": [ - { "processId": "process-id", "startTurnIndex": 0, "endTurnIndex": 2 } - ], - "observedPattern": "Quote-free summary of the observed pattern.", - "whyItMatters": "Why this pattern matters.", - "recommendation": "Concrete recommendation.", - "expectedImpact": "Expected impact if acted on.", - "confidence": 0.0, - "notAlreadyCoveredRationale": "Why this is not already covered by obvious existing behavior." - } - ] -} - -Rules: -- Optimize for precision over recall. Return an empty candidates array when evidence is weak. -- Use exactly these categories: ${DREAM_CARD_CATEGORIES.join(', ')}. -- Source ranges must reference only process IDs and turn ranges supplied in the prompt. -- Do not quote user or assistant text. Summarize observed patterns without direct quotes. -- Do not recommend direct mutations. Dream cards are review prompts only. -- Drop vague, speculative, duplicate, unactionable, or low-confidence ideas. -`.trim(); - -const CRITIC_SYSTEM_PROMPT = `\ -You are the CoC Dream critic and dedup validator. - -Your job is to validate candidate dream cards before they become visible. - -STRICT OUTPUT CONTRACT -====================== -Respond with ONLY a valid JSON object. No prose, no markdown, no code fences. - -Schema: -{ - "decisions": [ - { - "candidateIndex": 0, - "verdict": "accept" | "reject" | "duplicate", - "rationale": "Concrete reason for the decision.", - "dedupRationale": "Required when verdict is duplicate; optional otherwise.", - "duplicateOfCardId": "prior-card-id" - } - ] -} - -Rules: -- Accept only candidates with concrete source evidence, actionable recommendations, and high expected value. -- Reject vague, speculative, low-evidence, low-impact, or already-covered candidates. -- Mark as duplicate when the candidate is materially covered by prior dream cards, active work items, or skill-hardening records. -- Prefer rejection over showing a questionable card. -`.trim(); +/** + * Resolves the system prompt for a dream internal step from the bundled `dream` + * skill (`## Section: analyzer` / `## Section: critic`). The analyzer and critic + * system prompts are no longer inline TypeScript constants — the skill file is + * the single source of truth, resolved server-side and used verbatim. + */ +export type DreamSystemPromptResolver = (section: DreamInternalProcessPurpose) => string; type CriticVerdict = 'accept' | 'reject' | 'duplicate'; @@ -97,6 +39,7 @@ export interface DreamAnalysisPolicy { export interface DreamAnalyzerOptions extends DreamAnalysisPolicy { runInternalStep: DreamInternalStepRunner; + resolveSystemPrompt: DreamSystemPromptResolver; workspaceId: string; runId?: string; parentProcessId?: string; @@ -547,7 +490,7 @@ export async function analyzeDreamConversations(options: DreamAnalyzerOptions): workspaceId, runId: options.runId ?? 'dream-run', prompt: analysisPrompt, - systemPrompt: ANALYZER_SYSTEM_PROMPT, + systemPrompt: options.resolveSystemPrompt('analyzer'), timeoutMs, ...(options.parentProcessId ? { parentProcessId: options.parentProcessId } : {}), ...(options.provider ? { provider: options.provider } : {}), @@ -589,7 +532,7 @@ export async function analyzeDreamConversations(options: DreamAnalyzerOptions): runId: options.runId ?? 'dream-run', analyzerProcessId, prompt: criticPrompt, - systemPrompt: CRITIC_SYSTEM_PROMPT, + systemPrompt: options.resolveSystemPrompt('critic'), timeoutMs, ...(options.parentProcessId ? { parentProcessId: options.parentProcessId } : {}), ...(options.provider ? { provider: options.provider } : {}), diff --git a/packages/coc/src/server/dreams/dream-internal-process.ts b/packages/coc/src/server/dreams/dream-internal-process.ts index 2cb20c72c..58edc2295 100644 --- a/packages/coc/src/server/dreams/dream-internal-process.ts +++ b/packages/coc/src/server/dreams/dream-internal-process.ts @@ -24,3 +24,34 @@ export interface DreamInternalStepResult { } export type DreamInternalStepRunner = (request: DreamInternalStepRequest) => Promise<DreamInternalStepResult>; + +/** + * Provenance for the bundled skill section that supplied a dream step's system + * prompt. Persisted on `proc.metadata.dreamStep.skill` so history/audit can see + * that the analyzer/critic prompts are sourced from the `dream` skill rather + * than inline constants. + */ +export interface DreamStepSkillProvenance { + name: string; + section: DreamInternalProcessPurpose; +} + +/** + * Groupable subset of a dream internal step's persisted metadata + * (`proc.metadata.dreamStep`). The posture flags are constant by construction: + * dream steps are always read-only, tool-less, MCP-less, deny-all. + */ +export interface DreamStepContext { + kind: DreamInternalProcessPurpose; + purpose: string; + workspaceId: string; + runId: string; + readOnly: true; + toolsEnabled: false; + mcpEnabled: false; + permissionPolicy: 'deny-all'; + timeoutMs: number; + skill: DreamStepSkillProvenance; + parentProcessId?: string; + analyzerProcessId?: string; +} diff --git a/packages/coc/src/server/dreams/dream-runner.ts b/packages/coc/src/server/dreams/dream-runner.ts index ce83c517e..40b0833f5 100644 --- a/packages/coc/src/server/dreams/dream-runner.ts +++ b/packages/coc/src/server/dreams/dream-runner.ts @@ -12,6 +12,7 @@ import { type DreamAnalysisPolicy, type DreamAnalysisResult, type DreamRelatedRecord, + type DreamSystemPromptResolver, } from './dream-analyzer'; import type { DreamInternalProcessPurpose, DreamInternalStepRunner } from './dream-internal-process'; import { selectEligibleDreamConversations, type DreamConversationSelection } from './dream-source-selector'; @@ -30,6 +31,7 @@ export interface DreamRunExecutorOptions extends DreamRunPolicy { store: FileDreamStore; processStore: ProcessStore; runInternalStep: DreamInternalStepRunner; + resolveSystemPrompt: DreamSystemPromptResolver; getDreamsEnabled: () => boolean | Promise<boolean>; getWorkspaceDreamsEnabled: (workspaceId: string) => boolean | Promise<boolean>; listWorkspaceTasks?: (workspaceId: string) => readonly QueuedTask[] | Promise<readonly QueuedTask[]>; @@ -204,6 +206,7 @@ export class DreamRunExecutor { private readonly store: FileDreamStore; private readonly processStore: ProcessStore; private readonly runInternalStep: DreamInternalStepRunner; + private readonly resolveSystemPrompt: DreamSystemPromptResolver; private readonly getDreamsEnabled: () => boolean | Promise<boolean>; private readonly getWorkspaceDreamsEnabled: (workspaceId: string) => boolean | Promise<boolean>; private readonly listWorkspaceTasks?: (workspaceId: string) => readonly QueuedTask[] | Promise<readonly QueuedTask[]>; @@ -222,6 +225,7 @@ export class DreamRunExecutor { this.store = options.store; this.processStore = options.processStore; this.runInternalStep = options.runInternalStep; + this.resolveSystemPrompt = options.resolveSystemPrompt; this.getDreamsEnabled = options.getDreamsEnabled; this.getWorkspaceDreamsEnabled = options.getWorkspaceDreamsEnabled; this.listWorkspaceTasks = options.listWorkspaceTasks; @@ -349,6 +353,7 @@ export class DreamRunExecutor { const relatedRecords = await this.getRelatedRecords?.(workspaceId) ?? []; analysis = await analyzeDreamConversations({ runInternalStep: this.runInternalStep, + resolveSystemPrompt: this.resolveSystemPrompt, workspaceId, runId: run.id, ...(options.parentProcessId ? { parentProcessId: options.parentProcessId } : {}), diff --git a/packages/coc/src/server/executors/dream-internal-process-executor.ts b/packages/coc/src/server/executors/dream-internal-process-executor.ts index 02b07e440..58762c6e9 100644 --- a/packages/coc/src/server/executors/dream-internal-process-executor.ts +++ b/packages/coc/src/server/executors/dream-internal-process-executor.ts @@ -12,7 +12,13 @@ import { toQueueProcessId, } from '@plusplusoneplusplus/forge'; import type { ChatProvider } from '../tasks/task-types'; -import type { DreamInternalProcessPurpose, DreamInternalStepRequest, DreamInternalStepResult } from '../dreams/dream-internal-process'; +import type { + DreamInternalProcessPurpose, + DreamInternalStepRequest, + DreamInternalStepResult, + DreamStepContext, +} from '../dreams/dream-internal-process'; +import { DREAM_SKILL_NAME } from '../dreams/dream-prompt-resolver'; import { ProcessLifecycleRunner } from './process-lifecycle-runner'; export interface DreamInternalProcessExecutorOptions { @@ -139,6 +145,20 @@ export class DreamInternalProcessExecutor { const current = await this.store.getProcess(processId, input.workspaceId); const provider = input.provider ?? this.defaultProvider; const model = resolveModelForProvider(provider, input.model).model; + const dreamStep: DreamStepContext = { + kind: input.purpose, + purpose: displayNameForPurpose(input.purpose), + workspaceId: input.workspaceId, + runId: input.runId, + readOnly: true, + toolsEnabled: false, + mcpEnabled: false, + permissionPolicy: 'deny-all', + timeoutMs: input.timeoutMs, + skill: { name: DREAM_SKILL_NAME, section: input.purpose }, + ...(input.parentProcessId ? { parentProcessId: input.parentProcessId } : {}), + ...(input.analyzerProcessId ? { analyzerProcessId: input.analyzerProcessId } : {}), + }; await this.store.updateProcess(processId, { ...(input.parentProcessId ? { parentProcessId: input.parentProcessId } : {}), title: displayNameForPurpose(input.purpose), @@ -150,19 +170,7 @@ export class DreamInternalProcessExecutor { ...(model ? { model } : {}), ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}), mode: 'ask', - dreamStep: { - kind: input.purpose, - purpose: displayNameForPurpose(input.purpose), - workspaceId: input.workspaceId, - runId: input.runId, - readOnly: true, - toolsEnabled: false, - mcpEnabled: false, - permissionPolicy: 'deny-all', - timeoutMs: input.timeoutMs, - ...(input.parentProcessId ? { parentProcessId: input.parentProcessId } : {}), - ...(input.analyzerProcessId ? { analyzerProcessId: input.analyzerProcessId } : {}), - }, + dreamStep, }, }); } diff --git a/packages/coc/src/server/routes/index.ts b/packages/coc/src/server/routes/index.ts index f33c9afb8..70230cdcb 100644 --- a/packages/coc/src/server/routes/index.ts +++ b/packages/coc/src/server/routes/index.ts @@ -110,6 +110,7 @@ import { FileDreamStore } from '../dreams/dream-store'; import { DreamRunExecutor, type DreamRunRequestOptions } from '../dreams/dream-runner'; import { DreamIdleScheduler } from '../dreams/dream-idle-scheduler'; import { DEFAULT_DREAM_ANALYSIS_TIMEOUT_MS } from '../dreams/dream-analyzer'; +import { resolveDreamSystemPrompt } from '../dreams/dream-prompt-resolver'; import { DreamInternalProcessExecutor } from '../executors/dream-internal-process-executor'; import { registerLoopRoutes } from '../loops/loop-handler'; import type { LoopStore } from '../loops/loop-store'; @@ -708,6 +709,7 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): store: dreamStore, processStore: store, runInternalStep: request => dreamInternalProcessExecutor.runStep(request), + resolveSystemPrompt: section => resolveDreamSystemPrompt(section, { dataDir }), getDreamsEnabled, getWorkspaceDreamsEnabled: (workspaceId) => readRepoPreferences(dataDir, workspaceId).dreams?.enabled === true, listWorkspaceTasks: () => queueFacade.getAll(), diff --git a/packages/coc/test/server/dream-analyzer.test.ts b/packages/coc/test/server/dream-analyzer.test.ts index 38e47ca3a..14f207508 100644 --- a/packages/coc/test/server/dream-analyzer.test.ts +++ b/packages/coc/test/server/dream-analyzer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { DreamConversationSelection } from '../../src/server/dreams/dream-source-selector'; -import type { DreamInternalStepRunner } from '../../src/server/dreams/dream-internal-process'; +import type { DreamInternalProcessPurpose, DreamInternalStepRunner } from '../../src/server/dreams/dream-internal-process'; import type { DreamCard } from '../../src/server/dreams/types'; import { DEFAULT_DREAM_ANALYSIS_TIMEOUT_MS, @@ -82,6 +82,15 @@ function mockInternalStepRunner(...responses: string[]): DreamInternalStepRunner return runInternalStep; } +// Stand-in resolver: the byte-identical parity to the bundled `dream` skill is +// covered by dream-prompt-resolver.test.ts; here we only need the analyzer/critic +// system prompts to be section-distinguishable. +function resolveSystemPrompt(section: DreamInternalProcessPurpose): string { + return section === 'analyzer' + ? 'You are the CoC Dream analyzer.' + : 'You are the CoC Dream critic.'; +} + describe('normalizeDreamAnalysisCandidates', () => { it('keeps only candidates that pass deterministic prefilters, confidence, and source coverage', () => { const response = JSON.stringify({ @@ -175,6 +184,7 @@ describe('analyzeDreamConversations', () => { const result = await analyzeDreamConversations({ runInternalStep, + resolveSystemPrompt, workspaceId: WORKSPACE_ID, runId: RUN_ID, parentProcessId: 'queue_dream-run-parent', @@ -221,6 +231,7 @@ describe('analyzeDreamConversations', () => { await analyzeDreamConversations({ runInternalStep, + resolveSystemPrompt, workspaceId: WORKSPACE_ID, runId: RUN_ID, selection: selection(), @@ -249,6 +260,7 @@ describe('analyzeDreamConversations', () => { const result = await analyzeDreamConversations({ runInternalStep, + resolveSystemPrompt, workspaceId: WORKSPACE_ID, runId: RUN_ID, selection: selection(), @@ -276,6 +288,7 @@ describe('analyzeDreamConversations', () => { const result = await analyzeDreamConversations({ runInternalStep, + resolveSystemPrompt, workspaceId: WORKSPACE_ID, selection: emptySelection, }); diff --git a/packages/coc/test/server/dream-internal-process-executor.test.ts b/packages/coc/test/server/dream-internal-process-executor.test.ts index 386ab33dd..888c738ed 100644 --- a/packages/coc/test/server/dream-internal-process-executor.test.ts +++ b/packages/coc/test/server/dream-internal-process-executor.test.ts @@ -73,6 +73,7 @@ describe('DreamInternalProcessExecutor', () => { mcpEnabled: false, permissionPolicy: 'deny-all', timeoutMs: 45_000, + skill: { name: 'dream', section: 'analyzer' }, parentProcessId: 'queue_outer-dream-run', }, }, @@ -144,6 +145,7 @@ describe('DreamInternalProcessExecutor', () => { readOnly: true, toolsEnabled: false, mcpEnabled: false, + skill: { name: 'dream', section: 'critic' }, }, }, }); diff --git a/packages/coc/test/server/dream-prompt-resolver.test.ts b/packages/coc/test/server/dream-prompt-resolver.test.ts index 303e75725..ea11312d0 100644 --- a/packages/coc/test/server/dream-prompt-resolver.test.ts +++ b/packages/coc/test/server/dream-prompt-resolver.test.ts @@ -14,9 +14,11 @@ import { describe, expect, it } from 'vitest'; import { getBundledSkillsPath } from '@plusplusoneplusplus/forge'; import { DREAM_CARD_CATEGORIES_PLACEHOLDER, + DREAM_SKILL_NAME, resolveDreamSystemPromptFromContent, } from '../../src/server/dreams/dream-prompt-resolver'; import { DREAM_CARD_CATEGORIES } from '../../src/server/dreams/types'; +import { DEFAULT_BUNDLED_SKILLS } from '../../src/config'; const fixture = JSON.parse( fs.readFileSync(path.join(__dirname, 'fixtures', 'dream-system-prompts.fixture.json'), 'utf-8'), @@ -47,3 +49,9 @@ describe('dream system prompt parity', () => { expect(resolved).toContain(`Use exactly these categories: ${DREAM_CARD_CATEGORIES.join(', ')}.`); }); }); + +describe('dream skill auto-install (AC-05)', () => { + it('includes the dream skill in DEFAULT_BUNDLED_SKILLS so it lands in ~/.coc/skills', () => { + expect(DEFAULT_BUNDLED_SKILLS).toContain(DREAM_SKILL_NAME); + }); +}); diff --git a/packages/coc/test/server/dream-runner.test.ts b/packages/coc/test/server/dream-runner.test.ts index a208a69d9..c1337cdf9 100644 --- a/packages/coc/test/server/dream-runner.test.ts +++ b/packages/coc/test/server/dream-runner.test.ts @@ -152,6 +152,8 @@ function createRunner(options: { }], }), ), + resolveSystemPrompt: section => + section === 'analyzer' ? 'You are the CoC Dream analyzer.' : 'You are the CoC Dream critic.', getDreamsEnabled: options.getDreamsEnabled ?? (() => true), getWorkspaceDreamsEnabled: options.getWorkspaceDreamsEnabled ?? (() => true), listWorkspaceTasks: options.listWorkspaceTasks, From 99d4822cd3bf9c462f86cc26a0119bfd4453eca4 Mon Sep 17 00:00:00 2001 From: Yiheng Tao <ruby092977@gmail.com> Date: Fri, 12 Jun 2026 14:41:34 +0000 Subject: [PATCH 6/8] fix(coc): persist ask mode in pr-classification process metadata pr-classification tasks run read-only ask mode in ClassificationExecutor, but the process-lifecycle runner only recorded `metadata.mode` for chat payloads. With the field absent, dashboard surfaces fell back to labelling the process 'autopilot' even though it executed read-only. Record `mode: 'ask'` for pr-classification payloads so the persisted metadata reflects the real execution mode and the UI stops mislabelling it. Add regression coverage asserting classification tasks persist mode 'ask' (metadata + seeded user turn), chat tasks keep their explicit mode, and unrelated non-chat tasks leave mode undefined. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../references/server-architecture.md | 2 +- .../executors/process-lifecycle-runner.ts | 7 +- .../process-lifecycle-runner-mode.test.ts | 104 ++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/coc/test/server/process-lifecycle-runner-mode.test.ts diff --git a/.github/skills/coc-knowledge/references/server-architecture.md b/.github/skills/coc-knowledge/references/server-architecture.md index ad7353f9f..30f5197c2 100644 --- a/.github/skills/coc-knowledge/references/server-architecture.md +++ b/.github/skills/coc-knowledge/references/server-architecture.md @@ -131,7 +131,7 @@ The `executors/` directory contains the AI chat execution layer: | `chat-tool-builder.ts` | Common chat tool bundle assembly | | `bounded-memory-addon.ts` | Wires bounded MEMORY.md into chat executors | -CoC chat tasks use Ask, Autopilot, or Ralph modes. Diff classification jobs are queued as first-class `pr-classification` tasks or legacy classify-diff chat tasks, but `ClassificationExecutor` runs them with interactive Ask-mode semantics while preserving the `saveClassification` tool side effect for result persistence. Pull Requests Team auto-classification reuses the generic classify-diff enqueue path as low-priority background work, building PR identifiers as `prNumber:headSha`, capping each trigger at 10 new enqueues, and skipping ready/running entries through the existing classification store and pending-marker self-healing; the Pull Requests tab's manual "Classify now" action uses the same bounded server helper. Legacy stored or incoming chat payloads with `mode='plan'` are normalized to Ask before dispatch, metadata persistence, schedule execution, and follow-up execution; the server does not route CoC chat work through a dedicated Plan executor. Follow-up execution resolves the final provider/session/default model before applying per-turn reasoning effort; unsupported per-turn efforts are omitted with a warning so stale UI tier selections do not fail an existing chat, while persisted/default effort validation remains strict. +CoC chat tasks use Ask, Autopilot, or Ralph modes. Diff classification jobs are queued as first-class `pr-classification` tasks or legacy classify-diff chat tasks, but `ClassificationExecutor` runs them with interactive Ask-mode semantics while preserving the `saveClassification` tool side effect for result persistence. The process-lifecycle runner persists `mode: 'ask'` in `pr-classification` process metadata (chat payloads still record their normalized payload mode) so mode-less classification records are not mislabelled Autopilot by UI fallbacks. Pull Requests Team auto-classification reuses the generic classify-diff enqueue path as low-priority background work, building PR identifiers as `prNumber:headSha`, capping each trigger at 10 new enqueues, and skipping ready/running entries through the existing classification store and pending-marker self-healing; the Pull Requests tab's manual "Classify now" action uses the same bounded server helper. Legacy stored or incoming chat payloads with `mode='plan'` are normalized to Ask before dispatch, metadata persistence, schedule execution, and follow-up execution; the server does not route CoC chat work through a dedicated Plan executor. Follow-up execution resolves the final provider/session/default model before applying per-turn reasoning effort; unsupported per-turn efforts are omitted with a warning so stale UI tier selections do not fail an existing chat, while persisted/default effort validation remains strict. ## Configuration diff --git a/packages/coc/src/server/executors/process-lifecycle-runner.ts b/packages/coc/src/server/executors/process-lifecycle-runner.ts index 8f421900c..2f91895eb 100644 --- a/packages/coc/src/server/executors/process-lifecycle-runner.ts +++ b/packages/coc/src/server/executors/process-lifecycle-runner.ts @@ -408,9 +408,14 @@ export class ProcessLifecycleRunner extends BaseExecutor { const processId = toQueueProcessId(task.id); const prompt = applySkillContent(extractPrompt(task), task); const payload = task.payload as any; + // pr-classification tasks always run read-only ask mode (see + // ClassificationExecutor). Record it explicitly so UI surfaces don't + // fall back to labelling a mode-less classification process 'autopilot'. const normalizedPayloadMode = isChatPayload(task.payload) ? normalizeChatMode(payload?.mode) - : undefined; + : isPrClassificationPayload(task.payload) + ? 'ask' + : undefined; const selectedSkills = isChatPayload(task.payload) ? (task.payload as ChatPayload).context?.skills : isPrClassificationPayload(task.payload) diff --git a/packages/coc/test/server/process-lifecycle-runner-mode.test.ts b/packages/coc/test/server/process-lifecycle-runner-mode.test.ts new file mode 100644 index 000000000..39cab131c --- /dev/null +++ b/packages/coc/test/server/process-lifecycle-runner-mode.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import type { QueuedTask } from '@plusplusoneplusplus/forge'; +import { ProcessLifecycleRunner } from '../../src/server/executors/process-lifecycle-runner'; +import { createMockProcessStore } from './helpers/mock-process-store'; + +/** + * Regression coverage for the persisted `metadata.mode` of non-chat tasks. + * + * pr-classification tasks run read-only ask mode (ClassificationExecutor), but + * the lifecycle runner historically only recorded `mode` for chat payloads, so + * the field was absent and UI surfaces fell back to labelling the process + * 'autopilot'. The runner now records `mode: 'ask'` for classification tasks. + */ +describe('ProcessLifecycleRunner persisted metadata.mode', () => { + function runTask(task: QueuedTask, store = createMockProcessStore()) { + const runner = new ProcessLifecycleRunner(store, undefined, () => undefined, 'claude'); + return runner.run(task, { + cancelledTasks: new Set(), + executeFollowUpFn: async () => undefined, + executeByTypeFn: async () => ({ response: 'done.' }), + getWorkingDirectoryFn: () => undefined, + }).then((result) => ({ result, store })); + } + + it("records mode 'ask' for pr-classification tasks", async () => { + const task: QueuedTask = { + id: 'classify-task-1', + repoId: 'ws-classify', + type: 'pr-classification', + priority: 'normal', + status: 'running', + createdAt: Date.parse('2026-06-12T00:00:00.000Z'), + payload: { + kind: 'pr-classification', + workspaceId: 'ws-classify', + repoId: 'ws-classify', + prId: 'abc1234', + headSha: 'abc1234', + prompt: 'Classify every hunk in commit abc1234 of this repository.', + skills: ['classify-diff'], + }, + config: {}, + displayName: 'Classifying commit diff hunks', + }; + + const { result, store } = await runTask(task); + + expect(result.success).toBe(true); + const process = await store.getProcess('queue_classify-task-1'); + expect(process?.metadata?.mode).toBe('ask'); + // The seeded user turn carries the same mode so the conversation is + // consistent with the recorded process metadata. + expect(process?.conversationTurns?.[0]?.mode).toBe('ask'); + }); + + it('keeps the explicit chat mode for chat tasks', async () => { + const task: QueuedTask = { + id: 'chat-task-1', + repoId: 'ws-chat', + type: 'chat', + priority: 'normal', + status: 'running', + createdAt: Date.parse('2026-06-12T00:00:00.000Z'), + payload: { + kind: 'chat', + mode: 'autopilot', + prompt: 'Do the thing.', + workspaceId: 'ws-chat', + }, + config: {}, + displayName: 'Chat', + }; + + const { result, store } = await runTask(task); + + expect(result.success).toBe(true); + const process = await store.getProcess('queue_chat-task-1'); + expect(process?.metadata?.mode).toBe('autopilot'); + }); + + it('leaves mode undefined for unrelated non-chat tasks', async () => { + const task: QueuedTask = { + id: 'workflow-task-1', + repoId: 'ws-wf', + type: 'run-workflow', + priority: 'normal', + status: 'running', + createdAt: Date.parse('2026-06-12T00:00:00.000Z'), + payload: { + kind: 'run-workflow', + workflowPath: '/tmp/example.workflow.js', + workspaceId: 'ws-wf', + }, + config: {}, + displayName: 'Run Workflow', + }; + + const { result, store } = await runTask(task); + + expect(result.success).toBe(true); + const process = await store.getProcess('queue_workflow-task-1'); + expect(process?.metadata?.mode).toBeUndefined(); + }); +}); From 524b48113f73c890f578777fae78e29019fb1835 Mon Sep 17 00:00:00 2001 From: Yiheng Tao <ruby092977@gmail.com> Date: Thu, 11 Jun 2026 22:53:15 -0700 Subject: [PATCH 7/8] Add unified task-group framework for hierarchical tasks Consolidate the four bespoke parent/child task implementations (For Each, Map Reduce, Ralph, Dreams) onto one shared framework: - forge: generic task-group registry (task_groups + task_group_members, schema v22) with SqliteTaskGroupStore over the shared database handle. - coc server: TaskGroupService + GET /api/workspaces/:id/task-groups; every child task carries a generic payload.context.taskGroup tag (groupId/groupType/role/itemKey) mirrored into process metadata and history items; feature stores fire change hooks projected into the registry by feature-sync (Ralph via a dataDir-keyed session-change listener); idempotent startup backfill projects pre-framework runs/sessions; group pins accept open type strings. - Feature adoption: For Each and Map Reduce runs, Ralph sessions (iterations + final checks now persist child links), and Dream runs (hidden linkage-only groups for analyzer/critic internals). - coc-client: taskGroups domain + contracts; ProcessGroupPinType opened. - SPA: shared task-group-grouping engine + per-type descriptor registry + unified TaskGroupRunRow; for-each/map-reduce grouping modules become engine adapters and their rows thin display wrappers; ralph grouping shares the helpers and resolves sessions from the generic tag too. Behavior-preserving for existing UI (all prior grouping/row tests pass unchanged); new framework layers covered by forge store, REST, feature sync, backfill, and client engine tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../coc-knowledge/references/dashboard-spa.md | 15 + .../coc-knowledge/references/process-store.md | 8 +- .../skills/coc-knowledge/references/ralph.md | 12 + .../coc-knowledge/references/rest-api.md | 11 +- packages/coc-client/src/client.ts | 4 +- packages/coc-client/src/contracts/index.ts | 1 + .../coc-client/src/contracts/processes.ts | 6 +- .../coc-client/src/contracts/task-groups.ts | 70 ++++ packages/coc-client/src/domains/index.ts | 1 + .../coc-client/src/domains/task-groups.ts | 34 ++ packages/coc/AGENTS.md | 12 + packages/coc/src/server/dreams/dream-store.ts | 18 + .../executors/process-lifecycle-runner.ts | 2 + .../server/for-each/for-each-run-executor.ts | 7 + .../src/server/for-each/for-each-run-store.ts | 34 +- .../map-reduce/map-reduce-run-executor.ts | 13 + .../server/map-reduce/map-reduce-run-store.ts | 13 + .../src/server/processes/group-pin-handler.ts | 8 +- .../src/server/processes/group-pin-store.ts | 15 +- .../src/server/ralph/enqueue-final-check.ts | 7 + .../coc/src/server/ralph/enqueue-iteration.ts | 11 + .../src/server/ralph/ralph-session-store.ts | 46 +++ packages/coc/src/server/routes/index.ts | 34 +- .../src/server/routes/task-group-routes.ts | 57 +++ .../src/server/shared/process-history-item.ts | 5 +- .../react/features/chat/ForEachRunRow.tsx | 221 ++--------- .../react/features/chat/MapReduceRunRow.tsx | 222 ++--------- .../react/features/chat/TaskGroupRunRow.tsx | 258 +++++++++++++ .../features/chat/for-each-run-grouping.ts | 98 ++--- .../features/chat/map-reduce-run-grouping.ts | 98 ++--- .../features/chat/ralph-session-grouping.ts | 33 +- .../features/chat/task-group-descriptors.ts | 83 ++++ .../features/chat/task-group-grouping.ts | 173 +++++++++ .../coc/src/server/task-groups/backfill.ts | 115 ++++++ .../src/server/task-groups/feature-sync.ts | 239 ++++++++++++ .../server/task-groups/task-group-service.ts | 149 ++++++++ packages/coc/src/server/tasks/task-types.ts | 72 ++++ .../coc/test/server/group-pin-handler.test.ts | 15 +- .../server/ralph/enqueue-iteration.test.ts | 19 +- .../spa/client/task-group-grouping.test.ts | 154 ++++++++ .../test/server/task-group-backfill.test.ts | 110 ++++++ .../server/task-group-feature-sync.test.ts | 303 +++++++++++++++ .../coc/test/server/task-group-routes.test.ts | 144 +++++++ packages/forge/src/index.ts | 10 + packages/forge/src/sqlite-schema.ts | 58 ++- packages/forge/src/task-group-store.ts | 353 ++++++++++++++++++ packages/forge/test/sqlite-schema.test.ts | 7 +- packages/forge/test/task-group-store.test.ts | 206 ++++++++++ 48 files changed, 2984 insertions(+), 600 deletions(-) create mode 100644 packages/coc-client/src/contracts/task-groups.ts create mode 100644 packages/coc-client/src/domains/task-groups.ts create mode 100644 packages/coc/src/server/routes/task-group-routes.ts create mode 100644 packages/coc/src/server/spa/client/react/features/chat/TaskGroupRunRow.tsx create mode 100644 packages/coc/src/server/spa/client/react/features/chat/task-group-descriptors.ts create mode 100644 packages/coc/src/server/spa/client/react/features/chat/task-group-grouping.ts create mode 100644 packages/coc/src/server/task-groups/backfill.ts create mode 100644 packages/coc/src/server/task-groups/feature-sync.ts create mode 100644 packages/coc/src/server/task-groups/task-group-service.ts create mode 100644 packages/coc/test/server/spa/client/task-group-grouping.test.ts create mode 100644 packages/coc/test/server/task-group-backfill.test.ts create mode 100644 packages/coc/test/server/task-group-feature-sync.test.ts create mode 100644 packages/coc/test/server/task-group-routes.test.ts create mode 100644 packages/forge/src/task-group-store.ts create mode 100644 packages/forge/test/task-group-store.test.ts diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 85524806f..bb5960c0f 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -42,6 +42,21 @@ When `features.commitChatLens` is enabled from Admin -> Configure -> Features, r The Notes view inherits the same `features.commitChatLens` source of truth for its AI chat surface. `NotesView` uses `useReviewChatPresentation()` with a workspace-scoped `notes` target, preserving the legacy workspace-scoped notes chat open key while Lens is disabled and using the shared target-scoped Lens open/pin/minimize keys when Lens is enabled. The notes area shows no separate Lens indicator; no notes-specific Lens setting is stored or exposed. Note-producing SPA flows that originate from notes/chat UI (notes chat edits, AI note creation, and bulk chat summaries) attach `context.lensChat = { inherited: true, source: 'features.commitChatLens' }` only while the shared Lens flag is enabled, so the process metadata records inherited Lens routing without adding persistent notes-specific state. +Chat-list hierarchy grouping is consolidated behind a shared engine: +`features/chat/task-group-grouping.ts` owns the generic matching/aggregation +logic (the `payload.context.taskGroup` tag reader, activity/end timestamp +chains, seeded grouping used by For Each and Map Reduce, shared helpers used +by Ralph), `features/chat/task-group-descriptors.ts` registers per-type +presentation/behavior descriptors (label, badge, accent, pin type, +`matchesTask`, `groupable` — Dreams is `groupable: false` so its internals +stay ungrouped), and `features/chat/TaskGroupRunRow.tsx` is the shared +parent-row chrome that `ForEachRunRow`/`MapReduceRunRow` configure as thin +wrappers. The per-feature grouping modules (`for-each-run-grouping.ts`, +`map-reduce-run-grouping.ts`, `ralph-session-grouping.ts`) are adapters over +the engine that keep their legacy matching (feature contexts, +`generationProcessId`) in addition to the generic tag, so historical chats +group without data migration. + `features/chat/ChatListPane.tsx` keeps grouped chat-history expansion state local to the mounted view. Ralph session groups, For Each run groups, Map Reduce run groups, and plan-file/history groups render collapsed by default on diff --git a/.github/skills/coc-knowledge/references/process-store.md b/.github/skills/coc-knowledge/references/process-store.md index 9e8072143..bf37248f4 100644 --- a/.github/skills/coc-knowledge/references/process-store.md +++ b/.github/skills/coc-knowledge/references/process-store.md @@ -6,7 +6,7 @@ Location: `packages/forge/src/` (`process-store.ts`, `sqlite-process-store.ts`, ## SqliteProcessStore -Default backend. Single `processes.db` file at `~/.coc/processes.db`. Schema version 20. +Default backend. Single `processes.db` file at `~/.coc/processes.db`. Schema version 22. ### Tables @@ -20,6 +20,8 @@ Default backend. Single `processes.db` file at `~/.coc/processes.db`. Schema ver | `commit_chat_bindings` | commitHash → taskId mappings | | `pull_request_chat_bindings` | prId → taskId mappings (one persistent chat per PR per workspace) | | `work_item_chat_bindings` | workItemId → taskId mappings (one persistent chat per Work Item per workspace) | +| `task_groups` | Generic parent/child task-group registry: one row per hierarchical run/session (type, title, normalized status, hidden flag, origin process, extra JSON) | +| `task_group_members` | Child links per task group: role ('generation'/'item'/'reduce'/'iteration'/'final-check'/'analyzer'/'critic'), task/process IDs, itemKey, memberIndex | Commit, Pull Request, and Work Item binding routes also expose a workspace-scoped fresh-chat operation that archives the currently bound process and deletes only that target's binding. It does not fork the process, copy conversation turns, or create a new process record; the next lens send uses the normal target-specific creation flow to bind a fresh chat. @@ -48,6 +50,10 @@ unarchiveProcesses(ids) getPinnedProcesses() ``` +### Task Group Registry + +`SqliteTaskGroupStore` (forge) owns the `task_groups`/`task_group_members` tables over the shared database handle. The CoC server wraps it in `TaskGroupService` (`packages/coc/src/server/task-groups/`): feature stores fire change hooks (`onRunChanged` on the For Each/Map Reduce/Dream stores, a dataDir-keyed module listener on `RalphSessionStore`) that project run/session records into the registry via `feature-sync.ts`. Group statuses are normalized to `draft | running | completed | failed | cancelled`; feature states like `reducing`, `approved`, or `grilling` ride in `extra.detailStatus`. Child tasks additionally carry a generic `payload.context.taskGroup = { groupId, groupType, role, itemKey?, workspaceId }` tag mirrored into `AIProcess.metadata.taskGroup` and forwarded on history items. Dream groups are `hidden` (linkage-only). `backfillTaskGroups` idempotently projects pre-framework runs/sessions on server start. Registry writes are best-effort — failures log and never break feature orchestration. With the legacy file process-store backend the registry is in-memory only. + ## FileProcessStore (Legacy) Per-repo directory layout under `~/.coc/repos/<workspaceId>/processes/`. Used only when `store.backend: file` in config. 500-process cap. diff --git a/.github/skills/coc-knowledge/references/ralph.md b/.github/skills/coc-knowledge/references/ralph.md index 9ab4f87ca..e2786a65f 100644 --- a/.github/skills/coc-knowledge/references/ralph.md +++ b/.github/skills/coc-knowledge/references/ralph.md @@ -56,6 +56,18 @@ The Ralph executor is the only writer. It must: `currentIteration`, append to `iterations[]`, and update `phase` for terminal signals. +After every successful `session.json` write (`initSession` and +`updateSessionRecord`), the store notifies a module-level, dataDir-keyed +session-change listener (`registerRalphSessionChangeListener`). The server +registers one listener at startup that projects the record into the generic +task-group registry (`syncRalphSessionToTaskGroup`): groupId = sessionId, +type `ralph`, children = iterations (role `iteration`) and final checks (role +`final-check`). Iteration and final-check queue tasks also carry the generic +`payload.context.taskGroup` tag alongside `context.ralph`. +`listSessionIds(workspaceId)` enumerates persisted session directories (used +by the registry backfill). Listener errors are swallowed — registry sync never +breaks session persistence. + Readers, including REST handlers and the SPA `useRalphSessionView` hook, treat `session.json` and `progress.md` as source of truth and never mutate them. The session read route also returns raw text for every direct file in the session diff --git a/.github/skills/coc-knowledge/references/rest-api.md b/.github/skills/coc-knowledge/references/rest-api.md index 68509dcee..f3f448b30 100644 --- a/.github/skills/coc-knowledge/references/rest-api.md +++ b/.github/skills/coc-knowledge/references/rest-api.md @@ -84,7 +84,16 @@ CoC server exposes HTTP endpoints organized by domain. All routes are registered | PATCH | `/api/processes/:id/turns/:idx/pin` | Pin a turn | | PATCH | `/api/processes/:id/turns/:idx/archive` | Archive a turn | | GET | `/api/workspaces/:id/group-pins` | List workspace-scoped parent-row group pins for Ralph session groups, For Each run groups, and Map Reduce run groups, sorted newest pin first | -| PATCH | `/api/workspaces/:id/group-pins/:type/:groupId` | Pin/unpin a parent group row. `type` is `ralph-session`, `for-each-run`, or `map-reduce-run`; body `{ pinned: boolean }`. This updates only the group pin record and does not mutate child process pin/archive metadata | +| PATCH | `/api/workspaces/:id/group-pins/:type/:groupId` | Pin/unpin a parent group row. `type` is an open string: legacy names `ralph-session`, `for-each-run`, `map-reduce-run` plus any registered task-group type; body `{ pinned: boolean }`. This updates only the group pin record and does not mutate child process pin/archive metadata | + +## Task Groups + +Generic parent/child task relationship registry shared by For Each, Map Reduce, Ralph, Dreams, and future hierarchical features. Always registered (no feature flag). + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/:id/task-groups` | List visible task-group summaries (group record + child links with roles). Query: `type=` filters by group type; `includeHidden=true` includes linkage-only groups (Dream runs) | +| GET | `/api/workspaces/:id/task-groups/:groupId` | Get one task-group summary; 404 when unknown | ## Queue diff --git a/packages/coc-client/src/client.ts b/packages/coc-client/src/client.ts index c6b65aaab..4f40241a1 100644 --- a/packages/coc-client/src/client.ts +++ b/packages/coc-client/src/client.ts @@ -1,4 +1,4 @@ -import { AdminClient, AgentProvidersClient, DbBrowserClient, DreamsClient, ExplorerClient, ForEachClient, GitClient, HealthClient, LoopsClient, MapReduceClient, MemoryClient, MemoryV2Client, NotesClient, PreferencesClient, ProcessesClient, PromptHistoryClient, PullRequestsClient, QueueClient, SchedulesClient, SeenStateClient, ServersClient, SkillsClient, StatsClient, SuggestionsClient, SyncClient, TasksClient, TemplatesClient, WikiClient, WorkflowClient, WorkItemsClient, WorkspacesClient } from './domains'; +import { AdminClient, AgentProvidersClient, DbBrowserClient, DreamsClient, ExplorerClient, ForEachClient, GitClient, HealthClient, LoopsClient, MapReduceClient, MemoryClient, MemoryV2Client, NotesClient, PreferencesClient, ProcessesClient, PromptHistoryClient, PullRequestsClient, QueueClient, SchedulesClient, SeenStateClient, ServersClient, SkillsClient, StatsClient, SuggestionsClient, SyncClient, TaskGroupsClient, TasksClient, TemplatesClient, WikiClient, WorkflowClient, WorkItemsClient, WorkspacesClient } from './domains'; import { HttpTransport, normalizeOptions } from './http'; import { EventsClient } from './realtime'; import type { CocClientOptions, CocRequestOptions, NormalizedCocClientOptions } from './types'; @@ -27,6 +27,7 @@ export class CocClient { readonly skills: SkillsClient; readonly stats: StatsClient; readonly suggestions: SuggestionsClient; + readonly taskGroups: TaskGroupsClient; readonly tasks: TasksClient; readonly templates: TemplatesClient; readonly wiki: WikiClient; @@ -66,6 +67,7 @@ export class CocClient { this.skills = new SkillsClient(this.transport); this.stats = new StatsClient(this.transport); this.suggestions = new SuggestionsClient(this.transport); + this.taskGroups = new TaskGroupsClient(this.transport); this.tasks = new TasksClient(this.transport); this.templates = new TemplatesClient(this.transport); this.wiki = new WikiClient(this.transport, this.options); diff --git a/packages/coc-client/src/contracts/index.ts b/packages/coc-client/src/contracts/index.ts index 3f10fe7b5..4808e1016 100644 --- a/packages/coc-client/src/contracts/index.ts +++ b/packages/coc-client/src/contracts/index.ts @@ -16,6 +16,7 @@ export * from './schedules'; export * from './seen-state'; export * from './skills'; export * from './stats'; +export * from './task-groups'; export * from './tasks'; export * from './templates'; export * from './servers'; diff --git a/packages/coc-client/src/contracts/processes.ts b/packages/coc-client/src/contracts/processes.ts index 32baba7f0..c61148daa 100644 --- a/packages/coc-client/src/contracts/processes.ts +++ b/packages/coc-client/src/contracts/processes.ts @@ -203,7 +203,11 @@ export interface PinnedTurnsResponse { turns: ConversationTurn[]; } -export type ProcessGroupPinType = 'ralph-session' | 'for-each-run' | 'map-reduce-run'; +/** + * Group-pin type. Open union: the known literals are the legacy pin type + * names; any registered task-group type pins under its own type string. + */ +export type ProcessGroupPinType = 'ralph-session' | 'for-each-run' | 'map-reduce-run' | (string & {}); export interface ProcessGroupPin { type: ProcessGroupPinType; diff --git a/packages/coc-client/src/contracts/task-groups.ts b/packages/coc-client/src/contracts/task-groups.ts new file mode 100644 index 000000000..717bc1b4a --- /dev/null +++ b/packages/coc-client/src/contracts/task-groups.ts @@ -0,0 +1,70 @@ +/** + * Task Groups — generic parent/child task relationship registry. + * + * One summary shape covers every hierarchical feature (For Each runs, + * Map Reduce runs, Ralph sessions, Dream runs, and future group types). + * Feature-specific summary fields ride in `extra`. + */ + +/** Normalized group lifecycle. Feature-specific states ride in `extra.detailStatus`. */ +export type TaskGroupStatus = 'draft' | 'running' | 'completed' | 'failed' | 'cancelled'; + +/** Known group types. The registry is open — future types are plain strings. */ +export type TaskGroupType = 'for-each' | 'map-reduce' | 'ralph' | 'dream' | (string & {}); + +export interface TaskGroupChildLink { + /** Child role within the group ('generation' | 'item' | 'reduce' | 'iteration' | 'grilling' | 'analyzer' | 'critic' | ...). */ + role: string; + taskId?: string; + processId?: string; + /** Stable per-item key (For Each/Map Reduce item ID, Ralph iteration index, ...). */ + itemKey?: string; + /** Ordering hint within the group (e.g. iteration number). */ + memberIndex?: number; + linkedAt: string; +} + +export interface TaskGroupSummary { + groupId: string; + workspaceId: string; + type: TaskGroupType; + title?: string; + status: TaskGroupStatus; + /** Hidden groups are linkage-only (e.g. Dream internals) — not rendered as chat-list groups. */ + hidden?: boolean; + /** Process ID of the visible origin chat (generation chat, grilling chat). */ + originProcessId?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + childCount: number; + children: TaskGroupChildLink[]; + /** Feature summary extras (itemCount, reduceStatus, detailStatus, loopCount, ...). */ + extra?: Record<string, unknown>; +} + +export interface ListTaskGroupsQuery { + type?: string; + includeHidden?: boolean; +} + +export interface ListTaskGroupsResponse { + groups: TaskGroupSummary[]; +} + +export interface TaskGroupResponse { + group: TaskGroupSummary; +} + +/** + * The `payload.context.taskGroup` tag carried by every child task of a group. + * Mirrored into `AIProcess.metadata.taskGroup` when the process is created. + */ +export interface TaskGroupRef { + groupId: string; + groupType: TaskGroupType; + /** Child role within the group. */ + role: string; + itemKey?: string; + workspaceId: string; +} diff --git a/packages/coc-client/src/domains/index.ts b/packages/coc-client/src/domains/index.ts index d6b1ee6bb..92056a05f 100644 --- a/packages/coc-client/src/domains/index.ts +++ b/packages/coc-client/src/domains/index.ts @@ -20,6 +20,7 @@ export { ServersClient } from './servers'; export { SkillsClient } from './skills'; export { StatsClient } from './stats'; export { SuggestionsClient } from './suggestions'; +export { TaskGroupsClient } from './task-groups'; export { TasksClient } from './tasks'; export { TemplatesClient } from './templates'; export { WorkItemsClient } from './work-items'; diff --git a/packages/coc-client/src/domains/task-groups.ts b/packages/coc-client/src/domains/task-groups.ts new file mode 100644 index 000000000..f105a9774 --- /dev/null +++ b/packages/coc-client/src/domains/task-groups.ts @@ -0,0 +1,34 @@ +import type { + ListTaskGroupsQuery, + ListTaskGroupsResponse, + TaskGroupResponse, + TaskGroupSummary, +} from '../contracts'; +import type { RequestAdapter } from '../types'; +import { encodePathSegment } from '../url'; + +function groupsPath(workspaceId: string, suffix = ''): string { + return `/workspaces/${encodePathSegment(workspaceId)}/task-groups${suffix}`; +} + +export class TaskGroupsClient { + constructor(private readonly transport: RequestAdapter) {} + + async list(workspaceId: string, query?: ListTaskGroupsQuery): Promise<TaskGroupSummary[]> { + const params = new URLSearchParams(); + if (query?.type) params.set('type', query.type); + if (query?.includeHidden) params.set('includeHidden', 'true'); + const queryString = params.toString(); + const response = await this.transport.request<ListTaskGroupsResponse>( + groupsPath(workspaceId, queryString ? `?${queryString}` : ''), + ); + return response.groups ?? []; + } + + async get(workspaceId: string, groupId: string): Promise<TaskGroupSummary> { + const response = await this.transport.request<TaskGroupResponse>( + groupsPath(workspaceId, `/${encodePathSegment(groupId)}`), + ); + return response.group; + } +} diff --git a/packages/coc/AGENTS.md b/packages/coc/AGENTS.md index da4b2bbde..ff8c4c18b 100644 --- a/packages/coc/AGENTS.md +++ b/packages/coc/AGENTS.md @@ -77,6 +77,18 @@ all have their own `references/*.md`. `DreamInternalProcessExecutor`/`ProcessLifecycleRunner` so analyzer and critic prompts/responses are persisted as read-only internal processes. Do not add direct `aiService.sendMessage(...)` calls under `src/server/dreams/`. +- **Hierarchical parent/child task features** (For Each, Map Reduce, Ralph, + Dreams, and anything future that schedules sub-tasks) must use the task-group + framework instead of inventing new linkage: register/update the group through + `src/server/task-groups/` (feature stores fire change hooks projected by + `feature-sync.ts`), tag every child task with + `payload.context.taskGroup = { groupId, groupType, role, itemKey?, workspaceId }` + (mirrored to `metadata.taskGroup` by `ProcessLifecycleRunner`), and add a + chat-list descriptor in + `src/server/spa/client/react/features/chat/task-group-descriptors.ts`. + Group statuses are normalized (`draft|running|completed|failed|cancelled`) + with feature detail in `extra.detailStatus`; registry writes are best-effort + and must never break orchestration. - **Follow-up enqueue sites** must call `resolveFollowUpMode(...)` and set `payload.mode`. `FollowUpExecutor.executeFollowUp` fail-loud warns + defaults to `'ask'` if missing. diff --git a/packages/coc/src/server/dreams/dream-store.ts b/packages/coc/src/server/dreams/dream-store.ts index dcdabd81d..26245483d 100644 --- a/packages/coc/src/server/dreams/dream-store.ts +++ b/packages/coc/src/server/dreams/dream-store.ts @@ -40,6 +40,12 @@ interface StoredDreamRunsFile { export interface FileDreamStoreOptions { dataDir: string; + /** + * Invoked after every successful dream-run write with the fresh run record. + * Used to keep the generic task-group registry in sync. Errors thrown by + * the hook are swallowed — registry sync must never break dream runs. + */ + onRunChanged?: (run: DreamRunRecord) => void; } interface NormalizedDreamCandidate extends Omit<CreateDreamCandidateInput, 'dedupFingerprint'> { @@ -373,10 +379,20 @@ function dedupeSourceRanges(ranges: readonly DreamSourceRange[]): DreamSourceRan export class FileDreamStore { private readonly dataDir: string; + private readonly onRunChanged?: (run: DreamRunRecord) => void; private writeQueue: Promise<void> = Promise.resolve(); constructor(options: FileDreamStoreOptions) { this.dataDir = options.dataDir; + this.onRunChanged = options.onRunChanged; + } + + private notifyRunChanged(run: DreamRunRecord): void { + try { + this.onRunChanged?.(run); + } catch { + // Registry sync must never break dream-run persistence. + } } private dreamsDir(workspaceId: string): string { @@ -477,6 +493,7 @@ export class FileDreamStore { startedAt: now, }; await this.writeRuns(workspaceId, [...runs, run]); + this.notifyRunChanged(run); return run; }); } @@ -775,6 +792,7 @@ export class FileDreamStore { const next = buildNext(current); runs[index] = next; await this.writeRuns(workspaceId, runs); + this.notifyRunChanged(next); return next; }); } diff --git a/packages/coc/src/server/executors/process-lifecycle-runner.ts b/packages/coc/src/server/executors/process-lifecycle-runner.ts index 2f91895eb..f51894241 100644 --- a/packages/coc/src/server/executors/process-lifecycle-runner.ts +++ b/packages/coc/src/server/executors/process-lifecycle-runner.ts @@ -61,6 +61,7 @@ import { serializeForEachMetadata, serializeMapReduceMetadata, serializeRalphMetadata, + serializeTaskGroupMetadata, } from '../tasks/task-types'; import { deriveScriptTitle } from './title-generator'; import { BaseExecutor } from './base-executor'; @@ -478,6 +479,7 @@ export class ProcessLifecycleRunner extends BaseExecutor { forEach: serializeForEachMetadata(task.payload), mapReduce: serializeMapReduceMetadata(task.payload), ralph: serializeRalphMetadata(task.payload), + taskGroup: serializeTaskGroupMetadata(task.payload), dream: isDreamRunPayload(task.payload) ? { workspaceId: task.payload.workspaceId, diff --git a/packages/coc/src/server/for-each/for-each-run-executor.ts b/packages/coc/src/server/for-each/for-each-run-executor.ts index 64f254414..2ab91cbb3 100644 --- a/packages/coc/src/server/for-each/for-each-run-executor.ts +++ b/packages/coc/src/server/for-each/for-each-run-executor.ts @@ -64,6 +64,13 @@ function buildChildTask(run: ForEachRun, item: ForEachItem): CreateTaskInput { itemId: item.id, childMode: run.childMode, }, + taskGroup: { + groupId: run.runId, + groupType: 'for-each', + role: 'item', + itemKey: item.id, + workspaceId: run.workspaceId, + }, }, }, config: { diff --git a/packages/coc/src/server/for-each/for-each-run-store.ts b/packages/coc/src/server/for-each/for-each-run-store.ts index 118c4a515..39808edbe 100644 --- a/packages/coc/src/server/for-each/for-each-run-store.ts +++ b/packages/coc/src/server/for-each/for-each-run-store.ts @@ -18,6 +18,12 @@ import { assertDraftInitialStatuses, normalizeForEachItems } from './for-each-pl export interface FileForEachRunStoreOptions { dataDir: string; + /** + * Invoked after every successful run write with the fresh run state. + * Used to keep the generic task-group registry in sync. Errors thrown + * by the hook are swallowed — registry sync must never break runs. + */ + onRunChanged?: (run: ForEachRun) => void; } const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/; @@ -88,10 +94,20 @@ function hasFailedItem(items: ForEachItem[]): boolean { export class FileForEachRunStore { private readonly dataDir: string; + private readonly onRunChanged?: (run: ForEachRun) => void; private writeQueue: Promise<void> = Promise.resolve(); constructor(options: FileForEachRunStoreOptions) { this.dataDir = options.dataDir; + this.onRunChanged = options.onRunChanged; + } + + private notifyRunChanged(run: ForEachRun): void { + try { + this.onRunChanged?.(run); + } catch { + // Registry sync must never break run persistence. + } } private runsDir(workspaceId: string): string { @@ -130,6 +146,7 @@ export class FileForEachRunStore { const { items, ...metadata } = run; await atomicWriteJSON(this.runPath(run.workspaceId, run.runId), metadata); await atomicWriteJSON(this.itemsPath(run.workspaceId, run.runId), items); + this.notifyRunChanged(run); } async createDraftRun(input: CreateForEachRunInput): Promise<ForEachRun> { @@ -156,9 +173,9 @@ export class FileForEachRunStore { if (input.generationProcessId) metadata.generationProcessId = input.generationProcessId; if (input.generationId) metadata.generationId = input.generationId; - await atomicWriteJSON(this.runPath(input.workspaceId, runId), metadata); - await atomicWriteJSON(this.itemsPath(input.workspaceId, runId), normalizedItems); - return { ...metadata, items: normalizedItems }; + const run: ForEachRun = { ...metadata, items: normalizedItems }; + await this.writeRun(run); + return run; }); } @@ -213,9 +230,9 @@ export class FileForEachRunStore { ...(input.childMode !== undefined ? { childMode: input.childMode } : {}), updatedAt: new Date().toISOString(), }; - await atomicWriteJSON(this.runPath(workspaceId, runId), nextMetadata); - await atomicWriteJSON(this.itemsPath(workspaceId, runId), normalizedItems); - return { ...nextMetadata, items: normalizedItems }; + const nextRun: ForEachRun = { ...nextMetadata, items: normalizedItems }; + await this.writeRun(nextRun); + return nextRun; }); } @@ -238,8 +255,9 @@ export class FileForEachRunStore { approvedAt: now, updatedAt: now, }; - await atomicWriteJSON(this.runPath(workspaceId, runId), nextMetadata); - return { ...nextMetadata, items }; + const nextRun: ForEachRun = { ...nextMetadata, items }; + await this.writeRun(nextRun); + return nextRun; }); } diff --git a/packages/coc/src/server/map-reduce/map-reduce-run-executor.ts b/packages/coc/src/server/map-reduce/map-reduce-run-executor.ts index a4a75e5a3..e47aa9a0e 100644 --- a/packages/coc/src/server/map-reduce/map-reduce-run-executor.ts +++ b/packages/coc/src/server/map-reduce/map-reduce-run-executor.ts @@ -102,6 +102,13 @@ function buildMapChildTask(run: MapReduceRun, item: MapReduceItem): CreateTaskIn phase: 'map', childMode: run.childMode, }, + taskGroup: { + groupId: run.runId, + groupType: 'map-reduce', + role: 'item', + itemKey: item.id, + workspaceId: run.workspaceId, + }, }, }, config: childTaskConfig(run), @@ -131,6 +138,12 @@ function buildReduceChildTask(run: MapReduceRun): CreateTaskInput { phase: 'reduce', childMode: run.childMode, }, + taskGroup: { + groupId: run.runId, + groupType: 'map-reduce', + role: 'reduce', + workspaceId: run.workspaceId, + }, }, }, config: childTaskConfig(run), diff --git a/packages/coc/src/server/map-reduce/map-reduce-run-store.ts b/packages/coc/src/server/map-reduce/map-reduce-run-store.ts index 66d089436..a341492b2 100644 --- a/packages/coc/src/server/map-reduce/map-reduce-run-store.ts +++ b/packages/coc/src/server/map-reduce/map-reduce-run-store.ts @@ -27,6 +27,12 @@ import { export interface FileMapReduceRunStoreOptions { dataDir: string; + /** + * Invoked after every successful run write with the fresh run state. + * Used to keep the generic task-group registry in sync. Errors thrown + * by the hook are swallowed — registry sync must never break runs. + */ + onRunChanged?: (run: MapReduceRun) => void; } const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/; @@ -149,10 +155,12 @@ function mapPhaseStatusAfterManualSkip(items: MapReduceItem[]): MapReduceRun['st export class FileMapReduceRunStore { private readonly dataDir: string; + private readonly onRunChanged?: (run: MapReduceRun) => void; private writeQueue: Promise<void> = Promise.resolve(); constructor(options: FileMapReduceRunStoreOptions) { this.dataDir = options.dataDir; + this.onRunChanged = options.onRunChanged; } private runsDir(workspaceId: string): string { @@ -198,6 +206,11 @@ export class FileMapReduceRunStore { await atomicWriteJSON(this.runPath(run.workspaceId, run.runId), metadata); await atomicWriteJSON(this.itemsPath(run.workspaceId, run.runId), items); await atomicWriteJSON(this.reduceStepPath(run.workspaceId, run.runId), reduceStep); + try { + this.onRunChanged?.(run); + } catch { + // Registry sync must never break run persistence. + } } async createDraftRun(input: CreateMapReduceRunInput): Promise<MapReduceRun> { diff --git a/packages/coc/src/server/processes/group-pin-handler.ts b/packages/coc/src/server/processes/group-pin-handler.ts index d76ac3965..a96a5f94a 100644 --- a/packages/coc/src/server/processes/group-pin-handler.ts +++ b/packages/coc/src/server/processes/group-pin-handler.ts @@ -3,7 +3,7 @@ import { sendJSON } from '../core/api-handler'; import { badRequest, handleAPIError } from '../errors'; import { parseBodyOrReject, resolveWorkspaceOrFail } from '../shared/handler-utils'; import type { Route } from '../types'; -import { GROUP_PIN_TYPES, GroupPinStore, isGroupPinType, normalizeGroupId } from './group-pin-store'; +import { GroupPinStore, normalizeGroupId, normalizeGroupPinType } from './group-pin-store'; export function registerGroupPinRoutes(routes: Route[], store: ProcessStore, dataDir: string): void { const groupPinStore = new GroupPinStore(dataDir); @@ -26,9 +26,9 @@ export function registerGroupPinRoutes(routes: Route[], store: ProcessStore, dat const workspace = await resolveWorkspaceOrFail(store, match!, res); if (!workspace) return; - const type = decodeURIComponent(match![2]); - if (!isGroupPinType(type)) { - handleAPIError(res, badRequest('Invalid group pin type', { allowedTypes: GROUP_PIN_TYPES })); + const type = normalizeGroupPinType(decodeURIComponent(match![2])); + if (!type) { + handleAPIError(res, badRequest('Invalid group pin type')); return; } diff --git a/packages/coc/src/server/processes/group-pin-store.ts b/packages/coc/src/server/processes/group-pin-store.ts index 1bf38a77c..7410e73a3 100644 --- a/packages/coc/src/server/processes/group-pin-store.ts +++ b/packages/coc/src/server/processes/group-pin-store.ts @@ -2,9 +2,10 @@ import * as fs from 'fs'; import { atomicWriteJson } from '../shared/fs-utils'; import { getRepoDataPath } from '../paths'; +/** Legacy pin type names. The pin store is open: any task-group type pins under its own type string. */ export const GROUP_PIN_TYPES = ['ralph-session', 'for-each-run', 'map-reduce-run'] as const; -export type GroupPinType = typeof GROUP_PIN_TYPES[number]; +export type GroupPinType = typeof GROUP_PIN_TYPES[number] | (string & {}); export interface GroupPin { type: GroupPinType; @@ -21,8 +22,18 @@ interface GroupPinState { const GROUP_PINS_FILE = 'group-pins.json'; +/** + * Any non-empty type string is a valid pin type — the legacy literal names + * and registered task-group types share the same open namespace. + */ +export function normalizeGroupPinType(value: unknown): GroupPinType | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + export function isGroupPinType(value: unknown): value is GroupPinType { - return typeof value === 'string' && (GROUP_PIN_TYPES as readonly string[]).includes(value); + return normalizeGroupPinType(value) !== undefined; } export function normalizeGroupId(groupId: unknown): string | undefined { diff --git a/packages/coc/src/server/ralph/enqueue-final-check.ts b/packages/coc/src/server/ralph/enqueue-final-check.ts index c4149354e..9327c699e 100644 --- a/packages/coc/src/server/ralph/enqueue-final-check.ts +++ b/packages/coc/src/server/ralph/enqueue-final-check.ts @@ -140,6 +140,13 @@ export function buildFinalCheckTaskPayload(input: BuildFinalCheckTaskInput) { loopIndex, }, }, + taskGroup: { + groupId: sessionId, + groupType: 'ralph' as const, + role: 'final-check', + itemKey: `check-${checkIndex}`, + workspaceId, + }, }, }, }; diff --git a/packages/coc/src/server/ralph/enqueue-iteration.ts b/packages/coc/src/server/ralph/enqueue-iteration.ts index 4938d8b82..618e5725c 100644 --- a/packages/coc/src/server/ralph/enqueue-iteration.ts +++ b/packages/coc/src/server/ralph/enqueue-iteration.ts @@ -94,6 +94,17 @@ export function buildRalphIterationTask(input: BuildRalphIterationTaskInput) { currentIteration: input.iteration, maxIterations: input.maxIterations, }, + ...(input.workspaceId + ? { + taskGroup: { + groupId: input.sessionId, + groupType: 'ralph' as const, + role: 'iteration', + itemKey: String(input.iteration), + workspaceId: input.workspaceId, + }, + } + : {}), }, }, }; diff --git a/packages/coc/src/server/ralph/ralph-session-store.ts b/packages/coc/src/server/ralph/ralph-session-store.ts index e2cf5d3ad..c00c15191 100644 --- a/packages/coc/src/server/ralph/ralph-session-store.ts +++ b/packages/coc/src/server/ralph/ralph-session-store.ts @@ -33,6 +33,29 @@ export interface RalphSessionStoreOptions { dataDir: string; } +/** + * Module-level session-change listeners, keyed by dataDir. + * + * RalphSessionStore instances are constructed ad hoc at every call site, so + * there is no single construction seam to inject a callback. The server + * registers one listener per dataDir at startup (task-group registry sync); + * every store instance for that dataDir notifies it after a successful + * session-record write. Listener errors are swallowed — registry sync must + * never break session persistence. + */ +const sessionChangeListeners = new Map<string, (record: RalphSessionRecord) => void>(); + +export function registerRalphSessionChangeListener( + dataDir: string, + listener: (record: RalphSessionRecord) => void, +): void { + sessionChangeListeners.set(dataDir, listener); +} + +export function unregisterRalphSessionChangeListener(dataDir: string): void { + sessionChangeListeners.delete(dataDir); +} + export interface RalphSessionFile { name: string; content: string; @@ -54,6 +77,14 @@ export interface AppendProgressInput { export class RalphSessionStore { constructor(private readonly options: RalphSessionStoreOptions) {} + private notifySessionChanged(record: RalphSessionRecord): void { + try { + sessionChangeListeners.get(this.options.dataDir)?.(record); + } catch { + // Registry sync must never break session persistence. + } + } + getSessionDir(workspaceId: string, sessionId: string): string { return getRepoDataPath( this.options.dataDir, @@ -98,6 +129,7 @@ export class RalphSessionStore { iterations: [], }; await this.atomicWriteJson(recordPath, record); + this.notifySessionChanged(record); } if (!(await pathExists(progressPath))) { @@ -162,6 +194,19 @@ export class RalphSessionStore { }))); } + /** List all session IDs persisted for a workspace (directory names). */ + async listSessionIds(workspaceId: string): Promise<string[]> { + const dir = getRepoDataPath(this.options.dataDir, workspaceId, SESSIONS_DIR); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code === 'ENOENT') return []; + throw err; + } + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + } + async readSessionRecord( workspaceId: string, sessionId: string, @@ -195,6 +240,7 @@ export class RalphSessionStore { const current = await this.readSessionRecord(workspaceId, sessionId); const next = mutator(current); await this.atomicWriteJson(this.getSessionRecordPath(workspaceId, sessionId), next); + this.notifySessionChanged(next); return next; } diff --git a/packages/coc/src/server/routes/index.ts b/packages/coc/src/server/routes/index.ts index 70230cdcb..f0d48a7b5 100644 --- a/packages/coc/src/server/routes/index.ts +++ b/packages/coc/src/server/routes/index.ts @@ -53,6 +53,11 @@ import { registerSeenStateRoutes } from '../processes/seen-state-handler'; import { registerPromptSuggestionRoutes } from '../processes/prompt-suggestion-handler'; import { registerPromptHistoryRoutes } from '../processes/prompt-history-handler'; import { registerGroupPinRoutes } from '../processes/group-pin-handler'; +import { registerTaskGroupRoutes } from './task-group-routes'; +import { TaskGroupService } from '../task-groups/task-group-service'; +import { syncDreamRunToTaskGroup, syncForEachRunToTaskGroup, syncMapReduceRunToTaskGroup, syncRalphSessionToTaskGroup } from '../task-groups/feature-sync'; +import { registerRalphSessionChangeListener } from '../ralph/ralph-session-store'; +import { backfillTaskGroups } from '../task-groups/backfill'; import { registerPinArchiveRoutes } from '../processes/pin-archive-handler'; import { registerTurnActionRoutes } from '../processes/turn-actions-handler'; import { registerProcessHistoryRoutes } from '../processes/process-history-handler'; @@ -451,6 +456,9 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): registerPromptSuggestionRoutes(routes, store as any, dataDir, resolvedAiService); registerPromptHistoryRoutes(routes, store as any); registerGroupPinRoutes(routes, store, dataDir); + const taskGroupService = TaskGroupService.fromProcessStore(store); + registerTaskGroupRoutes({ routes, store, taskGroupService }); + registerRalphSessionChangeListener(dataDir, record => syncRalphSessionToTaskGroup(taskGroupService, record)); registerPinArchiveRoutes(routes, store as any); registerTurnActionRoutes(routes, store as any, getWsServer); registerProcessHistoryRoutes(routes, store as any); @@ -626,7 +634,10 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): // For Each routes: dedicated reviewed item-plan mode. Routes are registered // with a live feature guard so admin toggles take effect without restart. - const forEachRunStore = new FileForEachRunStore({ dataDir }); + const forEachRunStore = new FileForEachRunStore({ + dataDir, + onRunChanged: run => syncForEachRunToTaskGroup(taskGroupService, run), + }); const forEachRunExecutor = new ForEachRunExecutor({ store: forEachRunStore, enqueueChildTask: (task) => enqueueWithResolvedDefaults(task), @@ -659,7 +670,10 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): // Map Reduce routes: dedicated reviewed map-plan mode with parallel map // dispatch followed by a single reduce child chat. Live feature guard mirrors // For Each so admin toggles take effect without restart. - const mapReduceRunStore = new FileMapReduceRunStore({ dataDir }); + const mapReduceRunStore = new FileMapReduceRunStore({ + dataDir, + onRunChanged: run => syncMapReduceRunToTaskGroup(taskGroupService, run), + }); const mapReduceRunExecutor = new MapReduceRunExecutor({ store: mapReduceRunStore, enqueueChildTask: (task) => enqueueWithResolvedDefaults(task), @@ -693,7 +707,21 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): // Dreams routes: reviewable, workspace-scoped cards plus manual run trigger. // Route registration is always present; the live config guard controls availability. - const dreamStore = new FileDreamStore({ dataDir }); + const dreamStore = new FileDreamStore({ + dataDir, + onRunChanged: run => syncDreamRunToTaskGroup(taskGroupService, run), + }); + + // Project pre-framework runs/sessions into the task-group registry. + // Idempotent and best-effort: failures only log. + void backfillTaskGroups({ + processStore: store, + taskGroupService, + forEachRunStore, + mapReduceRunStore, + dreamStore, + dataDir, + }).catch(() => {}); const getDreamsEnabled = opts.runtimeConfigService ? () => opts.runtimeConfigService!.config.dreams?.enabled ?? false : () => opts.resolvedConfig?.dreams?.enabled ?? false; diff --git a/packages/coc/src/server/routes/task-group-routes.ts b/packages/coc/src/server/routes/task-group-routes.ts new file mode 100644 index 000000000..47eb01b04 --- /dev/null +++ b/packages/coc/src/server/routes/task-group-routes.ts @@ -0,0 +1,57 @@ +import * as url from 'url'; +import type { Route } from '../types'; +import { sendJSON } from '../core/api-handler'; +import { handleAPIError, notFound } from '../errors'; +import { resolveWorkspaceOrFail } from '../shared/handler-utils'; +import type { ProcessStore } from '@plusplusoneplusplus/forge'; +import type { TaskGroupService } from '../task-groups/task-group-service'; + +export interface TaskGroupRouteContext { + routes: Route[]; + store: ProcessStore; + taskGroupService: TaskGroupService; +} + +/** + * Generic task-group registry routes. Always registered — the registry is + * relationship infrastructure, not a feature: an empty workspace simply + * returns an empty list. + */ +export function registerTaskGroupRoutes(ctx: TaskGroupRouteContext): void { + const { routes, store, taskGroupService } = ctx; + + // GET /api/workspaces/:id/task-groups?type=&includeHidden= + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/task-groups$/, + handler: async (req, res, match) => { + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) {return;} + + const query = url.parse(req.url || '', true).query; + const type = typeof query.type === 'string' && query.type.trim() ? query.type.trim() : undefined; + const includeHidden = query.includeHidden === 'true'; + + const groups = taskGroupService.listGroups(workspace.id, { type, includeHidden }); + sendJSON(res, 200, { groups }); + }, + }); + + // GET /api/workspaces/:id/task-groups/:groupId + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/task-groups\/([^/]+)$/, + handler: async (_req, res, match) => { + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) {return;} + + const groupId = decodeURIComponent(match![2]); + const group = taskGroupService.getGroup(workspace.id, groupId); + if (!group) { + handleAPIError(res, notFound('Task group')); + return; + } + sendJSON(res, 200, { group }); + }, + }); +} diff --git a/packages/coc/src/server/shared/process-history-item.ts b/packages/coc/src/server/shared/process-history-item.ts index 6390de9d8..d8ade31c9 100644 --- a/packages/coc/src/server/shared/process-history-item.ts +++ b/packages/coc/src/server/shared/process-history-item.ts @@ -6,7 +6,7 @@ */ import type { AIProcess } from '@plusplusoneplusplus/forge'; -import type { ForEachContext, MapReduceContext } from '../tasks/task-types'; +import { isTaskGroupRef, type ForEachContext, type MapReduceContext, type TaskGroupRef } from '../tasks/task-types'; export interface ProcessHistoryItem { // Core identity @@ -61,6 +61,8 @@ export interface ProcessHistoryItem { forEach?: ForEachContext; /** Map Reduce map/reduce child metadata forwarded from proc.metadata.mapReduce. */ mapReduce?: MapReduceContext; + /** Generic task-group membership tag forwarded from proc.metadata.taskGroup. */ + taskGroup?: TaskGroupRef; } export function toProcessHistoryItem( @@ -107,5 +109,6 @@ export function toProcessHistoryItem( ralph: proc.metadata?.ralph as ProcessHistoryItem['ralph'], forEach: proc.metadata?.forEach as ProcessHistoryItem['forEach'], mapReduce: proc.metadata?.mapReduce as ProcessHistoryItem['mapReduce'], + taskGroup: isTaskGroupRef(proc.metadata?.taskGroup) ? proc.metadata?.taskGroup : undefined, }; } diff --git a/packages/coc/src/server/spa/client/react/features/chat/ForEachRunRow.tsx b/packages/coc/src/server/spa/client/react/features/chat/ForEachRunRow.tsx index b12f11c50..ea62799e3 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/ForEachRunRow.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/ForEachRunRow.tsx @@ -1,9 +1,7 @@ import type React from 'react'; -import { useState } from 'react'; import type { ForEachItemStatus, ForEachRunStatus } from '@plusplusoneplusplus/coc-client'; -import { cn } from '../../ui/cn'; -import { formatRelativeTime } from '../../utils/format'; import type { ForEachRunGroup } from './for-each-run-grouping'; +import { TaskGroupRunRow } from './TaskGroupRunRow'; interface ForEachRunRowProps { group: ForEachRunGroup; @@ -33,19 +31,10 @@ const STATUS_DOT_CLASSES: Record<ForEachRunStatus, string> = { cancelled: 'bg-[#bbbbbb] dark:bg-[#5c5c5c]', }; -const STATUS_LABEL: Record<ForEachRunStatus, string> = { - draft: 'draft', - approved: 'approved', - running: 'running', - failed: 'failed', - completed: 'completed', - cancelled: 'cancelled', -}; - const STATUS_ORDER: ForEachItemStatus[] = ['running', 'failed', 'pending', 'completed', 'skipped']; function summarizeItems(group: ForEachRunGroup): string { - if (group.run.itemCount === 0) return '0 items'; + if (group.run.itemCount === 0) {return '0 items';} const parts = STATUS_ORDER .map(status => { const count = group.run.itemStatusCounts[status] ?? 0; @@ -60,192 +49,26 @@ function titlePreview(text: string): string { return flat.length > 80 ? flat.slice(0, 77) + '...' : flat; } -export function ForEachRunRow({ - group, - selectedRunId, - isRangeSelected, - expanded: controlledExpanded, - onToggleExpanded, - now: _now, - onSelectRun, - onContextMenu, - onTouchStart, - onTouchEnd, - onTouchMove, - isPinned, - onTogglePin, - onMoreActions, - renderTaskCard, -}: ForEachRunRowProps) { - const [expanded, setExpanded] = useState(false); - const isExpanded = controlledExpanded ?? expanded; - const isSelected = selectedRunId === group.runId || !!isRangeSelected; - const childCount = group.children.length; - const timestamp = group.latestTimestamp - ? formatRelativeTime(new Date(group.latestTimestamp).toISOString()) - : ''; - - const toggle = () => { - if (onToggleExpanded) { - onToggleExpanded(); - return; - } - setExpanded(value => !value); - }; - +export function ForEachRunRow({ group, ...behavior }: ForEachRunRowProps) { return ( - <div - data-testid="for-each-run-row" - data-run-id={group.runId} - data-selected={isSelected ? 'true' : 'false'} - className={cn( - isExpanded && 'bg-[#f7f7f8] dark:bg-[#1f1f20]/80', - isSelected && 'ring-1 ring-sky-500/45', - )} - data-pinned={isPinned ? 'true' : undefined} - > - <div - className={cn( - 'chat-row group relative cursor-pointer leading-none transition-colors', - 'grid items-center gap-2 px-3 py-1', - 'grid-cols-[10px_30px_minmax(0,1fr)_auto]', - 'text-[12.5px] h-[26px]', - 'border-b border-[#e0e0e0]/60 dark:border-[#3c3c3c]/60', - 'hover:bg-[#f5f5f5] dark:hover:bg-[#2a2a2b]', - isPinned && 'border-l-2 border-l-amber-400 dark:border-l-amber-500', - )} - onClick={e => { - if (onSelectRun) onSelectRun(group.runId, e); - else toggle(); - }} - onContextMenu={onContextMenu} - onTouchStart={onTouchStart} - onTouchEnd={onTouchEnd} - onTouchMove={onTouchMove} - data-testid="for-each-run-body" - data-run-status={group.run.status} - data-expanded={isExpanded ? 'true' : 'false'} - data-pinned={isPinned ? 'true' : undefined} - aria-expanded={isExpanded} - > - <span - className={cn('w-2 h-2 rounded-full justify-self-center transition-shadow', STATUS_DOT_CLASSES[group.run.status])} - aria-label={`status: ${STATUS_LABEL[group.run.status]}`} - /> - <span - className={cn( - 'inline-flex items-center justify-center rounded-[3px] border font-mono font-bold uppercase select-none', - 'text-[9.5px] leading-none tracking-[0.06em] py-[4px] w-full', - 'text-sky-700 dark:text-sky-300', - 'border-sky-500/70 dark:border-sky-400/60', - 'bg-sky-50/70 dark:bg-sky-400/10', - )} - title="For Each run" - > - FE - </span> - <span className="min-w-0 flex items-center gap-1 overflow-hidden"> - <button - type="button" - className={cn( - 'shrink-0 inline-flex items-center justify-center w-4 h-4 -ml-1 rounded', - 'text-[#848484] dark:text-[#a0a0a0] hover:bg-black/[0.06] dark:hover:bg-white/[0.08]', - 'transition-transform', - isExpanded && 'rotate-90', - )} - onClick={e => { e.stopPropagation(); toggle(); }} - data-testid="for-each-run-chevron" - aria-label={isExpanded ? 'Collapse For Each run' : 'Expand For Each run'} - aria-expanded={isExpanded} - > - <span className="text-[12px] leading-none" aria-hidden="true">›</span> - </button> - {group.hasUnseen && ( - <span - className="shrink-0 w-1.5 h-1.5 rounded-full bg-[#0078d4] dark:bg-[#3794ff]" - data-testid="for-each-run-unseen-dot" - aria-label="Unseen activity" - /> - )} - <span className={cn('chat-title truncate text-[#1e1e1e] dark:text-[#cccccc]', group.hasUnseen && 'font-semibold')}> - For Each - <span className="ml-1.5 font-normal text-[#848484] dark:text-[#9d9d9d]"> - {titlePreview(group.run.originalRequest)} - </span> - </span> - <span - className="shrink min-w-0 max-w-[150px] truncate text-[10px] font-medium leading-none text-sky-700 dark:text-sky-300" - title={summarizeItems(group)} - data-testid="for-each-run-status-summary" - > - {summarizeItems(group)} - </span> - {childCount > 0 && ( - <span - className="shrink-0 text-[10px] font-mono tabular-nums text-[#848484] dark:text-[#9d9d9d]" - data-testid="for-each-run-child-count" - > - {childCount} - </span> - )} - </span> - <span className="flex items-center gap-1 text-[#848484] dark:text-[#999]"> - <span className="chat-row-when text-[10.5px] font-mono tabular-nums whitespace-nowrap group-hover:hidden"> - {timestamp} - </span> - {(onTogglePin || onMoreActions) && ( - <span className="chat-row-actions hidden group-hover:flex items-center gap-0"> - {onTogglePin && ( - <button - type="button" - className="h-5 w-5 grid place-items-center rounded text-[#848484] hover:text-[#1e1e1e] dark:hover:text-[#cccccc] hover:bg-[#ececec] dark:hover:bg-[#2f2f30]" - title={isPinned ? 'Unpin' : 'Pin'} - aria-label={isPinned ? 'Unpin For Each run group' : 'Pin For Each run group'} - data-testid="for-each-run-pin" - onClick={e => { e.stopPropagation(); onTogglePin(); }} - > - <svg width="12" height="12" viewBox="0 0 14 14" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true"> - <path d="M9 1.5l3.5 3.5-2 1-1.5 4-2-2-3 3-.5-.5 3-3-2-2 4-1.5 1-1z"/> - </svg> - </button> - )} - {onMoreActions && ( - <button - type="button" - className="h-5 w-5 grid place-items-center rounded text-[#848484] hover:text-[#1e1e1e] dark:hover:text-[#cccccc] hover:bg-[#ececec] dark:hover:bg-[#2f2f30]" - title="More" - aria-label="More For Each run group actions" - data-testid="for-each-run-more" - onClick={e => { e.stopPropagation(); onMoreActions(e); }} - > - <svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true"> - <circle cx="3.5" cy="7" r="1"/> - <circle cx="7" cy="7" r="1"/> - <circle cx="10.5" cy="7" r="1"/> - </svg> - </button> - )} - </span> - )} - </span> - </div> - - {isExpanded && ( - <div - className="flex flex-col ml-3 pl-2 border-l border-[#e0e0e0] dark:border-[#3c3c3c]" - data-testid="for-each-run-children" - > - {group.children.length === 0 ? ( - <div className="px-3 py-1.5 text-[11px] text-[#848484] dark:text-[#a0a0a0]" data-testid="for-each-run-no-children"> - No child chats yet - </div> - ) : group.children.map((task: any) => ( - <div key={task.id}> - {renderTaskCard(task)} - </div> - ))} - </div> - )} - </div> + <TaskGroupRunRow + group={group} + display={{ + testIdPrefix: 'for-each-run', + label: 'For Each', + badge: 'FE', + groupNoun: 'For Each run', + badgeClassName: 'text-sky-700 dark:text-sky-300 border-sky-500/70 dark:border-sky-400/60 bg-sky-50/70 dark:bg-sky-400/10', + selectedRingClassName: 'ring-sky-500/45', + statusDotClassName: STATUS_DOT_CLASSES[group.run.status], + statusLabel: group.run.status, + status: group.run.status, + summary: summarizeItems(group), + summaryClassName: 'shrink min-w-0 max-w-[150px] truncate text-[10px] font-medium leading-none text-sky-700 dark:text-sky-300', + title: titlePreview(group.run.originalRequest), + emptyChildrenText: 'No child chats yet', + }} + {...behavior} + /> ); } diff --git a/packages/coc/src/server/spa/client/react/features/chat/MapReduceRunRow.tsx b/packages/coc/src/server/spa/client/react/features/chat/MapReduceRunRow.tsx index bb3e3a529..1f2d862f9 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/MapReduceRunRow.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/MapReduceRunRow.tsx @@ -1,9 +1,7 @@ import type React from 'react'; -import { useState } from 'react'; import type { MapReduceItemStatus, MapReduceRunStatus } from '@plusplusoneplusplus/coc-client'; -import { cn } from '../../ui/cn'; -import { formatRelativeTime } from '../../utils/format'; import type { MapReduceRunGroup } from './map-reduce-run-grouping'; +import { TaskGroupRunRow } from './TaskGroupRunRow'; interface MapReduceRunRowProps { group: MapReduceRunGroup; @@ -33,20 +31,10 @@ const STATUS_DOT_CLASSES: Record<MapReduceRunStatus, string> = { cancelled: 'bg-[#bbbbbb] dark:bg-[#5c5c5c]', }; -const STATUS_LABEL: Record<MapReduceRunStatus, string> = { - draft: 'draft', - approved: 'approved', - running: 'running', - reducing: 'reducing', - failed: 'failed', - completed: 'completed', - cancelled: 'cancelled', -}; - const STATUS_ORDER: MapReduceItemStatus[] = ['running', 'failed', 'pending', 'completed', 'skipped']; function summarizeItems(group: MapReduceRunGroup): string { - if (group.run.itemCount === 0) return `0 items · reduce ${group.run.reduceStatus}`; + if (group.run.itemCount === 0) {return `0 items · reduce ${group.run.reduceStatus}`;} const parts = STATUS_ORDER .map(status => { const count = group.run.itemStatusCounts[status] ?? 0; @@ -62,192 +50,26 @@ function titlePreview(text: string): string { return flat.length > 80 ? flat.slice(0, 77) + '...' : flat; } -export function MapReduceRunRow({ - group, - selectedRunId, - isRangeSelected, - expanded: controlledExpanded, - onToggleExpanded, - now: _now, - onSelectRun, - onContextMenu, - onTouchStart, - onTouchEnd, - onTouchMove, - isPinned, - onTogglePin, - onMoreActions, - renderTaskCard, -}: MapReduceRunRowProps) { - const [expanded, setExpanded] = useState(false); - const isExpanded = controlledExpanded ?? expanded; - const isSelected = selectedRunId === group.runId || !!isRangeSelected; - const childCount = group.children.length; - const timestamp = group.latestTimestamp - ? formatRelativeTime(new Date(group.latestTimestamp).toISOString()) - : ''; - - const toggle = () => { - if (onToggleExpanded) { - onToggleExpanded(); - return; - } - setExpanded(value => !value); - }; - +export function MapReduceRunRow({ group, ...behavior }: MapReduceRunRowProps) { return ( - <div - data-testid="map-reduce-run-row" - data-run-id={group.runId} - data-selected={isSelected ? 'true' : 'false'} - className={cn( - isExpanded && 'bg-[#f7f7f8] dark:bg-[#1f1f20]/80', - isSelected && 'ring-1 ring-indigo-500/45', - )} - data-pinned={isPinned ? 'true' : undefined} - > - <div - className={cn( - 'chat-row group relative cursor-pointer leading-none transition-colors', - 'grid items-center gap-2 px-3 py-1', - 'grid-cols-[10px_30px_minmax(0,1fr)_auto]', - 'text-[12.5px] h-[26px]', - 'border-b border-[#e0e0e0]/60 dark:border-[#3c3c3c]/60', - 'hover:bg-[#f5f5f5] dark:hover:bg-[#2a2a2b]', - isPinned && 'border-l-2 border-l-amber-400 dark:border-l-amber-500', - )} - onClick={e => { - if (onSelectRun) onSelectRun(group.runId, e); - else toggle(); - }} - onContextMenu={onContextMenu} - onTouchStart={onTouchStart} - onTouchEnd={onTouchEnd} - onTouchMove={onTouchMove} - data-testid="map-reduce-run-body" - data-run-status={group.run.status} - data-expanded={isExpanded ? 'true' : 'false'} - data-pinned={isPinned ? 'true' : undefined} - aria-expanded={isExpanded} - > - <span - className={cn('w-2 h-2 rounded-full justify-self-center transition-shadow', STATUS_DOT_CLASSES[group.run.status])} - aria-label={`status: ${STATUS_LABEL[group.run.status]}`} - /> - <span - className={cn( - 'inline-flex items-center justify-center rounded-[3px] border font-mono font-bold uppercase select-none', - 'text-[9.5px] leading-none tracking-[0.06em] py-[4px] w-full', - 'text-indigo-700 dark:text-indigo-300', - 'border-indigo-500/70 dark:border-indigo-400/60', - 'bg-indigo-50/70 dark:bg-indigo-400/10', - )} - title="Map Reduce run" - > - MR - </span> - <span className="min-w-0 flex items-center gap-1 overflow-hidden"> - <button - type="button" - className={cn( - 'shrink-0 inline-flex items-center justify-center w-4 h-4 -ml-1 rounded', - 'text-[#848484] dark:text-[#a0a0a0] hover:bg-black/[0.06] dark:hover:bg-white/[0.08]', - 'transition-transform', - isExpanded && 'rotate-90', - )} - onClick={e => { e.stopPropagation(); toggle(); }} - data-testid="map-reduce-run-chevron" - aria-label={isExpanded ? 'Collapse Map Reduce run' : 'Expand Map Reduce run'} - aria-expanded={isExpanded} - > - <span className="text-[12px] leading-none" aria-hidden="true">›</span> - </button> - {group.hasUnseen && ( - <span - className="shrink-0 w-1.5 h-1.5 rounded-full bg-[#0078d4] dark:bg-[#3794ff]" - data-testid="map-reduce-run-unseen-dot" - aria-label="Unseen activity" - /> - )} - <span className={cn('chat-title truncate text-[#1e1e1e] dark:text-[#cccccc]', group.hasUnseen && 'font-semibold')}> - Map Reduce - <span className="ml-1.5 font-normal text-[#848484] dark:text-[#9d9d9d]"> - {titlePreview(group.run.originalRequest)} - </span> - </span> - <span - className="shrink min-w-0 max-w-[180px] truncate text-[10px] font-medium leading-none text-indigo-700 dark:text-indigo-300" - title={summarizeItems(group)} - data-testid="map-reduce-run-status-summary" - > - {summarizeItems(group)} - </span> - {childCount > 0 && ( - <span - className="shrink-0 text-[10px] font-mono tabular-nums text-[#848484] dark:text-[#9d9d9d]" - data-testid="map-reduce-run-child-count" - > - {childCount} - </span> - )} - </span> - <span className="flex items-center gap-1 text-[#848484] dark:text-[#999]"> - <span className="chat-row-when text-[10.5px] font-mono tabular-nums whitespace-nowrap group-hover:hidden"> - {timestamp} - </span> - {(onTogglePin || onMoreActions) && ( - <span className="chat-row-actions hidden group-hover:flex items-center gap-0"> - {onTogglePin && ( - <button - type="button" - className="h-5 w-5 grid place-items-center rounded text-[#848484] hover:text-[#1e1e1e] dark:hover:text-[#cccccc] hover:bg-[#ececec] dark:hover:bg-[#2f2f30]" - title={isPinned ? 'Unpin' : 'Pin'} - aria-label={isPinned ? 'Unpin Map Reduce run group' : 'Pin Map Reduce run group'} - data-testid="map-reduce-run-pin" - onClick={e => { e.stopPropagation(); onTogglePin(); }} - > - <svg width="12" height="12" viewBox="0 0 14 14" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true"> - <path d="M9 1.5l3.5 3.5-2 1-1.5 4-2-2-3 3-.5-.5 3-3-2-2 4-1.5 1-1z"/> - </svg> - </button> - )} - {onMoreActions && ( - <button - type="button" - className="h-5 w-5 grid place-items-center rounded text-[#848484] hover:text-[#1e1e1e] dark:hover:text-[#cccccc] hover:bg-[#ececec] dark:hover:bg-[#2f2f30]" - title="More" - aria-label="More Map Reduce run group actions" - data-testid="map-reduce-run-more" - onClick={e => { e.stopPropagation(); onMoreActions(e); }} - > - <svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true"> - <circle cx="3.5" cy="7" r="1"/> - <circle cx="7" cy="7" r="1"/> - <circle cx="10.5" cy="7" r="1"/> - </svg> - </button> - )} - </span> - )} - </span> - </div> - - {isExpanded && ( - <div - className="flex flex-col ml-3 pl-2 border-l border-[#e0e0e0] dark:border-[#3c3c3c]" - data-testid="map-reduce-run-children" - > - {group.children.length === 0 ? ( - <div className="px-3 py-1.5 text-[11px] text-[#848484] dark:text-[#a0a0a0]" data-testid="map-reduce-run-no-children"> - No map or reduce chats yet - </div> - ) : group.children.map((task: any) => ( - <div key={task.id}> - {renderTaskCard(task)} - </div> - ))} - </div> - )} - </div> + <TaskGroupRunRow + group={group} + display={{ + testIdPrefix: 'map-reduce-run', + label: 'Map Reduce', + badge: 'MR', + groupNoun: 'Map Reduce run', + badgeClassName: 'text-indigo-700 dark:text-indigo-300 border-indigo-500/70 dark:border-indigo-400/60 bg-indigo-50/70 dark:bg-indigo-400/10', + selectedRingClassName: 'ring-indigo-500/45', + statusDotClassName: STATUS_DOT_CLASSES[group.run.status], + statusLabel: group.run.status, + status: group.run.status, + summary: summarizeItems(group), + summaryClassName: 'shrink min-w-0 max-w-[180px] truncate text-[10px] font-medium leading-none text-indigo-700 dark:text-indigo-300', + title: titlePreview(group.run.originalRequest), + emptyChildrenText: 'No map or reduce chats yet', + }} + {...behavior} + /> ); } diff --git a/packages/coc/src/server/spa/client/react/features/chat/TaskGroupRunRow.tsx b/packages/coc/src/server/spa/client/react/features/chat/TaskGroupRunRow.tsx new file mode 100644 index 000000000..72313ceb4 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/TaskGroupRunRow.tsx @@ -0,0 +1,258 @@ +import type React from 'react'; +import { useState } from 'react'; +import { cn } from '../../ui/cn'; +import { formatRelativeTime } from '../../utils/format'; + +/** + * TaskGroupRunRow — shared parent-row chrome for hierarchical task groups in + * the chat list (For Each runs, Map Reduce runs, future group types). + * + * Feature row components stay as thin wrappers that derive the display + * config from their group data; all layout, expansion, pin/more affordances, + * and child rendering live here once. + */ + +export interface TaskGroupRunRowDisplay { + /** DOM test-id prefix (e.g. 'for-each-run'). */ + testIdPrefix: string; + /** Parent-row label (e.g. 'For Each'). */ + label: string; + /** Compact mono badge text (e.g. 'FE'). */ + badge: string; + /** Noun used in tooltips/ARIA copy (e.g. 'For Each run'). */ + groupNoun: string; + /** Accent classes for the badge chip. */ + badgeClassName: string; + /** Ring classes applied when the row is selected. */ + selectedRingClassName: string; + /** Resolved status-dot classes for the group's current status. */ + statusDotClassName: string; + /** Human-readable status (for the dot's ARIA label). */ + statusLabel: string; + /** Raw status value (exposed as data-run-status). */ + status: string; + /** Compact per-item status summary text. */ + summary: string; + /** Accent + sizing classes for the summary text. */ + summaryClassName: string; + /** Title preview shown after the label. */ + title: string; + /** Copy shown when the group has no child chats. */ + emptyChildrenText: string; +} + +export interface TaskGroupRunRowGroup { + runId: string; + children: any[]; + latestTimestamp: number; + hasUnseen: boolean; +} + +export interface TaskGroupRunRowProps { + group: TaskGroupRunRowGroup; + display: TaskGroupRunRowDisplay; + selectedRunId?: string | null; + isRangeSelected?: boolean; + expanded?: boolean; + onToggleExpanded?: () => void; + now: number; + onSelectRun?: (runId: string, event: React.MouseEvent<HTMLDivElement>) => void; + onContextMenu?: (e: React.MouseEvent) => void; + onTouchStart?: (e: React.TouchEvent) => void; + onTouchEnd?: (e: React.TouchEvent) => void; + onTouchMove?: (e: React.TouchEvent) => void; + /** Parent-row pin state and actions. This is independent from child chat pins. */ + isPinned?: boolean; + onTogglePin?: () => void; + onMoreActions?: (e: React.MouseEvent<HTMLButtonElement>) => void; + renderTaskCard: (task: any) => React.ReactNode; +} + +export function TaskGroupRunRow({ + group, + display, + selectedRunId, + isRangeSelected, + expanded: controlledExpanded, + onToggleExpanded, + now: _now, + onSelectRun, + onContextMenu, + onTouchStart, + onTouchEnd, + onTouchMove, + isPinned, + onTogglePin, + onMoreActions, + renderTaskCard, +}: TaskGroupRunRowProps) { + const [expanded, setExpanded] = useState(false); + const isExpanded = controlledExpanded ?? expanded; + const isSelected = selectedRunId === group.runId || !!isRangeSelected; + const childCount = group.children.length; + const timestamp = group.latestTimestamp + ? formatRelativeTime(new Date(group.latestTimestamp).toISOString()) + : ''; + + const toggle = () => { + if (onToggleExpanded) { + onToggleExpanded(); + return; + } + setExpanded(value => !value); + }; + + return ( + <div + data-testid={`${display.testIdPrefix}-row`} + data-run-id={group.runId} + data-selected={isSelected ? 'true' : 'false'} + className={cn( + isExpanded && 'bg-[#f7f7f8] dark:bg-[#1f1f20]/80', + isSelected && `ring-1 ${display.selectedRingClassName}`, + )} + data-pinned={isPinned ? 'true' : undefined} + > + <div + className={cn( + 'chat-row group relative cursor-pointer leading-none transition-colors', + 'grid items-center gap-2 px-3 py-1', + 'grid-cols-[10px_30px_minmax(0,1fr)_auto]', + 'text-[12.5px] h-[26px]', + 'border-b border-[#e0e0e0]/60 dark:border-[#3c3c3c]/60', + 'hover:bg-[#f5f5f5] dark:hover:bg-[#2a2a2b]', + isPinned && 'border-l-2 border-l-amber-400 dark:border-l-amber-500', + )} + onClick={e => { + if (onSelectRun) {onSelectRun(group.runId, e);} + else {toggle();} + }} + onContextMenu={onContextMenu} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + data-testid={`${display.testIdPrefix}-body`} + data-run-status={display.status} + data-expanded={isExpanded ? 'true' : 'false'} + data-pinned={isPinned ? 'true' : undefined} + aria-expanded={isExpanded} + > + <span + className={cn('w-2 h-2 rounded-full justify-self-center transition-shadow', display.statusDotClassName)} + aria-label={`status: ${display.statusLabel}`} + /> + <span + className={cn( + 'inline-flex items-center justify-center rounded-[3px] border font-mono font-bold uppercase select-none', + 'text-[9.5px] leading-none tracking-[0.06em] py-[4px] w-full', + display.badgeClassName, + )} + title={display.groupNoun} + > + {display.badge} + </span> + <span className="min-w-0 flex items-center gap-1 overflow-hidden"> + <button + type="button" + className={cn( + 'shrink-0 inline-flex items-center justify-center w-4 h-4 -ml-1 rounded', + 'text-[#848484] dark:text-[#a0a0a0] hover:bg-black/[0.06] dark:hover:bg-white/[0.08]', + 'transition-transform', + isExpanded && 'rotate-90', + )} + onClick={e => { e.stopPropagation(); toggle(); }} + data-testid={`${display.testIdPrefix}-chevron`} + aria-label={isExpanded ? `Collapse ${display.groupNoun}` : `Expand ${display.groupNoun}`} + aria-expanded={isExpanded} + > + <span className="text-[12px] leading-none" aria-hidden="true">›</span> + </button> + {group.hasUnseen && ( + <span + className="shrink-0 w-1.5 h-1.5 rounded-full bg-[#0078d4] dark:bg-[#3794ff]" + data-testid={`${display.testIdPrefix}-unseen-dot`} + aria-label="Unseen activity" + /> + )} + <span className={cn('chat-title truncate text-[#1e1e1e] dark:text-[#cccccc]', group.hasUnseen && 'font-semibold')}> + {display.label} + <span className="ml-1.5 font-normal text-[#848484] dark:text-[#9d9d9d]"> + {display.title} + </span> + </span> + <span + className={display.summaryClassName} + title={display.summary} + data-testid={`${display.testIdPrefix}-status-summary`} + > + {display.summary} + </span> + {childCount > 0 && ( + <span + className="shrink-0 text-[10px] font-mono tabular-nums text-[#848484] dark:text-[#9d9d9d]" + data-testid={`${display.testIdPrefix}-child-count`} + > + {childCount} + </span> + )} + </span> + <span className="flex items-center gap-1 text-[#848484] dark:text-[#999]"> + <span className="chat-row-when text-[10.5px] font-mono tabular-nums whitespace-nowrap group-hover:hidden"> + {timestamp} + </span> + {(onTogglePin || onMoreActions) && ( + <span className="chat-row-actions hidden group-hover:flex items-center gap-0"> + {onTogglePin && ( + <button + type="button" + className="h-5 w-5 grid place-items-center rounded text-[#848484] hover:text-[#1e1e1e] dark:hover:text-[#cccccc] hover:bg-[#ececec] dark:hover:bg-[#2f2f30]" + title={isPinned ? 'Unpin' : 'Pin'} + aria-label={isPinned ? `Unpin ${display.groupNoun} group` : `Pin ${display.groupNoun} group`} + data-testid={`${display.testIdPrefix}-pin`} + onClick={e => { e.stopPropagation(); onTogglePin(); }} + > + <svg width="12" height="12" viewBox="0 0 14 14" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true"> + <path d="M9 1.5l3.5 3.5-2 1-1.5 4-2-2-3 3-.5-.5 3-3-2-2 4-1.5 1-1z"/> + </svg> + </button> + )} + {onMoreActions && ( + <button + type="button" + className="h-5 w-5 grid place-items-center rounded text-[#848484] hover:text-[#1e1e1e] dark:hover:text-[#cccccc] hover:bg-[#ececec] dark:hover:bg-[#2f2f30]" + title="More" + aria-label={`More ${display.groupNoun} group actions`} + data-testid={`${display.testIdPrefix}-more`} + onClick={e => { e.stopPropagation(); onMoreActions(e); }} + > + <svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true"> + <circle cx="3.5" cy="7" r="1"/> + <circle cx="7" cy="7" r="1"/> + <circle cx="10.5" cy="7" r="1"/> + </svg> + </button> + )} + </span> + )} + </span> + </div> + + {isExpanded && ( + <div + className="flex flex-col ml-3 pl-2 border-l border-[#e0e0e0] dark:border-[#3c3c3c]" + data-testid={`${display.testIdPrefix}-children`} + > + {group.children.length === 0 ? ( + <div className="px-3 py-1.5 text-[11px] text-[#848484] dark:text-[#a0a0a0]" data-testid={`${display.testIdPrefix}-no-children`}> + {display.emptyChildrenText} + </div> + ) : group.children.map((task: any) => ( + <div key={task.id}> + {renderTaskCard(task)} + </div> + ))} + </div> + )} + </div> + ); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/for-each-run-grouping.ts b/packages/coc/src/server/spa/client/react/features/chat/for-each-run-grouping.ts index ca644d079..8b45edbe6 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/for-each-run-grouping.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/for-each-run-grouping.ts @@ -1,19 +1,21 @@ /** * for-each-run-grouping — groups chat history around persisted For Each runs. * + * Thin adapter over the shared task-group grouping engine; matching accepts + * both the generic `taskGroup` tag and the legacy `forEach` context. + * * Pure utility: no React, no side effects. */ import type { ForEachRunSummary } from '@plusplusoneplusplus/coc-client'; +import { + getTaskGroupIdForType, + getTaskTimestamp, + groupBySeededTaskGroups, + type SeededTaskGroup, +} from './task-group-grouping'; -export interface ForEachRunGroup { - kind: 'for-each-run'; - runId: string; - run: ForEachRunSummary; - children: any[]; - latestTimestamp: number; - hasUnseen: boolean; -} +export type ForEachRunGroup = SeededTaskGroup<'for-each-run', ForEachRunSummary>; export type ForEachRunHistoryEntry = ForEachRunGroup | (any & { kind?: undefined }); @@ -27,11 +29,13 @@ export interface ForEachProcessContext { export function getForEachContext(task: any): ForEachProcessContext | undefined { const context = task?.payload?.context?.forEach ?? task?.forEach; - if (!context || typeof context !== 'object') return undefined; + if (!context || typeof context !== 'object') {return undefined;} return context as ForEachProcessContext; } export function getForEachRunId(task: any): string | undefined { + const tagged = getTaskGroupIdForType(task, 'for-each'); + if (tagged) {return tagged;} const runId = getForEachContext(task)?.runId; return typeof runId === 'string' && runId.trim() ? runId : undefined; } @@ -41,85 +45,25 @@ export function isForEachRunTask(task: any): boolean { } export function getForEachEntryTimestamp(entry: ForEachRunHistoryEntry): number { - if (entry.kind === 'for-each-run') return entry.latestTimestamp; + if (entry.kind === 'for-each-run') {return entry.latestTimestamp;} return getTaskTimestamp(entry); } -function getTaskTimestamp(task: any): number { - const ts = task?.lastActivityAt - ?? task?.endTime - ?? task?.completedAt - ?? task?.startedAt - ?? task?.startTime - ?? task?.createdAt - ?? 0; - return typeof ts === 'number' ? ts : +new Date(ts); -} - function getRunTimestamp(run: ForEachRunSummary): number { const ts = run.updatedAt ?? run.completedAt ?? run.cancelledAt ?? run.approvedAt ?? run.createdAt; return ts ? +new Date(ts) : 0; } -function getTaskIds(task: any): string[] { - return [task?.id, task?.processId].filter((id): id is string => typeof id === 'string' && id.length > 0); -} - -function taskMatchesGenerationProcess(task: any, runByGenerationProcessId: Map<string, string>): string | undefined { - for (const id of getTaskIds(task)) { - const runId = runByGenerationProcessId.get(id); - if (runId) return runId; - } - return undefined; -} - -function isUnseenTask(task: any, unseenIds?: Set<string>): boolean { - if (!unseenIds) return false; - return getTaskIds(task).some(id => unseenIds.has(id)); -} - export function groupByForEachRun( items: any[], runs: ForEachRunSummary[], unseenIds?: Set<string>, ): ForEachRunHistoryEntry[] { - if (runs.length === 0) return items; - - const groups = new Map<string, ForEachRunGroup>(); - const runByGenerationProcessId = new Map<string, string>(); - for (const run of runs) { - groups.set(run.runId, { - kind: 'for-each-run', - runId: run.runId, - run, - children: [], - latestTimestamp: getRunTimestamp(run), - hasUnseen: false, - }); - if (run.generationProcessId) { - runByGenerationProcessId.set(run.generationProcessId, run.runId); - } - } - - const standalone: any[] = []; - for (const item of items) { - const runId = getForEachRunId(item) ?? taskMatchesGenerationProcess(item, runByGenerationProcessId); - const group = runId ? groups.get(runId) : undefined; - if (!group) { - standalone.push(item); - continue; - } - group.children.push(item); - } - - for (const group of groups.values()) { - group.children.sort((a, b) => getTaskTimestamp(a) - getTaskTimestamp(b)); - const childTimestamps = group.children.map(getTaskTimestamp).filter(Number.isFinite); - group.latestTimestamp = Math.max(getRunTimestamp(group.run), ...childTimestamps); - group.hasUnseen = group.children.some(child => isUnseenTask(child, unseenIds)); - } - - const entries: ForEachRunHistoryEntry[] = [...groups.values(), ...standalone]; - entries.sort((a, b) => getForEachEntryTimestamp(b) - getForEachEntryTimestamp(a)); - return entries; + return groupBySeededTaskGroups(items, runs, { + kind: 'for-each-run', + getSeedId: run => run.runId, + getSeedTimestamp: getRunTimestamp, + getSeedOriginProcessIds: run => run.generationProcessId ? [run.generationProcessId] : [], + resolveTaskGroupId: getForEachRunId, + }, unseenIds); } diff --git a/packages/coc/src/server/spa/client/react/features/chat/map-reduce-run-grouping.ts b/packages/coc/src/server/spa/client/react/features/chat/map-reduce-run-grouping.ts index adfe80872..ebcd60453 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/map-reduce-run-grouping.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/map-reduce-run-grouping.ts @@ -1,19 +1,21 @@ /** * map-reduce-run-grouping — groups chat history around persisted Map Reduce runs. * + * Thin adapter over the shared task-group grouping engine; matching accepts + * both the generic `taskGroup` tag and the legacy `mapReduce` context. + * * Pure utility: no React, no side effects. */ import type { MapReduceRunSummary } from '@plusplusoneplusplus/coc-client'; +import { + getTaskGroupIdForType, + getTaskTimestamp, + groupBySeededTaskGroups, + type SeededTaskGroup, +} from './task-group-grouping'; -export interface MapReduceRunGroup { - kind: 'map-reduce-run'; - runId: string; - run: MapReduceRunSummary; - children: any[]; - latestTimestamp: number; - hasUnseen: boolean; -} +export type MapReduceRunGroup = SeededTaskGroup<'map-reduce-run', MapReduceRunSummary>; export type MapReduceRunHistoryEntry = MapReduceRunGroup | (any & { kind?: undefined }); @@ -28,11 +30,13 @@ export interface MapReduceProcessContext { export function getMapReduceContext(task: any): MapReduceProcessContext | undefined { const context = task?.payload?.context?.mapReduce ?? task?.mapReduce; - if (!context || typeof context !== 'object') return undefined; + if (!context || typeof context !== 'object') {return undefined;} return context as MapReduceProcessContext; } export function getMapReduceRunId(task: any): string | undefined { + const tagged = getTaskGroupIdForType(task, 'map-reduce'); + if (tagged) {return tagged;} const runId = getMapReduceContext(task)?.runId; return typeof runId === 'string' && runId.trim() ? runId : undefined; } @@ -42,85 +46,25 @@ export function isMapReduceRunTask(task: any): boolean { } export function getMapReduceEntryTimestamp(entry: MapReduceRunHistoryEntry): number { - if (entry.kind === 'map-reduce-run') return entry.latestTimestamp; + if (entry.kind === 'map-reduce-run') {return entry.latestTimestamp;} return getTaskTimestamp(entry); } -function getTaskTimestamp(task: any): number { - const ts = task?.lastActivityAt - ?? task?.endTime - ?? task?.completedAt - ?? task?.startedAt - ?? task?.startTime - ?? task?.createdAt - ?? 0; - return typeof ts === 'number' ? ts : +new Date(ts); -} - function getRunTimestamp(run: MapReduceRunSummary): number { const ts = run.updatedAt ?? run.completedAt ?? run.cancelledAt ?? run.approvedAt ?? run.createdAt; return ts ? +new Date(ts) : 0; } -function getTaskIds(task: any): string[] { - return [task?.id, task?.processId].filter((id): id is string => typeof id === 'string' && id.length > 0); -} - -function taskMatchesGenerationProcess(task: any, runByGenerationProcessId: Map<string, string>): string | undefined { - for (const id of getTaskIds(task)) { - const runId = runByGenerationProcessId.get(id); - if (runId) return runId; - } - return undefined; -} - -function isUnseenTask(task: any, unseenIds?: Set<string>): boolean { - if (!unseenIds) return false; - return getTaskIds(task).some(id => unseenIds.has(id)); -} - export function groupByMapReduceRun( items: any[], runs: MapReduceRunSummary[], unseenIds?: Set<string>, ): MapReduceRunHistoryEntry[] { - if (runs.length === 0) return items; - - const groups = new Map<string, MapReduceRunGroup>(); - const runByGenerationProcessId = new Map<string, string>(); - for (const run of runs) { - groups.set(run.runId, { - kind: 'map-reduce-run', - runId: run.runId, - run, - children: [], - latestTimestamp: getRunTimestamp(run), - hasUnseen: false, - }); - if (run.generationProcessId) { - runByGenerationProcessId.set(run.generationProcessId, run.runId); - } - } - - const standalone: any[] = []; - for (const item of items) { - const runId = getMapReduceRunId(item) ?? taskMatchesGenerationProcess(item, runByGenerationProcessId); - const group = runId ? groups.get(runId) : undefined; - if (!group) { - standalone.push(item); - continue; - } - group.children.push(item); - } - - for (const group of groups.values()) { - group.children.sort((a, b) => getTaskTimestamp(a) - getTaskTimestamp(b)); - const childTimestamps = group.children.map(getTaskTimestamp).filter(Number.isFinite); - group.latestTimestamp = Math.max(getRunTimestamp(group.run), ...childTimestamps); - group.hasUnseen = group.children.some(child => isUnseenTask(child, unseenIds)); - } - - const entries: MapReduceRunHistoryEntry[] = [...groups.values(), ...standalone]; - entries.sort((a, b) => getMapReduceEntryTimestamp(b) - getMapReduceEntryTimestamp(a)); - return entries; + return groupBySeededTaskGroups(items, runs, { + kind: 'map-reduce-run', + getSeedId: run => run.runId, + getSeedTimestamp: getRunTimestamp, + getSeedOriginProcessIds: run => run.generationProcessId ? [run.generationProcessId] : [], + resolveTaskGroupId: getMapReduceRunId, + }, unseenIds); } diff --git a/packages/coc/src/server/spa/client/react/features/chat/ralph-session-grouping.ts b/packages/coc/src/server/spa/client/react/features/chat/ralph-session-grouping.ts index d07572840..6bbeb0099 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/ralph-session-grouping.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/ralph-session-grouping.ts @@ -5,6 +5,7 @@ */ import { deriveRalphTitle } from './ralph-title'; +import { getTaskGroupIdForType, getTaskEndTimestamp, getTaskTimestamp } from './task-group-grouping'; export interface RalphSession { kind: 'ralph-session'; @@ -39,7 +40,9 @@ export type RalphSessionPhase = 'grilling' | 'executing' | 'complete' | 'failed' * field forwarded by `toProcessHistoryItem()`. */ export function getRalphSessionId(task: any): string | undefined { - return (task.payload?.context?.ralph?.sessionId ?? task.ralph?.sessionId) as string | undefined; + return (task.payload?.context?.ralph?.sessionId + ?? task.ralph?.sessionId + ?? getTaskGroupIdForType(task, 'ralph')) as string | undefined; } /** Extract ralph.originalGoal from a process/task. @@ -121,10 +124,10 @@ function computeSessionPhase( if (iterations.length > 0 && iterations.every(t => t.status === 'completed')) { return 'complete'; } - if (iterations.length > 0) return 'executing'; + if (iterations.length > 0) {return 'executing';} if (grillingProcess) { const gPhase = getRalphPhase(grillingProcess); - if (gPhase === 'grilling') return 'grilling'; + if (gPhase === 'grilling') {return 'grilling';} } return 'grilling'; } @@ -164,7 +167,7 @@ export function groupByRalphSession( .filter(t => t !== grillingProcess) .sort((a: any, b: any) => { const iterDiff = getRalphIteration(a) - getRalphIteration(b); - if (iterDiff !== 0) return iterDiff; + if (iterDiff !== 0) {return iterDiff;} // Timestamp tiebreak for items at the same iteration number const tsA = a.createdAt ?? a.startedAt ?? a.startTime ?? 0; const tsB = b.createdAt ?? b.startedAt ?? b.startTime ?? 0; @@ -185,16 +188,10 @@ export function groupByRalphSession( // For sessions still running (grilling / executing) we keep // `lastActivityAt` first — that's the desired behavior: live // activity should float to the top. - function getTs(t: any): number { - const ts = t.lastActivityAt ?? t.endTime ?? t.completedAt ?? t.startedAt ?? t.startTime ?? t.createdAt ?? 0; - return typeof ts === 'number' ? ts : +new Date(ts); - } - function getEndTs(t: any): number { - const ts = t.endTime ?? t.completedAt ?? t.startedAt ?? t.startTime ?? t.createdAt ?? 0; - return typeof ts === 'number' ? ts : +new Date(ts); - } const sessionPhase = computeSessionPhase(grillingProcess, iterations); - const tsPicker = sessionPhase === 'complete' || sessionPhase === 'failed' ? getEndTs : getTs; + const tsPicker = sessionPhase === 'complete' || sessionPhase === 'failed' + ? getTaskEndTimestamp + : getTaskTimestamp; const latestTimestamp = Math.max(...sessionItems.map(tsPicker)); const hasUnseen = unseenIds @@ -225,14 +222,8 @@ export function groupByRalphSession( // we only restrict ralph *complete* sessions to end-time so they stop // floating after late server-side turn appends. entries.sort((a, b) => { - const tsA = a.kind === 'ralph-session' ? a.latestTimestamp : (() => { - const ts = a.lastActivityAt ?? a.endTime ?? a.completedAt ?? a.startedAt ?? a.startTime ?? a.createdAt ?? 0; - return typeof ts === 'number' ? ts : +new Date(ts); - })(); - const tsB = b.kind === 'ralph-session' ? b.latestTimestamp : (() => { - const ts = b.lastActivityAt ?? b.endTime ?? b.completedAt ?? b.startedAt ?? b.startTime ?? b.createdAt ?? 0; - return typeof ts === 'number' ? ts : +new Date(ts); - })(); + const tsA = a.kind === 'ralph-session' ? a.latestTimestamp : getTaskTimestamp(a); + const tsB = b.kind === 'ralph-session' ? b.latestTimestamp : getTaskTimestamp(b); return tsB - tsA; }); diff --git a/packages/coc/src/server/spa/client/react/features/chat/task-group-descriptors.ts b/packages/coc/src/server/spa/client/react/features/chat/task-group-descriptors.ts new file mode 100644 index 000000000..55fd21875 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/task-group-descriptors.ts @@ -0,0 +1,83 @@ +/** + * task-group-descriptors — per-type presentation/behavior descriptors for + * hierarchical task groups in the chat list. + * + * Adding a new hierarchical feature to the chat list means registering a + * descriptor here (label, badge, accent, pin type, matching) instead of + * duplicating grouping modules and row components. + */ + +import { getTaskGroupIdForType } from './task-group-grouping'; +import { getForEachRunId } from './for-each-run-grouping'; +import { getMapReduceRunId } from './map-reduce-run-grouping'; +import { getRalphSessionId } from './ralph-session-grouping'; + +export interface TaskGroupDescriptor { + /** Registry group type ('for-each', 'map-reduce', 'ralph', 'dream', ...). */ + type: string; + /** History-entry discriminator / DOM test-id prefix (e.g. 'for-each-run'). */ + entryKind: string; + /** Workspace group-pin type. Legacy names are kept so persisted pins stay valid. */ + pinType: string; + /** Display label for parent rows (e.g. 'For Each'). */ + label: string; + /** Compact mono badge text (e.g. 'FE'). */ + badge: string; + /** Tailwind accent family used for badges/summaries ('sky', 'indigo', 'purple'). */ + accent: string; + /** + * Whether this type renders as a collapsed parent group in the chat list. + * Hidden/linkage-only types (Dreams) keep their current presentation. + */ + groupable: boolean; + /** Resolve the group ID a task belongs to (generic tag plus legacy feature context). */ + matchesTask(task: any): string | undefined; +} + +export const TASK_GROUP_DESCRIPTORS: Record<string, TaskGroupDescriptor> = { + 'for-each': { + type: 'for-each', + entryKind: 'for-each-run', + pinType: 'for-each-run', + label: 'For Each', + badge: 'FE', + accent: 'sky', + groupable: true, + matchesTask: task => getForEachRunId(task), + }, + 'map-reduce': { + type: 'map-reduce', + entryKind: 'map-reduce-run', + pinType: 'map-reduce-run', + label: 'Map Reduce', + badge: 'MR', + accent: 'indigo', + groupable: true, + matchesTask: task => getMapReduceRunId(task), + }, + ralph: { + type: 'ralph', + entryKind: 'ralph-session', + pinType: 'ralph-session', + label: 'Ralph', + badge: 'RALPH', + accent: 'purple', + groupable: true, + matchesTask: task => getRalphSessionId(task), + }, + dream: { + type: 'dream', + entryKind: 'dream-run', + pinType: 'dream', + label: 'Dream', + badge: 'DR', + accent: 'violet', + // Linkage-only: dream internals keep their current (ungrouped) chat-list presentation. + groupable: false, + matchesTask: task => getTaskGroupIdForType(task, 'dream'), + }, +}; + +export function getTaskGroupDescriptor(type: string): TaskGroupDescriptor | undefined { + return TASK_GROUP_DESCRIPTORS[type]; +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/task-group-grouping.ts b/packages/coc/src/server/spa/client/react/features/chat/task-group-grouping.ts new file mode 100644 index 000000000..12ef13a43 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/task-group-grouping.ts @@ -0,0 +1,173 @@ +/** + * task-group-grouping — shared engine for grouping chat history around + * hierarchical task groups (For Each runs, Map Reduce runs, Ralph sessions, + * and future group types). + * + * Pure utility: no React, no side effects. + * + * Two grouping shapes exist: + * - Seeded groups ({@link groupBySeededTaskGroups}): groups are seeded from + * persisted run summaries; tasks nest by group-id resolution (the generic + * `taskGroup` tag, a feature-specific legacy context, or origin-process + * matching). For Each and Map Reduce use this. + * - Tag-derived groups: groups are formed purely from the tasks themselves + * (Ralph sessions). Ralph keeps its bespoke phase/title logic in + * ralph-session-grouping but shares the helpers below. + */ + +/** Generic task-group membership tag (`payload.context.taskGroup` on live tasks, `taskGroup` on history items). */ +export interface TaskGroupTaskRef { + groupId: string; + groupType: string; + role?: string; + itemKey?: string; + workspaceId?: string; +} + +export function getTaskGroupRef(task: any): TaskGroupTaskRef | undefined { + const ref = task?.payload?.context?.taskGroup ?? task?.taskGroup; + if (!ref || typeof ref !== 'object') {return undefined;} + const candidate = ref as Partial<TaskGroupTaskRef>; + if (typeof candidate.groupId !== 'string' || !candidate.groupId.trim()) {return undefined;} + if (typeof candidate.groupType !== 'string' || !candidate.groupType.trim()) {return undefined;} + return candidate as TaskGroupTaskRef; +} + +/** Resolve the group ID a task belongs to for one specific group type, from the generic tag. */ +export function getTaskGroupIdForType(task: any, groupType: string): string | undefined { + const ref = getTaskGroupRef(task); + return ref && ref.groupType === groupType ? ref.groupId : undefined; +} + +/** Activity timestamp: prefers live conversation activity, falls back to lifecycle times. */ +export function getTaskTimestamp(task: any): number { + const ts = task?.lastActivityAt + ?? task?.endTime + ?? task?.completedAt + ?? task?.startedAt + ?? task?.startTime + ?? task?.createdAt + ?? 0; + return typeof ts === 'number' ? ts : +new Date(ts); +} + +/** + * End timestamp: ignores `lastActivityAt` so finished groups stop floating + * when late server-side turn appends bump activity after completion. + */ +export function getTaskEndTimestamp(task: any): number { + const ts = task?.endTime + ?? task?.completedAt + ?? task?.startedAt + ?? task?.startTime + ?? task?.createdAt + ?? 0; + return typeof ts === 'number' ? ts : +new Date(ts); +} + +export function getTaskIds(task: any): string[] { + return [task?.id, task?.processId].filter((id): id is string => typeof id === 'string' && id.length > 0); +} + +export function isUnseenTask(task: any, unseenIds?: Set<string>): boolean { + if (!unseenIds) {return false;} + return getTaskIds(task).some(id => unseenIds.has(id)); +} + +/** + * One seeded group entry. The field names intentionally match the legacy + * per-feature group interfaces (`runId`/`run`) so existing consumers and + * row components keep working unchanged. + */ +export interface SeededTaskGroup<TKind extends string, TData> { + kind: TKind; + runId: string; + run: TData; + children: any[]; + latestTimestamp: number; + hasUnseen: boolean; +} + +export interface SeededGroupingOptions<TKind extends string, TData> { + /** History-entry discriminator (e.g. 'for-each-run'). */ + kind: TKind; + /** Stable group ID of a seed (run summary). */ + getSeedId(data: TData): string; + /** Base timestamp of a seed, used when it has no (newer) children. */ + getSeedTimestamp(data: TData): number; + /** Origin chat process IDs (e.g. the plan-generation chat) that nest under the seed. */ + getSeedOriginProcessIds?(data: TData): string[]; + /** + * Resolve the group ID a task belongs to (generic tag and/or legacy + * feature context). Origin-process matching is applied afterwards. + */ + resolveTaskGroupId(task: any): string | undefined; +} + +/** + * Group a flat task/history list around persisted group seeds. + * Tasks that match no seed stay standalone. Matches legacy per-feature + * behavior exactly: with no seeds the input is returned untouched, children + * sort oldest-first, and entries sort by latest activity descending. + */ +export function groupBySeededTaskGroups<TKind extends string, TData>( + items: any[], + seeds: TData[], + options: SeededGroupingOptions<TKind, TData>, + unseenIds?: Set<string>, +): Array<SeededTaskGroup<TKind, TData> | any> { + if (seeds.length === 0) {return items;} + + const groups = new Map<string, SeededTaskGroup<TKind, TData>>(); + const groupIdByOriginProcessId = new Map<string, string>(); + for (const seed of seeds) { + const groupId = options.getSeedId(seed); + groups.set(groupId, { + kind: options.kind, + runId: groupId, + run: seed, + children: [], + latestTimestamp: options.getSeedTimestamp(seed), + hasUnseen: false, + }); + for (const originId of options.getSeedOriginProcessIds?.(seed) ?? []) { + groupIdByOriginProcessId.set(originId, groupId); + } + } + + const standalone: any[] = []; + for (const item of items) { + const groupId = options.resolveTaskGroupId(item) + ?? matchByOriginProcess(item, groupIdByOriginProcessId); + const group = groupId ? groups.get(groupId) : undefined; + if (!group) { + standalone.push(item); + continue; + } + group.children.push(item); + } + + for (const group of groups.values()) { + group.children.sort((a, b) => getTaskTimestamp(a) - getTaskTimestamp(b)); + const childTimestamps = group.children.map(getTaskTimestamp).filter(Number.isFinite); + group.latestTimestamp = Math.max(options.getSeedTimestamp(group.run), ...childTimestamps); + group.hasUnseen = group.children.some(child => isUnseenTask(child, unseenIds)); + } + + const entries: Array<SeededTaskGroup<TKind, TData> | any> = [...groups.values(), ...standalone]; + entries.sort((a, b) => getSeededEntryTimestamp(b, options.kind) - getSeededEntryTimestamp(a, options.kind)); + return entries; +} + +function matchByOriginProcess(task: any, groupIdByOriginProcessId: Map<string, string>): string | undefined { + for (const id of getTaskIds(task)) { + const groupId = groupIdByOriginProcessId.get(id); + if (groupId) {return groupId;} + } + return undefined; +} + +function getSeededEntryTimestamp(entry: any, kind: string): number { + if (entry?.kind === kind) {return entry.latestTimestamp as number;} + return getTaskTimestamp(entry); +} diff --git a/packages/coc/src/server/task-groups/backfill.ts b/packages/coc/src/server/task-groups/backfill.ts new file mode 100644 index 000000000..0750e82a1 --- /dev/null +++ b/packages/coc/src/server/task-groups/backfill.ts @@ -0,0 +1,115 @@ +/** + * Task-group registry backfill. + * + * Projects runs/sessions persisted before the task-group framework existed + * into the registry, so historical hierarchies are queryable through the + * generic surface. Idempotent and derived-data only: it re-applies the same + * projections the live change hooks use, never mutates feature stores, and + * is safe to run on every server start. + */ + +import { getLogger, LogCategory, type ProcessStore } from '@plusplusoneplusplus/forge'; +import type { TaskGroupService } from './task-group-service'; +import { + syncDreamRunToTaskGroup, + syncForEachRunToTaskGroup, + syncMapReduceRunToTaskGroup, + syncRalphSessionToTaskGroup, +} from './feature-sync'; +import type { FileForEachRunStore } from '../for-each/for-each-run-store'; +import type { FileMapReduceRunStore } from '../map-reduce/map-reduce-run-store'; +import type { FileDreamStore } from '../dreams/dream-store'; +import { RalphSessionStore } from '../ralph/ralph-session-store'; + +export interface BackfillTaskGroupsOptions { + processStore: ProcessStore; + taskGroupService: TaskGroupService; + forEachRunStore: FileForEachRunStore; + mapReduceRunStore: FileMapReduceRunStore; + dreamStore: FileDreamStore; + dataDir: string; +} + +export interface BackfillTaskGroupsResult { + workspaces: number; + groups: number; + errors: number; +} + +export async function backfillTaskGroups(options: BackfillTaskGroupsOptions): Promise<BackfillTaskGroupsResult> { + const { processStore, taskGroupService, forEachRunStore, mapReduceRunStore, dreamStore, dataDir } = options; + const ralphSessionStore = new RalphSessionStore({ dataDir }); + const result: BackfillTaskGroupsResult = { workspaces: 0, groups: 0, errors: 0 }; + + let workspaces; + try { + workspaces = await processStore.getWorkspaces(); + } catch (error) { + warn('list workspaces', error); + return { ...result, errors: 1 }; + } + + for (const workspace of workspaces) { + result.workspaces += 1; + + try { + for (const summary of await forEachRunStore.listRuns(workspace.id)) { + const run = await forEachRunStore.getRun(workspace.id, summary.runId); + if (!run) {continue;} + syncForEachRunToTaskGroup(taskGroupService, run); + result.groups += 1; + } + } catch (error) { + result.errors += 1; + warn(`for-each runs for ${workspace.id}`, error); + } + + try { + for (const summary of await mapReduceRunStore.listRuns(workspace.id)) { + const run = await mapReduceRunStore.getRun(workspace.id, summary.runId); + if (!run) {continue;} + syncMapReduceRunToTaskGroup(taskGroupService, run); + result.groups += 1; + } + } catch (error) { + result.errors += 1; + warn(`map-reduce runs for ${workspace.id}`, error); + } + + try { + for (const sessionId of await ralphSessionStore.listSessionIds(workspace.id)) { + const record = await ralphSessionStore.readSessionRecord(workspace.id, sessionId); + if (!record) {continue;} + syncRalphSessionToTaskGroup(taskGroupService, record); + result.groups += 1; + } + } catch (error) { + result.errors += 1; + warn(`ralph sessions for ${workspace.id}`, error); + } + + try { + for (const run of await dreamStore.listRuns(workspace.id)) { + syncDreamRunToTaskGroup(taskGroupService, run); + result.groups += 1; + } + } catch (error) { + result.errors += 1; + warn(`dream runs for ${workspace.id}`, error); + } + } + + if (result.groups > 0 || result.errors > 0) { + getLogger().info( + LogCategory.TASKS, + `[TaskGroups] Backfill projected ${result.groups} group(s) across ${result.workspaces} workspace(s)` + + (result.errors > 0 ? ` with ${result.errors} error(s)` : ''), + ); + } + return result; +} + +function warn(scope: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + getLogger().warn(LogCategory.TASKS, `[TaskGroups] Backfill failed for ${scope}: ${message}`); +} diff --git a/packages/coc/src/server/task-groups/feature-sync.ts b/packages/coc/src/server/task-groups/feature-sync.ts new file mode 100644 index 000000000..db6b43318 --- /dev/null +++ b/packages/coc/src/server/task-groups/feature-sync.ts @@ -0,0 +1,239 @@ +/** + * Feature → task-group registry projections. + * + * Each hierarchical feature keeps its own run/session store as the source of + * truth for orchestration; these helpers project a run record into the + * generic task-group registry (group summary + child links with roles). + * They are invoked from the feature stores' change hooks, so every mutation + * path keeps the registry in sync without scattering registry calls through + * orchestration code. + */ + +import type { TaskGroupStatus } from '@plusplusoneplusplus/forge'; +import type { TaskGroupService } from './task-group-service'; +import type { ForEachRun, ForEachRunStatus } from '../for-each/types'; +import type { MapReduceRun, MapReduceRunStatus } from '../map-reduce/types'; +import type { RalphSessionRecord } from '../ralph/types'; +import type { DreamRunRecord } from '../dreams/types'; + +export const TASK_GROUP_TYPE_FOR_EACH = 'for-each'; +export const TASK_GROUP_TYPE_MAP_REDUCE = 'map-reduce'; +export const TASK_GROUP_TYPE_RALPH = 'ralph'; +export const TASK_GROUP_TYPE_DREAM = 'dream'; + +const TITLE_MAX_LENGTH = 80; + +/** Derive a compact single-line group title from free-form request text. */ +export function toTaskGroupTitle(text: string | undefined): string | undefined { + const collapsed = text?.replace(/\s+/g, ' ').trim(); + if (!collapsed) {return undefined;} + return collapsed.length > TITLE_MAX_LENGTH + ? `${collapsed.slice(0, TITLE_MAX_LENGTH - 1).trimEnd()}…` + : collapsed; +} + +export function forEachStatusToTaskGroupStatus(status: ForEachRunStatus): TaskGroupStatus { + switch (status) { + case 'draft': + case 'approved': + return 'draft'; + case 'running': + return 'running'; + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'cancelled': + return 'cancelled'; + } +} + +export function mapReduceStatusToTaskGroupStatus(status: MapReduceRunStatus): TaskGroupStatus { + switch (status) { + case 'draft': + case 'approved': + return 'draft'; + case 'running': + case 'reducing': + return 'running'; + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'cancelled': + return 'cancelled'; + } +} + +/** + * Project a Dream run into the registry. Dream groups are hidden: the + * relationship is recorded for linkage-only consumers, but the chat list + * keeps its current presentation (internal steps stay internal). + */ +export function syncDreamRunToTaskGroup(service: TaskGroupService, run: DreamRunRecord): void { + const status: TaskGroupStatus = run.status === 'running' + ? 'running' + : run.status === 'completed' ? 'completed' : 'failed'; + service.ensureGroup({ + workspaceId: run.workspaceId, + groupId: run.id, + type: TASK_GROUP_TYPE_DREAM, + title: `Dream run (${run.trigger})`, + status, + hidden: true, + createdAt: run.startedAt, + completedAt: run.completedAt ?? run.failedAt, + extra: { + detailStatus: run.status, + trigger: run.trigger, + candidateCount: run.candidateCardIds.length, + ...(run.error ? { error: run.error } : {}), + }, + }); + if (run.analyzerProcessId) { + service.linkChild(run.workspaceId, run.id, { + role: 'analyzer', + processId: run.analyzerProcessId, + memberIndex: 1, + }); + } + if (run.criticProcessId) { + service.linkChild(run.workspaceId, run.id, { + role: 'critic', + processId: run.criticProcessId, + memberIndex: 2, + }); + } +} + +export function ralphSessionToTaskGroupStatus(record: RalphSessionRecord): TaskGroupStatus { + switch (record.phase) { + case 'grilling': + return 'draft'; + case 'executing': + return 'running'; + case 'complete': + return record.terminalReason === 'CANCELLED' ? 'cancelled' : 'completed'; + } +} + +/** Project a Ralph session record into the registry: group summary + child links. */ +export function syncRalphSessionToTaskGroup(service: TaskGroupService, record: RalphSessionRecord): void { + service.ensureGroup({ + workspaceId: record.workspaceId, + groupId: record.sessionId, + type: TASK_GROUP_TYPE_RALPH, + title: toTaskGroupTitle(record.originalGoal), + status: ralphSessionToTaskGroupStatus(record), + createdAt: record.startedAt, + completedAt: record.completedAt, + extra: { + detailStatus: record.phase, + currentIteration: record.currentIteration, + maxIterations: record.maxIterations, + iterationCount: record.iterations.length, + ...(record.loops?.length ? { loopCount: record.loops.length } : {}), + ...(record.terminalReason ? { terminalReason: record.terminalReason } : {}), + }, + }); + for (const iteration of record.iterations) { + if (!iteration.taskId && !iteration.processId) {continue;} + service.linkChild(record.workspaceId, record.sessionId, { + role: 'iteration', + taskId: iteration.taskId, + processId: iteration.processId, + itemKey: String(iteration.iteration), + memberIndex: iteration.iteration, + }); + } + for (const check of record.finalChecks ?? []) { + if (!check.taskId && !check.processId) {continue;} + service.linkChild(record.workspaceId, record.sessionId, { + role: 'final-check', + taskId: check.taskId, + processId: check.processId, + itemKey: `check-${check.checkIndex}`, + memberIndex: record.iterations.length + check.checkIndex, + }); + } +} + +/** Project a For Each run into the registry: group summary + child links. */ +export function syncForEachRunToTaskGroup(service: TaskGroupService, run: ForEachRun): void { + service.ensureGroup({ + workspaceId: run.workspaceId, + groupId: run.runId, + type: TASK_GROUP_TYPE_FOR_EACH, + title: toTaskGroupTitle(run.originalRequest), + status: forEachStatusToTaskGroupStatus(run.status), + originProcessId: run.generationProcessId, + createdAt: run.createdAt, + completedAt: run.completedAt ?? run.cancelledAt, + extra: { + detailStatus: run.status, + itemCount: run.items.length, + childMode: run.childMode, + }, + }); + if (run.generationProcessId) { + service.linkChild(run.workspaceId, run.runId, { + role: 'generation', + processId: run.generationProcessId, + }); + } + run.items.forEach((item, index) => { + if (!item.childTaskId && !item.childProcessId) {return;} + service.linkChild(run.workspaceId, run.runId, { + role: 'item', + taskId: item.childTaskId, + processId: item.childProcessId, + itemKey: item.id, + memberIndex: index + 1, + }); + }); +} + +/** Project a Map Reduce run into the registry: group summary + child links. */ +export function syncMapReduceRunToTaskGroup(service: TaskGroupService, run: MapReduceRun): void { + service.ensureGroup({ + workspaceId: run.workspaceId, + groupId: run.runId, + type: TASK_GROUP_TYPE_MAP_REDUCE, + title: toTaskGroupTitle(run.originalRequest), + status: mapReduceStatusToTaskGroupStatus(run.status), + originProcessId: run.generationProcessId, + createdAt: run.createdAt, + completedAt: run.completedAt ?? run.cancelledAt, + extra: { + detailStatus: run.status, + itemCount: run.items.length, + maxParallel: run.maxParallel, + reduceStatus: run.reduceStep.status, + childMode: run.childMode, + }, + }); + if (run.generationProcessId) { + service.linkChild(run.workspaceId, run.runId, { + role: 'generation', + processId: run.generationProcessId, + }); + } + run.items.forEach((item, index) => { + if (!item.childTaskId && !item.childProcessId) {return;} + service.linkChild(run.workspaceId, run.runId, { + role: 'item', + taskId: item.childTaskId, + processId: item.childProcessId, + itemKey: item.id, + memberIndex: index + 1, + }); + }); + if (run.reduceStep.childTaskId || run.reduceStep.childProcessId) { + service.linkChild(run.workspaceId, run.runId, { + role: 'reduce', + taskId: run.reduceStep.childTaskId, + processId: run.reduceStep.childProcessId, + memberIndex: run.items.length + 1, + }); + } +} diff --git a/packages/coc/src/server/task-groups/task-group-service.ts b/packages/coc/src/server/task-groups/task-group-service.ts new file mode 100644 index 000000000..851913f62 --- /dev/null +++ b/packages/coc/src/server/task-groups/task-group-service.ts @@ -0,0 +1,149 @@ +/** + * TaskGroupService + * + * Server-side facade over the forge task-group registry — the canonical + * record of parent/child task relationships shared by every hierarchical + * feature (For Each, Map Reduce, Ralph, Dreams, future group types). + * + * Feature orchestrators call this service at the same code points where they + * update their own run/session stores; the registry stores the relationship + * and a normalized summary only. The dashboard reads it through + * `GET /api/workspaces/:id/task-groups`. + * + * When the process store is not SQLite-backed (legacy file backend), the + * registry lives in an in-memory database: linkage still works within the + * server's lifetime, and the legacy metadata-tag fallbacks in the dashboard + * keep grouping functional across restarts. + */ + +import { + Database, + getLogger, + initializeDatabase, + LogCategory, + SqliteProcessStore, + SqliteTaskGroupStore, + type ProcessStore, + type ListTaskGroupsOptions, + type TaskGroupChildLink, + type TaskGroupStatus, + type TaskGroupSummaryRecord, +} from '@plusplusoneplusplus/forge'; + +export interface CreateTaskGroupInput { + workspaceId: string; + groupId: string; + type: string; + title?: string; + status?: TaskGroupStatus; + hidden?: boolean; + originProcessId?: string; + extra?: Record<string, unknown>; + /** Override the creation timestamp (used by backfill). */ + createdAt?: string; + /** Completion timestamp; pass undefined to clear when a run resumes. */ + completedAt?: string; +} + +export interface UpdateTaskGroupInput { + title?: string; + status?: TaskGroupStatus; + originProcessId?: string; + completedAt?: string; + extra?: Record<string, unknown>; +} + +export type LinkTaskGroupChildInput = Omit<TaskGroupChildLink, 'linkedAt'> & { linkedAt?: string }; + +export class TaskGroupService { + private readonly store: SqliteTaskGroupStore; + + constructor(store: SqliteTaskGroupStore) { + this.store = store; + } + + /** + * Build a service backed by the process store's SQLite database, or an + * in-memory database when the process store is not SQLite-backed. + */ + static fromProcessStore(processStore: ProcessStore): TaskGroupService { + let db: Database.Database; + if (processStore instanceof SqliteProcessStore) { + db = processStore.getDatabase(); + } else { + db = new Database(':memory:'); + initializeDatabase(db); + } + return new TaskGroupService(new SqliteTaskGroupStore(db)); + } + + /** + * Register (or refresh) a group. Safe to call repeatedly — existing + * groups keep their creation time and accumulate updates. + */ + ensureGroup(input: CreateTaskGroupInput): TaskGroupSummaryRecord | undefined { + const now = new Date().toISOString(); + try { + this.store.upsertGroup({ + groupId: input.groupId, + workspaceId: input.workspaceId, + type: input.type, + title: input.title, + status: input.status ?? 'draft', + hidden: input.hidden, + originProcessId: input.originProcessId, + createdAt: input.createdAt ?? now, + updatedAt: now, + completedAt: input.completedAt, + extra: input.extra, + }); + return this.store.getGroup(input.workspaceId, input.groupId); + } catch (error) { + this.warn('ensureGroup', input.workspaceId, input.groupId, error); + return undefined; + } + } + + /** Partial-update a group. No-op (with a warning) when the group is missing. */ + updateGroup(workspaceId: string, groupId: string, updates: UpdateTaskGroupInput): TaskGroupSummaryRecord | undefined { + try { + return this.store.updateGroup(workspaceId, groupId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + } catch (error) { + this.warn('updateGroup', workspaceId, groupId, error); + return undefined; + } + } + + /** Record (or refresh) a child link for a group. */ + linkChild(workspaceId: string, groupId: string, link: LinkTaskGroupChildInput): void { + try { + this.store.linkChild(workspaceId, groupId, link); + } catch (error) { + this.warn('linkChild', workspaceId, groupId, error); + } + } + + getGroup(workspaceId: string, groupId: string): TaskGroupSummaryRecord | undefined { + return this.store.getGroup(workspaceId, groupId); + } + + listGroups(workspaceId: string, options?: ListTaskGroupsOptions): TaskGroupSummaryRecord[] { + return this.store.listGroups(workspaceId, options); + } + + removeGroup(workspaceId: string, groupId: string): boolean { + return this.store.removeGroup(workspaceId, groupId); + } + + /** + * Registry writes are best-effort: a registry failure must never break + * feature orchestration, so errors are logged and swallowed. + */ + private warn(operation: string, workspaceId: string, groupId: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + getLogger().warn(LogCategory.TASKS, `[TaskGroupService] ${operation} failed for ${workspaceId}/${groupId}: ${message}`); + } +} diff --git a/packages/coc/src/server/tasks/task-types.ts b/packages/coc/src/server/tasks/task-types.ts index 6d10983cc..212f6c1bb 100644 --- a/packages/coc/src/server/tasks/task-types.ts +++ b/packages/coc/src/server/tasks/task-types.ts @@ -265,6 +265,14 @@ export interface ChatContext { forEach?: ForEachContext; /** Map Reduce generation or parent-run linkage. */ mapReduce?: MapReduceContext; + /** + * Generic task-group membership tag (mirrored into + * AIProcess.metadata.taskGroup). Carried by every child task of a + * registered task group, alongside the feature-specific context above — + * the feature context carries execution inputs, this tag carries only + * the relationship. + */ + taskGroup?: TaskGroupRef; /** Auto provider selection details captured before execution. */ autoProviderRouting?: { requested?: boolean; @@ -283,6 +291,28 @@ export interface ChatContext { }; } +// ============================================================================ +// Task Group Tag (generic parent/child relationship) +// ============================================================================ + +/** Known task-group types. The registry is open — future types are plain strings. */ +export type TaskGroupType = 'for-each' | 'map-reduce' | 'ralph' | 'dream' | (string & {}); + +/** + * Generic membership tag linking a child task to its task group. + * The canonical group record lives in the task-group registry; this tag is + * the child-side linkage that survives independently of the registry. + */ +export interface TaskGroupRef { + groupId: string; + groupType: TaskGroupType; + /** Child role within the group ('generation' | 'item' | 'reduce' | 'iteration' | 'grilling' | 'analyzer' | 'critic' | ...). */ + role: string; + /** Stable per-item key (For Each/Map Reduce item ID, Ralph iteration index, ...). */ + itemKey?: string; + workspaceId: string; +} + export type ForEachChildMode = 'ask' | 'autopilot'; export interface ForEachGenerationLatestPlan { @@ -685,6 +715,41 @@ export function getMapReduceContext( return null; } +/** + * Resolves the generic task-group tag from any task-or-process shape, or null. + * + * Precedence: + * 1. `payload.context.taskGroup` (authoritative for live queue tasks) + * 2. `metadata.taskGroup` (denormalized projection on AIProcess history) + * 3. `null` + */ +export function getTaskGroupRef( + source: { payload?: unknown; metadata?: unknown } | null | undefined, +): TaskGroupRef | null { + if (!source) return null; + const payload = source.payload as { context?: { taskGroup?: unknown } } | undefined; + const fromPayload = payload?.context?.taskGroup; + if (isTaskGroupRef(fromPayload)) { + return fromPayload; + } + const metadata = source.metadata as { taskGroup?: unknown } | undefined; + const fromMetadata = metadata?.taskGroup; + if (isTaskGroupRef(fromMetadata)) { + return fromMetadata; + } + return null; +} + +/** Structural validation for persisted/transported task-group tags. */ +export function isTaskGroupRef(value: unknown): value is TaskGroupRef { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial<TaskGroupRef>; + return typeof candidate.groupId === 'string' && candidate.groupId.trim().length > 0 + && typeof candidate.groupType === 'string' && candidate.groupType.trim().length > 0 + && typeof candidate.role === 'string' && candidate.role.trim().length > 0 + && typeof candidate.workspaceId === 'string' && candidate.workspaceId.trim().length > 0; +} + export function isForEachGenerationContext(context: ForEachContext | null | undefined): context is ForEachGenerationContext { return context?.kind === 'generation'; } @@ -722,3 +787,10 @@ export function serializeMapReduceMetadata(payload: unknown): MapReduceContext | const mapReduce = (payload as ChatPayload).context?.mapReduce; return mapReduce ?? undefined; } + +export function serializeTaskGroupMetadata(payload: unknown): TaskGroupRef | undefined { + if (!payload || typeof payload !== 'object') return undefined; + if (!isChatPayload(payload as Record<string, unknown>)) return undefined; + const taskGroup = (payload as ChatPayload).context?.taskGroup; + return isTaskGroupRef(taskGroup) ? taskGroup : undefined; +} diff --git a/packages/coc/test/server/group-pin-handler.test.ts b/packages/coc/test/server/group-pin-handler.test.ts index 4a3d4a9a7..e7473881a 100644 --- a/packages/coc/test/server/group-pin-handler.test.ts +++ b/packages/coc/test/server/group-pin-handler.test.ts @@ -188,8 +188,21 @@ describe('Group Pin REST API', () => { expect(childAfterClear!.archived).toBe(true); }); + it('accepts open pin types for registered task-group types', async () => { + const novelType = await patchJSON(groupPinsUrl(wsA, 'dream', 'dream-run-1'), { pinned: true }); + expect(novelType.status).toBe(200); + expect(JSON.parse(novelType.body)).toMatchObject({ + pin: { type: 'dream', groupId: 'dream-run-1', pinnedAt: expect.any(String) }, + }); + + const list = await getJSON(groupPinsUrl(wsA)); + expect(JSON.parse(list.body).pins).toEqual([ + expect.objectContaining({ type: 'dream', groupId: 'dream-run-1' }), + ]); + }); + it('rejects invalid group pin inputs', async () => { - const badType = await patchJSON(groupPinsUrl(wsA, 'plan-file', 'group-1'), { pinned: true }); + const badType = await patchJSON(groupPinsUrl(wsA, ' ', 'group-1'), { pinned: true }); expect(badType.status).toBe(400); expect(JSON.parse(badType.body).error).toBe('Invalid group pin type'); diff --git a/packages/coc/test/server/ralph/enqueue-iteration.test.ts b/packages/coc/test/server/ralph/enqueue-iteration.test.ts index a1b09bcd6..ee99a3862 100644 --- a/packages/coc/test/server/ralph/enqueue-iteration.test.ts +++ b/packages/coc/test/server/ralph/enqueue-iteration.test.ts @@ -20,7 +20,7 @@ describe('buildRalphIterationTask', () => { expect(task.payload.prompt).toContain('<goal>'); expect(task.payload.prompt).toContain('Implement a feature and test it.'); expect(task.payload.prompt).not.toContain('<work_intent>'); - expect(Object.keys(task.payload.context)).toEqual(['ralph']); + expect(Object.keys(task.payload.context)).toEqual(['ralph', 'taskGroup']); expect(task.payload.context).not.toHaveProperty('skills'); expect(task.payload.context.ralph).toMatchObject({ phase: 'executing', @@ -29,6 +29,23 @@ describe('buildRalphIterationTask', () => { currentIteration: 2, maxIterations: 5, }); + expect(task.payload.context.taskGroup).toEqual({ + groupId: 'sess-1', + groupType: 'ralph', + role: 'iteration', + itemKey: '2', + workspaceId: 'ws-1', + }); + }); + + it('omits the task-group tag when workspaceId is unknown', () => { + const task = buildRalphIterationTask({ + sessionId: 'sess-no-ws', + originalGoal: 'Goal', + iteration: 1, + maxIterations: 5, + }); + expect(task.payload.context).not.toHaveProperty('taskGroup'); }); it('includes iteration counter in user prompt', () => { diff --git a/packages/coc/test/server/spa/client/task-group-grouping.test.ts b/packages/coc/test/server/spa/client/task-group-grouping.test.ts new file mode 100644 index 000000000..5621f3bcb --- /dev/null +++ b/packages/coc/test/server/spa/client/task-group-grouping.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; +import { + getTaskGroupRef, + getTaskGroupIdForType, + getTaskTimestamp, + getTaskEndTimestamp, + groupBySeededTaskGroups, +} from '../../../../src/server/spa/client/react/features/chat/task-group-grouping'; +import { groupByForEachRun } from '../../../../src/server/spa/client/react/features/chat/for-each-run-grouping'; +import { groupByMapReduceRun } from '../../../../src/server/spa/client/react/features/chat/map-reduce-run-grouping'; +import { getRalphSessionId } from '../../../../src/server/spa/client/react/features/chat/ralph-session-grouping'; +import { TASK_GROUP_DESCRIPTORS, getTaskGroupDescriptor } from '../../../../src/server/spa/client/react/features/chat/task-group-descriptors'; + +function forEachRunSummary(overrides: Record<string, unknown> = {}): any { + return { + runId: 'run-1', + workspaceId: 'ws-1', + status: 'running', + originalRequest: 'do things', + childMode: 'ask', + createdAt: '2026-06-11T10:00:00.000Z', + updatedAt: '2026-06-11T10:05:00.000Z', + itemCount: 2, + itemStatusCounts: { pending: 1, running: 1, completed: 0, failed: 0, skipped: 0 }, + ...overrides, + }; +} + +describe('task-group-grouping engine', () => { + it('reads the generic tag from live tasks and history items', () => { + const live = { payload: { context: { taskGroup: { groupId: 'g1', groupType: 'for-each', role: 'item', workspaceId: 'ws' } } } }; + const history = { taskGroup: { groupId: 'g2', groupType: 'ralph', role: 'iteration', workspaceId: 'ws' } }; + expect(getTaskGroupRef(live)?.groupId).toBe('g1'); + expect(getTaskGroupRef(history)?.groupId).toBe('g2'); + expect(getTaskGroupRef({})).toBeUndefined(); + expect(getTaskGroupRef({ taskGroup: { groupId: ' ', groupType: 'x' } })).toBeUndefined(); + }); + + it('filters tag resolution by group type', () => { + const task = { taskGroup: { groupId: 'g1', groupType: 'for-each' } }; + expect(getTaskGroupIdForType(task, 'for-each')).toBe('g1'); + expect(getTaskGroupIdForType(task, 'map-reduce')).toBeUndefined(); + }); + + it('timestamp helpers follow the activity and end chains', () => { + const task = { + lastActivityAt: 5_000, + endTime: 4_000, + completedAt: 3_000, + startedAt: 2_000, + startTime: 1_000, + createdAt: 500, + }; + expect(getTaskTimestamp(task)).toBe(5_000); + expect(getTaskEndTimestamp(task)).toBe(4_000); + expect(getTaskTimestamp({})).toBe(0); + }); + + it('returns items untouched when there are no seeds', () => { + const items = [{ id: 'a' }, { id: 'b' }]; + const result = groupBySeededTaskGroups(items, [], { + kind: 'x', + getSeedId: () => 'never', + getSeedTimestamp: () => 0, + resolveTaskGroupId: () => undefined, + }); + expect(result).toBe(items); + }); + + it('nests tasks carrying ONLY the generic tag into For Each run groups', () => { + const runs = [forEachRunSummary()]; + const taggedChild = { + id: 'task-tagged', + createdAt: 1_000, + payload: { context: { taskGroup: { groupId: 'run-1', groupType: 'for-each', role: 'item', workspaceId: 'ws-1' } } }, + }; + const legacyChild = { + id: 'task-legacy', + createdAt: 2_000, + payload: { context: { forEach: { workspaceId: 'ws-1', runId: 'run-1', itemId: 'i', childMode: 'ask' } } }, + }; + const unrelated = { id: 'task-other', createdAt: 3_000 }; + + const entries = groupByForEachRun([taggedChild, legacyChild, unrelated], runs); + const group = entries.find((entry: any) => entry.kind === 'for-each-run'); + expect(group.children.map((child: any) => child.id)).toEqual(['task-tagged', 'task-legacy']); + expect(entries).toContain(unrelated); + }); + + it('does not nest a tag of one type into another type group', () => { + const runs = [forEachRunSummary()]; + const wrongType = { + id: 'task-mr', + payload: { context: { taskGroup: { groupId: 'run-1', groupType: 'map-reduce', role: 'item', workspaceId: 'ws-1' } } }, + }; + const entries = groupByForEachRun([wrongType], runs); + const group = entries.find((entry: any) => entry.kind === 'for-each-run'); + expect(group.children).toHaveLength(0); + }); + + it('nests tagged map and reduce children into Map Reduce run groups', () => { + const runs = [{ + runId: 'mr-1', + status: 'reducing', + originalRequest: 'map it', + createdAt: '2026-06-11T10:00:00.000Z', + updatedAt: '2026-06-11T10:05:00.000Z', + itemCount: 1, + itemStatusCounts: { pending: 0, running: 0, completed: 1, failed: 0, skipped: 0 }, + reduceStatus: 'running', + }] as any[]; + const reduceChild = { + id: 'task-reduce', + payload: { context: { taskGroup: { groupId: 'mr-1', groupType: 'map-reduce', role: 'reduce', workspaceId: 'ws-1' } } }, + }; + const entries = groupByMapReduceRun([reduceChild], runs); + const group = entries.find((entry: any) => entry.kind === 'map-reduce-run'); + expect(group.children.map((child: any) => child.id)).toEqual(['task-reduce']); + }); + + it('resolves Ralph session IDs from the generic tag as a fallback', () => { + expect(getRalphSessionId({ + payload: { context: { taskGroup: { groupId: 'session-9', groupType: 'ralph', role: 'iteration', workspaceId: 'ws' } } }, + })).toBe('session-9'); + // Legacy context still wins when present. + expect(getRalphSessionId({ + payload: { context: { ralph: { sessionId: 'session-ctx' }, taskGroup: { groupId: 'session-tag', groupType: 'ralph' } } }, + })).toBe('session-ctx'); + }); +}); + +describe('task-group descriptors', () => { + it('registers the four built-in types with stable pin types', () => { + expect(Object.keys(TASK_GROUP_DESCRIPTORS).sort()).toEqual(['dream', 'for-each', 'map-reduce', 'ralph']); + expect(getTaskGroupDescriptor('for-each')!.pinType).toBe('for-each-run'); + expect(getTaskGroupDescriptor('map-reduce')!.pinType).toBe('map-reduce-run'); + expect(getTaskGroupDescriptor('ralph')!.pinType).toBe('ralph-session'); + }); + + it('dreams stay linkage-only (not groupable in the chat list)', () => { + expect(getTaskGroupDescriptor('dream')!.groupable).toBe(false); + }); + + it('descriptor matching resolves group IDs from tags and legacy contexts', () => { + const descriptor = getTaskGroupDescriptor('for-each')!; + expect(descriptor.matchesTask({ + payload: { context: { taskGroup: { groupId: 'r1', groupType: 'for-each' } } }, + })).toBe('r1'); + expect(descriptor.matchesTask({ + payload: { context: { forEach: { runId: 'r2' } } }, + })).toBe('r2'); + expect(descriptor.matchesTask({})).toBeUndefined(); + }); +}); diff --git a/packages/coc/test/server/task-group-backfill.test.ts b/packages/coc/test/server/task-group-backfill.test.ts new file mode 100644 index 000000000..073cca8c4 --- /dev/null +++ b/packages/coc/test/server/task-group-backfill.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Database, initializeDatabase, SqliteProcessStore, SqliteTaskGroupStore } from '@plusplusoneplusplus/forge'; +import { TaskGroupService } from '../../src/server/task-groups/task-group-service'; +import { backfillTaskGroups } from '../../src/server/task-groups/backfill'; +import { FileForEachRunStore } from '../../src/server/for-each/for-each-run-store'; +import { FileMapReduceRunStore } from '../../src/server/map-reduce/map-reduce-run-store'; +import { FileDreamStore } from '../../src/server/dreams/dream-store'; +import { RalphSessionStore } from '../../src/server/ralph/ralph-session-store'; + +const WS = 'ws-backfill'; + +describe('backfillTaskGroups', () => { + let tmpDir: string; + let db: Database.Database; + let processStore: SqliteProcessStore; + let service: TaskGroupService; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-tg-backfill-')); + processStore = new SqliteProcessStore({ dbPath: path.join(tmpDir, 'test.db') }); + db = processStore.getDatabase(); + service = new TaskGroupService(new SqliteTaskGroupStore(db)); + await processStore.registerWorkspace({ id: WS, name: 'Backfill WS', rootPath: '/tmp/backfill' }); + }); + + afterEach(() => { + processStore.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('projects legacy runs and sessions into the registry, idempotently', async () => { + // Seed legacy data through the feature stores WITHOUT change hooks, + // simulating runs persisted before the framework existed. + const forEachRunStore = new FileForEachRunStore({ dataDir: tmpDir }); + const mapReduceRunStore = new FileMapReduceRunStore({ dataDir: tmpDir }); + const dreamStore = new FileDreamStore({ dataDir: tmpDir }); + const ralphStore = new RalphSessionStore({ dataDir: tmpDir }); + + const forEachRun = await forEachRunStore.createDraftRun({ + workspaceId: WS, + originalRequest: 'legacy for-each', + childMode: 'ask', + generationProcessId: 'proc-gen-legacy', + items: [{ id: 'item-1', title: 'One', prompt: 'p', status: 'pending' }], + }); + const mapReduceRun = await mapReduceRunStore.createDraftRun({ + workspaceId: WS, + originalRequest: 'legacy map-reduce', + childMode: 'ask', + reduceInstructions: 'merge', + maxParallel: 2, + items: [{ id: 'map-1', title: 'One', prompt: 'p', status: 'pending' }], + }); + await ralphStore.initSession(WS, 'legacy-session', { + originalGoal: 'legacy goal', + maxIterations: 3, + }); + const dreamRun = await dreamStore.createRun({ workspaceId: WS, trigger: 'idle' }); + + // Nothing in the registry yet. + expect(service.listGroups(WS, { includeHidden: true })).toHaveLength(0); + + const result = await backfillTaskGroups({ + processStore, + taskGroupService: service, + forEachRunStore, + mapReduceRunStore, + dreamStore, + dataDir: tmpDir, + }); + + expect(result.errors).toBe(0); + expect(result.groups).toBe(4); + + const groups = service.listGroups(WS, { includeHidden: true }); + const byId = new Map(groups.map(group => [group.groupId, group])); + expect(byId.get(forEachRun.runId)).toMatchObject({ type: 'for-each', status: 'draft' }); + expect(byId.get(mapReduceRun.runId)).toMatchObject({ type: 'map-reduce', status: 'draft' }); + expect(byId.get('legacy-session')).toMatchObject({ type: 'ralph', status: 'running' }); + expect(byId.get(dreamRun.id)).toMatchObject({ type: 'dream', hidden: true }); + + // Idempotent: re-running creates no duplicates and keeps counts stable. + const second = await backfillTaskGroups({ + processStore, + taskGroupService: service, + forEachRunStore, + mapReduceRunStore, + dreamStore, + dataDir: tmpDir, + }); + expect(second.errors).toBe(0); + expect(service.listGroups(WS, { includeHidden: true })).toHaveLength(4); + expect(service.getGroup(WS, forEachRun.runId)!.children).toHaveLength(1); + }); + + it('handles workspaces with no legacy data', async () => { + const result = await backfillTaskGroups({ + processStore, + taskGroupService: service, + forEachRunStore: new FileForEachRunStore({ dataDir: tmpDir }), + mapReduceRunStore: new FileMapReduceRunStore({ dataDir: tmpDir }), + dreamStore: new FileDreamStore({ dataDir: tmpDir }), + dataDir: tmpDir, + }); + expect(result).toEqual({ workspaces: 1, groups: 0, errors: 0 }); + }); +}); diff --git a/packages/coc/test/server/task-group-feature-sync.test.ts b/packages/coc/test/server/task-group-feature-sync.test.ts new file mode 100644 index 000000000..9361769d3 --- /dev/null +++ b/packages/coc/test/server/task-group-feature-sync.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Database, initializeDatabase, SqliteTaskGroupStore } from '@plusplusoneplusplus/forge'; +import { TaskGroupService } from '../../src/server/task-groups/task-group-service'; +import { + syncDreamRunToTaskGroup, + syncForEachRunToTaskGroup, + syncMapReduceRunToTaskGroup, + syncRalphSessionToTaskGroup, + toTaskGroupTitle, +} from '../../src/server/task-groups/feature-sync'; +import { FileForEachRunStore } from '../../src/server/for-each/for-each-run-store'; +import { FileMapReduceRunStore } from '../../src/server/map-reduce/map-reduce-run-store'; +import { + RalphSessionStore, + registerRalphSessionChangeListener, + unregisterRalphSessionChangeListener, +} from '../../src/server/ralph/ralph-session-store'; +import { FileDreamStore } from '../../src/server/dreams/dream-store'; +import type { ForEachItem } from '../../src/server/for-each/types'; +import type { MapReduceItem } from '../../src/server/map-reduce/types'; + +const WS = 'ws-sync'; + +function makeService(): { service: TaskGroupService; db: Database.Database } { + const db = new Database(':memory:'); + initializeDatabase(db); + return { service: new TaskGroupService(new SqliteTaskGroupStore(db)), db }; +} + +function forEachItems(): ForEachItem[] { + return [ + { id: 'item-a', title: 'Item A', prompt: 'do a', status: 'pending' }, + { id: 'item-b', title: 'Item B', prompt: 'do b', status: 'pending' }, + ]; +} + +describe('task-group feature sync', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-tg-sync-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('toTaskGroupTitle collapses whitespace and truncates', () => { + expect(toTaskGroupTitle(' hello\n world ')).toBe('hello world'); + expect(toTaskGroupTitle('')).toBeUndefined(); + expect(toTaskGroupTitle(undefined)).toBeUndefined(); + const long = 'x'.repeat(200); + expect(toTaskGroupTitle(long)!.length).toBeLessThanOrEqual(80); + }); + + it('keeps the registry in sync across the For Each run lifecycle', async () => { + const { service, db } = makeService(); + const store = new FileForEachRunStore({ + dataDir: tmpDir, + onRunChanged: run => syncForEachRunToTaskGroup(service, run), + }); + + const run = await store.createDraftRun({ + workspaceId: WS, + originalRequest: 'Process two things', + childMode: 'ask', + generationProcessId: 'proc-gen', + items: forEachItems(), + }); + + let group = service.getGroup(WS, run.runId)!; + expect(group).toMatchObject({ + type: 'for-each', + status: 'draft', + title: 'Process two things', + originProcessId: 'proc-gen', + extra: { detailStatus: 'draft', itemCount: 2, childMode: 'ask' }, + }); + expect(group.children).toEqual([ + expect.objectContaining({ role: 'generation', processId: 'proc-gen' }), + ]); + + await store.approveRun(WS, run.runId); + group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('draft'); + expect(group.extra?.detailStatus).toBe('approved'); + + const claimed = await store.claimNextRunnableItem(WS, run.runId); + expect(claimed).toBeDefined(); + await store.linkRunningItemChild(WS, run.runId, claimed!.item.id, 'task-a', 'proc-a'); + group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('running'); + expect(group.children).toContainEqual( + expect.objectContaining({ role: 'item', itemKey: 'item-a', taskId: 'task-a', processId: 'proc-a' }), + ); + + await store.markRunningItemCompleted(WS, run.runId, 'item-a', 'task-a'); + const claimedB = await store.claimNextRunnableItem(WS, run.runId); + await store.linkRunningItemChild(WS, run.runId, claimedB!.item.id, 'task-b', 'proc-b'); + await store.markRunningItemCompleted(WS, run.runId, 'item-b', 'task-b'); + + group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('completed'); + expect(group.completedAt).toBeDefined(); + expect(group.childCount).toBe(3); + + db.close(); + }); + + it('records failure and cancellation statuses for For Each runs', async () => { + const { service, db } = makeService(); + const store = new FileForEachRunStore({ + dataDir: tmpDir, + onRunChanged: run => syncForEachRunToTaskGroup(service, run), + }); + + const run = await store.createDraftRun({ + workspaceId: WS, + originalRequest: 'fail then cancel', + childMode: 'ask', + items: forEachItems(), + }); + await store.approveRun(WS, run.runId); + const claimed = await store.claimNextRunnableItem(WS, run.runId); + await store.linkRunningItemChild(WS, run.runId, claimed!.item.id, 'task-a', 'proc-a'); + await store.markRunningItemFailed(WS, run.runId, 'item-a', 'boom', 'task-a'); + + expect(service.getGroup(WS, run.runId)!.status).toBe('failed'); + + await store.cancelRun(WS, run.runId); + const group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('cancelled'); + expect(group.completedAt).toBeDefined(); + + db.close(); + }); + + it('keeps the registry in sync across the Map Reduce run lifecycle including reduce', async () => { + const { service, db } = makeService(); + const store = new FileMapReduceRunStore({ + dataDir: tmpDir, + onRunChanged: run => syncMapReduceRunToTaskGroup(service, run), + }); + + const items: MapReduceItem[] = [ + { id: 'map-a', title: 'Map A', prompt: 'a', status: 'pending' }, + { id: 'map-b', title: 'Map B', prompt: 'b', status: 'pending' }, + ]; + const run = await store.createDraftRun({ + workspaceId: WS, + originalRequest: 'Map and reduce things', + childMode: 'ask', + reduceInstructions: 'merge results', + maxParallel: 2, + generationProcessId: 'proc-gen-mr', + items, + }); + + let group = service.getGroup(WS, run.runId)!; + expect(group).toMatchObject({ + type: 'map-reduce', + status: 'draft', + originProcessId: 'proc-gen-mr', + extra: { detailStatus: 'draft', itemCount: 2, maxParallel: 2, reduceStatus: 'pending' }, + }); + + await store.approveRun(WS, run.runId); + const claimed = await store.claimRunnableItems(WS, run.runId); + expect(claimed!.items.length).toBeGreaterThan(0); + for (const item of claimed!.items) { + await store.linkRunningItemChild(WS, run.runId, item.id, `task-${item.id}`, `proc-${item.id}`); + } + group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('running'); + + for (const item of claimed!.items) { + await store.markRunningItemCompleted(WS, run.runId, item.id, `task-${item.id}`, { ok: true }); + } + + const reduceClaim = await store.claimReduceStep(WS, run.runId); + expect(reduceClaim).toBeDefined(); + await store.linkRunningReduceChild(WS, run.runId, 'task-reduce', 'proc-reduce'); + group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('running'); + expect(group.extra?.detailStatus).toBe('reducing'); + expect(group.children).toContainEqual( + expect.objectContaining({ role: 'reduce', taskId: 'task-reduce', processId: 'proc-reduce' }), + ); + + await store.markRunningReduceCompleted(WS, run.runId, 'task-reduce'); + group = service.getGroup(WS, run.runId)!; + expect(group.status).toBe('completed'); + expect(group.extra?.reduceStatus).toBe('completed'); + // generation + 2 map items + reduce + expect(group.childCount).toBe(4); + + db.close(); + }); + + it('keeps the registry in sync across the Ralph session lifecycle via the change listener', async () => { + const { service, db } = makeService(); + registerRalphSessionChangeListener(tmpDir, record => syncRalphSessionToTaskGroup(service, record)); + try { + const store = new RalphSessionStore({ dataDir: tmpDir }); + await store.initSession(WS, 'session-1', { + originalGoal: 'Build the thing end to end', + maxIterations: 5, + }); + + let group = service.getGroup(WS, 'session-1')!; + expect(group).toMatchObject({ + type: 'ralph', + status: 'running', + title: 'Build the thing end to end', + extra: { detailStatus: 'executing', maxIterations: 5 }, + }); + + await store.updateSessionRecord(WS, 'session-1', rec => ({ + ...rec!, + currentIteration: 1, + iterations: [ + { + iteration: 1, + loopIndex: 1, + taskId: 'task-iter-1', + processId: 'proc-iter-1', + startedAt: new Date().toISOString(), + status: 'running', + }, + ], + })); + + group = service.getGroup(WS, 'session-1')!; + expect(group.children).toContainEqual( + expect.objectContaining({ role: 'iteration', itemKey: '1', taskId: 'task-iter-1', processId: 'proc-iter-1' }), + ); + + await store.updateSessionRecord(WS, 'session-1', rec => ({ + ...rec!, + phase: 'complete', + completedAt: new Date().toISOString(), + terminalReason: 'RALPH_COMPLETE', + finalChecks: [ + { + checkIndex: 1, + loopIndex: 1, + sourceIteration: 1, + taskId: 'task-check-1', + processId: 'proc-check-1', + startedAt: new Date().toISOString(), + status: 'completed', + hasGaps: false, + }, + ], + })); + + group = service.getGroup(WS, 'session-1')!; + expect(group.status).toBe('completed'); + expect(group.completedAt).toBeDefined(); + expect(group.children).toContainEqual( + expect.objectContaining({ role: 'final-check', itemKey: 'check-1', taskId: 'task-check-1' }), + ); + } finally { + unregisterRalphSessionChangeListener(tmpDir); + db.close(); + } + }); + + it('records dream runs as hidden groups with analyzer/critic links', async () => { + const { service, db } = makeService(); + const store = new FileDreamStore({ + dataDir: tmpDir, + onRunChanged: run => syncDreamRunToTaskGroup(service, run), + }); + + const run = await store.createRun({ workspaceId: WS, trigger: 'manual' }); + + let group = service.getGroup(WS, run.id)!; + expect(group).toMatchObject({ type: 'dream', status: 'running', hidden: true }); + // Hidden groups are excluded from default listings. + expect(service.listGroups(WS).map(entry => entry.groupId)).not.toContain(run.id); + expect(service.listGroups(WS, { includeHidden: true }).map(entry => entry.groupId)).toContain(run.id); + + await store.completeRun(WS, run.id, { + sourceRanges: [{ processId: 'proc-src', startTurnIndex: 0, endTurnIndex: 2 }], + candidateCardIds: [], + analyzerProcessId: 'proc-analyzer', + criticProcessId: 'proc-critic', + }); + + group = service.getGroup(WS, run.id)!; + expect(group.status).toBe('completed'); + expect(group.children).toEqual([ + expect.objectContaining({ role: 'analyzer', processId: 'proc-analyzer' }), + expect.objectContaining({ role: 'critic', processId: 'proc-critic' }), + ]); + + db.close(); + }); +}); diff --git a/packages/coc/test/server/task-group-routes.test.ts b/packages/coc/test/server/task-group-routes.test.ts new file mode 100644 index 000000000..eeb6c195a --- /dev/null +++ b/packages/coc/test/server/task-group-routes.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { SqliteProcessStore, SqliteTaskGroupStore } from '@plusplusoneplusplus/forge'; +import { createExecutionServer } from '../../src/server/index'; +import type { ExecutionServer } from '../../src/server/types'; + +function getJSON(url: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + method: 'GET', + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf-8') }); + }); + } + ); + req.on('error', reject); + req.end(); + }); +} + +describe('Task Group REST API', () => { + let server: ExecutionServer | undefined; + let baseUrl: string; + let tmpDir: string; + let store: SqliteProcessStore | undefined; + + const wsA = 'ws-task-groups-a'; + const wsB = 'ws-task-groups-b'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coc-task-groups-')); + store = new SqliteProcessStore({ dbPath: path.join(tmpDir, 'test.db') }); + await store.registerWorkspace({ id: wsA, name: 'Workspace A', rootPath: '/tmp/task-groups-a' }); + await store.registerWorkspace({ id: wsB, name: 'Workspace B', rootPath: '/tmp/task-groups-b' }); + + // Seed the registry through the shared database, as feature + // orchestrators do via TaskGroupService. + const registry = new SqliteTaskGroupStore(store.getDatabase()); + registry.upsertGroup({ + groupId: 'run-1', + workspaceId: wsA, + type: 'for-each', + title: 'Process 2 items', + status: 'running', + originProcessId: 'proc-gen', + createdAt: '2026-06-11T10:00:00.000Z', + updatedAt: '2026-06-11T10:05:00.000Z', + extra: { itemCount: 2 }, + }); + registry.linkChild(wsA, 'run-1', { role: 'generation', processId: 'proc-gen' }); + registry.linkChild(wsA, 'run-1', { role: 'item', taskId: 'task-a', itemKey: 'item-a', memberIndex: 1 }); + registry.upsertGroup({ + groupId: 'dream-1', + workspaceId: wsA, + type: 'dream', + status: 'completed', + hidden: true, + createdAt: '2026-06-11T09:00:00.000Z', + updatedAt: '2026-06-11T09:10:00.000Z', + }); + registry.upsertGroup({ + groupId: 'session-1', + workspaceId: wsB, + type: 'ralph', + status: 'running', + createdAt: '2026-06-11T08:00:00.000Z', + updatedAt: '2026-06-11T08:30:00.000Z', + }); + + server = await createExecutionServer({ port: 0, dataDir: tmpDir, store }); + baseUrl = server.url; + }); + + afterEach(async () => { + await server?.close(); + store?.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function groupsUrl(workspaceId: string, suffix = ''): string { + return `${baseUrl}/api/workspaces/${encodeURIComponent(workspaceId)}/task-groups${suffix}`; + } + + it('lists workspace-scoped visible groups with children', async () => { + const res = await getJSON(groupsUrl(wsA)); + expect(res.status).toBe(200); + const { groups } = JSON.parse(res.body); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + groupId: 'run-1', + type: 'for-each', + status: 'running', + originProcessId: 'proc-gen', + childCount: 2, + extra: { itemCount: 2 }, + }); + expect(groups[0].children.map((child: { role: string }) => child.role)).toEqual(['generation', 'item']); + }); + + it('includes hidden groups only when requested', async () => { + const withHidden = await getJSON(groupsUrl(wsA, '?includeHidden=true')); + const { groups } = JSON.parse(withHidden.body); + expect(groups.map((group: { groupId: string }) => group.groupId).sort()).toEqual(['dream-1', 'run-1']); + }); + + it('filters by type', async () => { + const res = await getJSON(groupsUrl(wsA, '?type=map-reduce')); + expect(JSON.parse(res.body).groups).toEqual([]); + + const forEach = await getJSON(groupsUrl(wsA, '?type=for-each')); + expect(JSON.parse(forEach.body).groups).toHaveLength(1); + }); + + it('returns a single group and 404 for missing ones', async () => { + const res = await getJSON(groupsUrl(wsA, '/run-1')); + expect(res.status).toBe(200); + expect(JSON.parse(res.body).group.groupId).toBe('run-1'); + + const missing = await getJSON(groupsUrl(wsA, '/nope')); + expect(missing.status).toBe(404); + }); + + it('is workspace-scoped', async () => { + const res = await getJSON(groupsUrl(wsB)); + const { groups } = JSON.parse(res.body); + expect(groups).toHaveLength(1); + expect(groups[0].groupId).toBe('session-1'); + + const missingWorkspace = await getJSON(groupsUrl('missing-workspace')); + expect(missingWorkspace.status).toBe(404); + }); +}); diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 78afc87d4..1e31721f7 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -308,6 +308,16 @@ export { export { SqliteProcessStore, SqliteProcessStoreOptions } from './sqlite-process-store'; export { SqliteQueueStore, SqliteQueueStoreOptions } from './sqlite-queue-store'; +export { + SqliteTaskGroupStore, + TaskGroupRecord, + TaskGroupSummaryRecord, + TaskGroupChildLink, + TaskGroupStatus, + ListTaskGroupsOptions, + TASK_GROUP_STATUSES, + isTaskGroupStatus, +} from './task-group-store'; export { Database, initializeDatabase, SCHEMA_VERSION, getSchemaVersion } from './sqlite-schema'; // ============================================================================ diff --git a/packages/forge/src/sqlite-schema.ts b/packages/forge/src/sqlite-schema.ts index 4bb19a27d..fcd0d5994 100644 --- a/packages/forge/src/sqlite-schema.ts +++ b/packages/forge/src/sqlite-schema.ts @@ -3,7 +3,7 @@ import Database from 'better-sqlite3'; export { Database }; export type { Database as DatabaseType } from 'better-sqlite3'; -export const SCHEMA_VERSION = 21; +export const SCHEMA_VERSION = 22; /** * Read the current schema version from the database. @@ -354,6 +354,49 @@ export function initializeDatabase(db: Database.Database): void { ON work_item_chat_bindings(workspace_id); `); + // ── task_groups (generic parent/child task relationship registry) ── + db.exec(` + CREATE TABLE IF NOT EXISTS task_groups ( + workspace_id TEXT NOT NULL, + group_id TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT, + status TEXT NOT NULL DEFAULT 'draft', + hidden INTEGER DEFAULT 0, + origin_process_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + extra TEXT, + PRIMARY KEY (workspace_id, group_id) + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS task_group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + group_id TEXT NOT NULL, + role TEXT NOT NULL, + task_id TEXT, + process_id TEXT, + item_key TEXT, + member_index INTEGER, + linked_at TEXT NOT NULL + ) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_task_groups_workspace_type + ON task_groups(workspace_id, type); + + CREATE INDEX IF NOT EXISTS idx_task_group_members_group + ON task_group_members(workspace_id, group_id); + + CREATE INDEX IF NOT EXISTS idx_task_group_members_process + ON task_group_members(process_id); + `); + // ── incremental migrations for existing databases ─────────── // Guards use only `versionBefore < N` (not `>= 1`) so that // databases at version 0 with pre-existing tables still get @@ -416,6 +459,9 @@ export function initializeDatabase(db: Database.Database): void { if (versionBefore < 21) { migrateV20toV21(db); } + if (versionBefore < 22) { + migrateV21toV22(db); + } // Stamp the schema version db.pragma(`user_version = ${SCHEMA_VERSION}`); @@ -698,6 +744,16 @@ function migrateV20toV21(db: Database.Database): void { ensureColumn(db, 'conversation_turns', 'interruption_reason', 'TEXT'); } +/** + * V21 -> V22: add `task_groups` + `task_group_members` tables for the generic + * parent/child task relationship registry. + * The CREATE TABLE IF NOT EXISTS above handles fresh and existing databases; + * this migration keeps the version chain explicit. + */ +function migrateV21toV22(_db: Database.Database): void { + // Tables already created by the idempotent DDL above. +} + /** * Local copy of computeMessagePreview kept here to avoid a circular import * (`sqlite-schema` is a leaf module). Behaviour must stay in sync with diff --git a/packages/forge/src/task-group-store.ts b/packages/forge/src/task-group-store.ts new file mode 100644 index 000000000..bcb68577b --- /dev/null +++ b/packages/forge/src/task-group-store.ts @@ -0,0 +1,353 @@ +/** + * SQLite-backed Task Group Store + * + * Generic registry for parent/child task relationships ("task groups"). + * A task group is a workspace-scoped record describing one hierarchical run + * (a For Each run, Map Reduce run, Ralph session, Dream run, or any future + * hierarchical feature). Children are linked with a role label so a single + * flat membership table covers generation chats, per-item children, reduce + * steps, iterations, and internal analysis steps. + * + * The registry stores the relationship and a normalized summary only — + * feature-specific orchestration state (plans, items, journals, cards) stays + * in each feature's own store. + * + * All methods are synchronous (better-sqlite3). + */ + +import type Database from 'better-sqlite3'; + +/** + * Normalized group lifecycle. Feature-specific states (e.g. 'reducing', + * 'grilling') belong in `extra.detailStatus`, not here. + */ +export type TaskGroupStatus = 'draft' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export const TASK_GROUP_STATUSES: readonly TaskGroupStatus[] = ['draft', 'running', 'completed', 'failed', 'cancelled']; + +export function isTaskGroupStatus(value: unknown): value is TaskGroupStatus { + return typeof value === 'string' && (TASK_GROUP_STATUSES as readonly string[]).includes(value); +} + +/** One linked child of a task group. */ +export interface TaskGroupChildLink { + /** Child role within the group ('generation' | 'item' | 'reduce' | 'iteration' | 'grilling' | 'analyzer' | 'critic' | ...). */ + role: string; + /** Queue task ID, when known. */ + taskId?: string; + /** Process ID, when known (may be linked after the task starts). */ + processId?: string; + /** Stable per-item key (For Each/Map Reduce item ID, Ralph iteration index, ...). */ + itemKey?: string; + /** Optional ordering hint within the group (e.g. iteration number). */ + memberIndex?: number; + /** ISO timestamp of when the link was first recorded. */ + linkedAt: string; +} + +export interface TaskGroupRecord { + groupId: string; + workspaceId: string; + /** Open group type: 'for-each' | 'map-reduce' | 'ralph' | 'dream' | future types. */ + type: string; + title?: string; + status: TaskGroupStatus; + /** Hidden groups are linkage-only (e.g. Dream internals) — not rendered as chat-list groups. */ + hidden?: boolean; + /** Process ID of the visible origin chat (generation chat, grilling chat). */ + originProcessId?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + /** Feature summary extras (itemCount, reduceStatus, detailStatus, loopCount, ...). */ + extra?: Record<string, unknown>; +} + +/** Group record plus aggregated child links. */ +export interface TaskGroupSummaryRecord extends TaskGroupRecord { + childCount: number; + children: TaskGroupChildLink[]; +} + +export interface ListTaskGroupsOptions { + type?: string; + status?: TaskGroupStatus | TaskGroupStatus[]; + /** Include hidden (linkage-only) groups. Default: false. */ + includeHidden?: boolean; +} + +// ============================================================================ +// Row types (snake_case, matching SQLite columns) +// ============================================================================ + +interface TaskGroupRow { + workspace_id: string; + group_id: string; + type: string; + title: string | null; + status: string; + hidden: number; + origin_process_id: string | null; + created_at: string; + updated_at: string; + completed_at: string | null; + extra: string | null; +} + +interface TaskGroupMemberRow { + id: number; + workspace_id: string; + group_id: string; + role: string; + task_id: string | null; + process_id: string | null; + item_key: string | null; + member_index: number | null; + linked_at: string; +} + +function rowToRecord(row: TaskGroupRow): TaskGroupRecord { + const record: TaskGroupRecord = { + groupId: row.group_id, + workspaceId: row.workspace_id, + type: row.type, + status: isTaskGroupStatus(row.status) ? row.status : 'draft', + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + if (row.title !== null) record.title = row.title; + if (row.hidden === 1) record.hidden = true; + if (row.origin_process_id !== null) record.originProcessId = row.origin_process_id; + if (row.completed_at !== null) record.completedAt = row.completed_at; + if (row.extra !== null) { + try { + const parsed = JSON.parse(row.extra); + if (parsed && typeof parsed === 'object') { + record.extra = parsed as Record<string, unknown>; + } + } catch { + // Ignore malformed extra payloads — relationship data stays usable. + } + } + return record; +} + +function memberRowToLink(row: TaskGroupMemberRow): TaskGroupChildLink { + const link: TaskGroupChildLink = { + role: row.role, + linkedAt: row.linked_at, + }; + if (row.task_id !== null) link.taskId = row.task_id; + if (row.process_id !== null) link.processId = row.process_id; + if (row.item_key !== null) link.itemKey = row.item_key; + if (row.member_index !== null) link.memberIndex = row.member_index; + return link; +} + +// ============================================================================ +// SqliteTaskGroupStore +// ============================================================================ + +export class SqliteTaskGroupStore { + private readonly db: Database.Database; + + constructor(db: Database.Database) { + this.db = db; + } + + /** + * INSERT a group, or update its mutable fields when it already exists. + * `createdAt` is preserved on conflict; `updatedAt` always refreshes. + */ + upsertGroup(record: TaskGroupRecord): TaskGroupRecord { + this.db.prepare(` + INSERT INTO task_groups + (workspace_id, group_id, type, title, status, hidden, + origin_process_id, created_at, updated_at, completed_at, extra) + VALUES + (@workspace_id, @group_id, @type, @title, @status, @hidden, + @origin_process_id, @created_at, @updated_at, @completed_at, @extra) + ON CONFLICT(workspace_id, group_id) DO UPDATE SET + type = excluded.type, + title = COALESCE(excluded.title, task_groups.title), + status = excluded.status, + hidden = excluded.hidden, + origin_process_id = COALESCE(excluded.origin_process_id, task_groups.origin_process_id), + updated_at = excluded.updated_at, + completed_at = excluded.completed_at, + extra = COALESCE(excluded.extra, task_groups.extra) + `).run({ + workspace_id: record.workspaceId, + group_id: record.groupId, + type: record.type, + title: record.title ?? null, + status: record.status, + hidden: record.hidden ? 1 : 0, + origin_process_id: record.originProcessId ?? null, + created_at: record.createdAt, + updated_at: record.updatedAt, + completed_at: record.completedAt ?? null, + extra: record.extra !== undefined ? JSON.stringify(record.extra) : null, + }); + return this.getGroup(record.workspaceId, record.groupId) as TaskGroupRecord; + } + + /** + * Partial-update a group's mutable fields. Returns the updated record, + * or undefined when the group does not exist. + */ + updateGroup( + workspaceId: string, + groupId: string, + updates: Partial<Pick<TaskGroupRecord, 'title' | 'status' | 'hidden' | 'originProcessId' | 'completedAt' | 'extra'>> & { updatedAt: string }, + ): TaskGroupSummaryRecord | undefined { + const existing = this.getGroup(workspaceId, groupId); + if (!existing) return undefined; + + const merged: TaskGroupRecord = { + ...existing, + title: updates.title !== undefined ? updates.title : existing.title, + status: updates.status !== undefined ? updates.status : existing.status, + hidden: updates.hidden !== undefined ? updates.hidden : existing.hidden, + originProcessId: updates.originProcessId !== undefined ? updates.originProcessId : existing.originProcessId, + completedAt: updates.completedAt !== undefined ? updates.completedAt : existing.completedAt, + extra: updates.extra !== undefined ? { ...existing.extra, ...updates.extra } : existing.extra, + updatedAt: updates.updatedAt, + }; + this.upsertGroup(merged); + return this.getGroup(workspaceId, groupId); + } + + getGroup(workspaceId: string, groupId: string): TaskGroupSummaryRecord | undefined { + const row = this.db.prepare( + 'SELECT * FROM task_groups WHERE workspace_id = ? AND group_id = ?', + ).get(workspaceId, groupId) as TaskGroupRow | undefined; + if (!row) return undefined; + + const children = this.getChildren(workspaceId, groupId); + return { ...rowToRecord(row), childCount: children.length, children }; + } + + listGroups(workspaceId: string, options?: ListTaskGroupsOptions): TaskGroupSummaryRecord[] { + const clauses = ['workspace_id = @workspaceId']; + const params: Record<string, unknown> = { workspaceId }; + + if (options?.type !== undefined) { + clauses.push('type = @type'); + params.type = options.type; + } + if (!options?.includeHidden) { + clauses.push('hidden = 0'); + } + const statuses = options?.status === undefined + ? [] + : Array.isArray(options.status) ? options.status : [options.status]; + if (statuses.length > 0) { + const placeholders = statuses.map((_, i) => `@status${i}`); + clauses.push(`status IN (${placeholders.join(', ')})`); + statuses.forEach((status, i) => { params[`status${i}`] = status; }); + } + + const rows = this.db.prepare( + `SELECT * FROM task_groups WHERE ${clauses.join(' AND ')} ORDER BY updated_at DESC`, + ).all(params) as TaskGroupRow[]; + if (rows.length === 0) return []; + + const memberRows = this.db.prepare( + 'SELECT * FROM task_group_members WHERE workspace_id = ? ORDER BY member_index ASC, id ASC', + ).all(workspaceId) as TaskGroupMemberRow[]; + const childrenByGroup = new Map<string, TaskGroupChildLink[]>(); + for (const memberRow of memberRows) { + const links = childrenByGroup.get(memberRow.group_id); + const link = memberRowToLink(memberRow); + if (links) { + links.push(link); + } else { + childrenByGroup.set(memberRow.group_id, [link]); + } + } + + return rows.map(row => { + const children = childrenByGroup.get(row.group_id) ?? []; + return { ...rowToRecord(row), childCount: children.length, children }; + }); + } + + /** + * Record (or refresh) a child link. Matching precedence for upsert: + * an existing row with the same taskId, then the same processId, then the + * same (role, itemKey) when neither ID matched but the link carries an + * itemKey with no IDs recorded yet. Otherwise a new row is inserted, so + * retries of the same item legitimately add additional links. + */ + linkChild( + workspaceId: string, + groupId: string, + link: Omit<TaskGroupChildLink, 'linkedAt'> & { linkedAt?: string }, + ): void { + const linkedAt = link.linkedAt ?? new Date().toISOString(); + const rows = this.db.prepare( + 'SELECT * FROM task_group_members WHERE workspace_id = ? AND group_id = ?', + ).all(workspaceId, groupId) as TaskGroupMemberRow[]; + + const match = (link.taskId !== undefined ? rows.find(row => row.task_id === link.taskId) : undefined) + ?? (link.processId !== undefined ? rows.find(row => row.process_id === link.processId) : undefined) + ?? (link.itemKey !== undefined + ? rows.find(row => row.role === link.role && row.item_key === link.itemKey && row.task_id === null && row.process_id === null) + : undefined); + + if (match) { + this.db.prepare(` + UPDATE task_group_members SET + role = @role, + task_id = COALESCE(@task_id, task_id), + process_id = COALESCE(@process_id, process_id), + item_key = COALESCE(@item_key, item_key), + member_index = COALESCE(@member_index, member_index) + WHERE id = @id + `).run({ + id: match.id, + role: link.role, + task_id: link.taskId ?? null, + process_id: link.processId ?? null, + item_key: link.itemKey ?? null, + member_index: link.memberIndex ?? null, + }); + return; + } + + this.db.prepare(` + INSERT INTO task_group_members + (workspace_id, group_id, role, task_id, process_id, item_key, member_index, linked_at) + VALUES + (@workspace_id, @group_id, @role, @task_id, @process_id, @item_key, @member_index, @linked_at) + `).run({ + workspace_id: workspaceId, + group_id: groupId, + role: link.role, + task_id: link.taskId ?? null, + process_id: link.processId ?? null, + item_key: link.itemKey ?? null, + member_index: link.memberIndex ?? null, + linked_at: linkedAt, + }); + } + + getChildren(workspaceId: string, groupId: string): TaskGroupChildLink[] { + const rows = this.db.prepare( + 'SELECT * FROM task_group_members WHERE workspace_id = ? AND group_id = ? ORDER BY member_index ASC, id ASC', + ).all(workspaceId, groupId) as TaskGroupMemberRow[]; + return rows.map(memberRowToLink); + } + + /** DELETE a group and its member links. Returns true when the group existed. */ + removeGroup(workspaceId: string, groupId: string): boolean { + this.db.prepare( + 'DELETE FROM task_group_members WHERE workspace_id = ? AND group_id = ?', + ).run(workspaceId, groupId); + const result = this.db.prepare( + 'DELETE FROM task_groups WHERE workspace_id = ? AND group_id = ?', + ).run(workspaceId, groupId); + return result.changes > 0; + } +} diff --git a/packages/forge/test/sqlite-schema.test.ts b/packages/forge/test/sqlite-schema.test.ts index b8481e52e..e45903df0 100644 --- a/packages/forge/test/sqlite-schema.test.ts +++ b/packages/forge/test/sqlite-schema.test.ts @@ -65,6 +65,9 @@ describe('sqlite-schema', () => { 'idx_pull_request_chat_bindings_workspace', 'idx_work_item_chat_bindings_workspace', 'idx_processes_ws_status_activity', + 'idx_task_groups_workspace_type', + 'idx_task_group_members_group', + 'idx_task_group_members_process', ]; for (const name of expected) { @@ -99,7 +102,7 @@ describe('sqlite-schema', () => { it('getSchemaVersion returns SCHEMA_VERSION after initialization', () => { initializeDatabase(db); expect(getSchemaVersion(db)).toBe(SCHEMA_VERSION); - expect(SCHEMA_VERSION).toBe(21); + expect(SCHEMA_VERSION).toBe(22); }); it('creates context-window breakdown columns on processes', () => { @@ -138,6 +141,8 @@ describe('sqlite-schema', () => { expect(tables).toContain('wikis'); expect(tables).toContain('queue_tasks'); expect(tables).toContain('queue_repo_state'); + expect(tables).toContain('task_groups'); + expect(tables).toContain('task_group_members'); expect(getSchemaVersion(db)).toBe(SCHEMA_VERSION); }); diff --git a/packages/forge/test/task-group-store.test.ts b/packages/forge/test/task-group-store.test.ts new file mode 100644 index 000000000..6d60e9374 --- /dev/null +++ b/packages/forge/test/task-group-store.test.ts @@ -0,0 +1,206 @@ +/** + * SqliteTaskGroupStore Tests + * + * Uses an in-memory SQLite database. Cross-platform compatible. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; + +import { initializeDatabase } from '../src/sqlite-schema'; +import { SqliteTaskGroupStore, TaskGroupRecord } from '../src/task-group-store'; + +function makeGroup(overrides?: Partial<TaskGroupRecord>): TaskGroupRecord { + return { + groupId: 'run-1', + workspaceId: 'ws-1', + type: 'for-each', + title: 'Process 3 items', + status: 'draft', + createdAt: '2026-06-11T10:00:00.000Z', + updatedAt: '2026-06-11T10:00:00.000Z', + ...overrides, + }; +} + +describe('SqliteTaskGroupStore', () => { + let db: Database.Database; + let store: SqliteTaskGroupStore; + + beforeEach(() => { + db = new Database(':memory:'); + initializeDatabase(db); + store = new SqliteTaskGroupStore(db); + }); + + afterEach(() => { + db.close(); + }); + + it('creates and reads back a group with no children', () => { + store.upsertGroup(makeGroup()); + const group = store.getGroup('ws-1', 'run-1'); + expect(group).toBeDefined(); + expect(group!.type).toBe('for-each'); + expect(group!.title).toBe('Process 3 items'); + expect(group!.status).toBe('draft'); + expect(group!.childCount).toBe(0); + expect(group!.children).toEqual([]); + }); + + it('upsert preserves createdAt and refreshes mutable fields', () => { + store.upsertGroup(makeGroup()); + store.upsertGroup(makeGroup({ + status: 'running', + updatedAt: '2026-06-11T11:00:00.000Z', + })); + const group = store.getGroup('ws-1', 'run-1')!; + expect(group.status).toBe('running'); + expect(group.createdAt).toBe('2026-06-11T10:00:00.000Z'); + expect(group.updatedAt).toBe('2026-06-11T11:00:00.000Z'); + }); + + it('upsert keeps existing title/origin/extra when omitted', () => { + store.upsertGroup(makeGroup({ + originProcessId: 'proc-gen', + extra: { itemCount: 3 }, + })); + store.upsertGroup(makeGroup({ + title: undefined, + originProcessId: undefined, + extra: undefined, + status: 'running', + updatedAt: '2026-06-11T11:00:00.000Z', + })); + const group = store.getGroup('ws-1', 'run-1')!; + expect(group.title).toBe('Process 3 items'); + expect(group.originProcessId).toBe('proc-gen'); + expect(group.extra).toEqual({ itemCount: 3 }); + }); + + it('updateGroup merges extra and returns the updated summary', () => { + store.upsertGroup(makeGroup({ extra: { itemCount: 3 } })); + const updated = store.updateGroup('ws-1', 'run-1', { + status: 'completed', + completedAt: '2026-06-11T12:00:00.000Z', + extra: { detailStatus: 'reduced' }, + updatedAt: '2026-06-11T12:00:00.000Z', + }); + expect(updated).toBeDefined(); + expect(updated!.status).toBe('completed'); + expect(updated!.completedAt).toBe('2026-06-11T12:00:00.000Z'); + expect(updated!.extra).toEqual({ itemCount: 3, detailStatus: 'reduced' }); + }); + + it('updateGroup returns undefined for a missing group', () => { + const updated = store.updateGroup('ws-1', 'missing', { + status: 'failed', + updatedAt: '2026-06-11T12:00:00.000Z', + }); + expect(updated).toBeUndefined(); + }); + + it('links children with roles and aggregates them in summaries', () => { + store.upsertGroup(makeGroup()); + store.linkChild('ws-1', 'run-1', { role: 'generation', processId: 'proc-gen', linkedAt: '2026-06-11T10:01:00.000Z' }); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a', itemKey: 'item-a', memberIndex: 1, linkedAt: '2026-06-11T10:02:00.000Z' }); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-b', itemKey: 'item-b', memberIndex: 2, linkedAt: '2026-06-11T10:03:00.000Z' }); + + const group = store.getGroup('ws-1', 'run-1')!; + expect(group.childCount).toBe(3); + expect(group.children.map(child => child.role)).toEqual(['generation', 'item', 'item']); + expect(group.children[1].itemKey).toBe('item-a'); + expect(group.children[1].taskId).toBe('task-a'); + }); + + it('linkChild upserts by taskId, filling processId later', () => { + store.upsertGroup(makeGroup()); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a', itemKey: 'item-a' }); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a', itemKey: 'item-a', processId: 'proc-a' }); + + const children = store.getChildren('ws-1', 'run-1'); + expect(children).toHaveLength(1); + expect(children[0].taskId).toBe('task-a'); + expect(children[0].processId).toBe('proc-a'); + }); + + it('linkChild upserts by processId when no taskId is recorded', () => { + store.upsertGroup(makeGroup({ type: 'dream', groupId: 'dream-1' })); + store.linkChild('ws-1', 'dream-1', { role: 'analyzer', processId: 'proc-analyzer' }); + store.linkChild('ws-1', 'dream-1', { role: 'analyzer', processId: 'proc-analyzer', taskId: 'task-analyzer' }); + + const children = store.getChildren('ws-1', 'dream-1'); + expect(children).toHaveLength(1); + expect(children[0].taskId).toBe('task-analyzer'); + }); + + it('keeps separate links for retries of the same itemKey with new tasks', () => { + store.upsertGroup(makeGroup()); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a1', itemKey: 'item-a' }); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a2', itemKey: 'item-a' }); + + const children = store.getChildren('ws-1', 'run-1'); + expect(children).toHaveLength(2); + expect(children.map(child => child.taskId).sort()).toEqual(['task-a1', 'task-a2']); + }); + + it('listGroups is workspace-scoped, filters by type/status, excludes hidden by default', () => { + store.upsertGroup(makeGroup({ groupId: 'run-1', type: 'for-each', status: 'running' })); + store.upsertGroup(makeGroup({ groupId: 'run-2', type: 'map-reduce', status: 'completed' })); + store.upsertGroup(makeGroup({ groupId: 'dream-1', type: 'dream', hidden: true })); + store.upsertGroup(makeGroup({ groupId: 'other-ws', workspaceId: 'ws-2' })); + + const all = store.listGroups('ws-1'); + expect(all.map(group => group.groupId).sort()).toEqual(['run-1', 'run-2']); + + const forEach = store.listGroups('ws-1', { type: 'for-each' }); + expect(forEach.map(group => group.groupId)).toEqual(['run-1']); + + const completed = store.listGroups('ws-1', { status: 'completed' }); + expect(completed.map(group => group.groupId)).toEqual(['run-2']); + + const withHidden = store.listGroups('ws-1', { includeHidden: true }); + expect(withHidden.map(group => group.groupId).sort()).toEqual(['dream-1', 'run-1', 'run-2']); + }); + + it('listGroups attaches children to the right groups', () => { + store.upsertGroup(makeGroup({ groupId: 'run-1' })); + store.upsertGroup(makeGroup({ groupId: 'run-2' })); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a' }); + store.linkChild('ws-1', 'run-2', { role: 'item', taskId: 'task-b' }); + + const groups = store.listGroups('ws-1'); + const byId = new Map(groups.map(group => [group.groupId, group])); + expect(byId.get('run-1')!.children.map(child => child.taskId)).toEqual(['task-a']); + expect(byId.get('run-2')!.children.map(child => child.taskId)).toEqual(['task-b']); + }); + + it('orders children by memberIndex then insertion order', () => { + store.upsertGroup(makeGroup({ groupId: 'session-1', type: 'ralph' })); + store.linkChild('ws-1', 'session-1', { role: 'iteration', taskId: 'task-2', memberIndex: 2 }); + store.linkChild('ws-1', 'session-1', { role: 'grilling', taskId: 'task-0' }); + store.linkChild('ws-1', 'session-1', { role: 'iteration', taskId: 'task-1', memberIndex: 1 }); + + const children = store.getChildren('ws-1', 'session-1'); + // NULL member_index sorts first in SQLite ASC ordering. + expect(children.map(child => child.taskId)).toEqual(['task-0', 'task-1', 'task-2']); + }); + + it('removeGroup deletes the group and its members', () => { + store.upsertGroup(makeGroup()); + store.linkChild('ws-1', 'run-1', { role: 'item', taskId: 'task-a' }); + + expect(store.removeGroup('ws-1', 'run-1')).toBe(true); + expect(store.getGroup('ws-1', 'run-1')).toBeUndefined(); + expect(store.getChildren('ws-1', 'run-1')).toEqual([]); + expect(store.removeGroup('ws-1', 'run-1')).toBe(false); + }); + + it('survives malformed extra JSON', () => { + store.upsertGroup(makeGroup()); + db.prepare("UPDATE task_groups SET extra = 'not-json' WHERE group_id = 'run-1'").run(); + const group = store.getGroup('ws-1', 'run-1')!; + expect(group.extra).toBeUndefined(); + expect(group.groupId).toBe('run-1'); + }); +}); From bba52cf0b0e35165331c8f6db44606d125bb5170 Mon Sep 17 00:00:00 2001 From: Yiheng Tao <ruby092977@gmail.com> Date: Fri, 12 Jun 2026 08:31:39 -0700 Subject: [PATCH 8/8] fix(coc): repair CI failures from missing realtimeRevisionByRepo mock and taskGroup context key - Add realtimeRevisionByRepo: {} to useWorkItems mocks in WorkItemImportFromGitHub and WorkItemsTab.remote-provider tests - Harden WorkItemHierarchyTree to use optional chaining on realtimeRevisionByRepo access - Update ralph-start-routes test to expect ['ralph', 'taskGroup'] context keys after the unified task-group framework addition Co-authored-by: Cursor <cursoragent@cursor.com> --- .../client/react/features/work-items/WorkItemHierarchyTree.tsx | 2 +- packages/coc/test/server/routes/ralph-start-routes.test.ts | 2 +- .../coc/test/spa/react/repos/WorkItemImportFromGitHub.test.tsx | 1 + .../test/spa/react/repos/WorkItemsTab.remote-provider.test.tsx | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx b/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx index 90611e6d0..818618fc5 100644 --- a/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx +++ b/packages/coc/src/server/spa/client/react/features/work-items/WorkItemHierarchyTree.tsx @@ -357,7 +357,7 @@ export function WorkItemHierarchyTree({ ]); // Refresh when workspace-scoped Work Item WebSocket events arrive. - const realtimeRevision = workItemState.realtimeRevisionByRepo[workspaceId] || 0; + const realtimeRevision = workItemState.realtimeRevisionByRepo?.[workspaceId] ?? 0; const prevRealtimeRevisionRef = useRef(realtimeRevision); useEffect(() => { if (prevRealtimeRevisionRef.current === realtimeRevision) return; diff --git a/packages/coc/test/server/routes/ralph-start-routes.test.ts b/packages/coc/test/server/routes/ralph-start-routes.test.ts index 9acb8b155..c25eec2d5 100644 --- a/packages/coc/test/server/routes/ralph-start-routes.test.ts +++ b/packages/coc/test/server/routes/ralph-start-routes.test.ts @@ -153,7 +153,7 @@ describe('POST /api/processes/:id/ralph-start', () => { expect(enqueueArg.payload.prompt).toContain('<goal>'); expect(enqueueArg.payload.prompt).toContain('ultra-ralph'); expect(enqueueArg.payload.prompt).not.toBe('Begin Ralph execution loop.'); - expect(Object.keys(enqueueArg.payload.context)).toEqual(['ralph']); + expect(Object.keys(enqueueArg.payload.context)).toEqual(['ralph', 'taskGroup']); expect(enqueueArg.payload.context).not.toHaveProperty('skills'); expect(enqueueArg.payload.provider).toBeUndefined(); expect(enqueueArg.config).toEqual({}); diff --git a/packages/coc/test/spa/react/repos/WorkItemImportFromGitHub.test.tsx b/packages/coc/test/spa/react/repos/WorkItemImportFromGitHub.test.tsx index 87da6af95..0c2f550f9 100644 --- a/packages/coc/test/spa/react/repos/WorkItemImportFromGitHub.test.tsx +++ b/packages/coc/test/spa/react/repos/WorkItemImportFromGitHub.test.tsx @@ -30,6 +30,7 @@ vi.mock('../../../../src/server/spa/client/react/contexts/WorkItemContext', () = }, }, loading: {}, + realtimeRevisionByRepo: {}, }, dispatch: mocks.dispatch, }), diff --git a/packages/coc/test/spa/react/repos/WorkItemsTab.remote-provider.test.tsx b/packages/coc/test/spa/react/repos/WorkItemsTab.remote-provider.test.tsx index 664b9df28..67194b1b2 100644 --- a/packages/coc/test/spa/react/repos/WorkItemsTab.remote-provider.test.tsx +++ b/packages/coc/test/spa/react/repos/WorkItemsTab.remote-provider.test.tsx @@ -21,6 +21,7 @@ vi.mock('../../../../src/server/spa/client/react/contexts/WorkItemContext', () = workItemsByRepo: {}, paginationByRepo: {}, loading: {}, + realtimeRevisionByRepo: {}, }, dispatch: mocks.dispatch, }),