diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index 386fcf39..0bd0311e 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -53,6 +53,18 @@ interface TaskData { links: { label: string; url: string }[]; notes: string; fulfilled?: Record; + status?: string; // rTasks status (TODO, IN_PROGRESS, DONE) + canvasX?: number; // canvas position from weaving overlay + canvasY?: number; +} + +interface UnplacedTask { + id: string; + title: string; + status: string; + description: string; + priority: string | null; + labels: string[]; } interface WeaveNode { id: string; @@ -318,6 +330,7 @@ class FolkTimebankApp extends HTMLElement { // Data private commitments: Commitment[] = []; private tasks: TaskData[] = []; + private unplacedTasks: UnplacedTask[] = []; private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = []; // Collaborate state @@ -439,19 +452,32 @@ class FolkTimebankApp extends HTMLElement { private async fetchData() { const base = this.getApiBase(); try { - const [cResp, tResp] = await Promise.all([ + const [cResp, wResp] = await Promise.all([ fetch(`${base}/api/commitments`), - fetch(`${base}/api/tasks`), + fetch(`${base}/api/weave`), ]); if (cResp.ok) { const cData = await cResp.json(); this.commitments = cData.commitments || []; } - if (tResp.ok) { - const tData = await tResp.json(); - this.tasks = tData.tasks || []; - // Restore connections (with hours + status) - this._restoredConnections = (tData.connections || []).map((cn: any) => ({ + if (wResp.ok) { + const wData = await wResp.json(); + // Map placed tasks to TaskData format + this.tasks = (wData.placedTasks || []).map((t: any) => ({ + id: t.id, + name: t.title, + description: t.description || '', + needs: t.needs || {}, + links: t.links || [], + notes: t.notes || '', + status: t.status, + canvasX: t.canvasX, + canvasY: t.canvasY, + })); + // Store unplaced tasks for picker + this.unplacedTasks = wData.unplacedTasks || []; + // Restore connections + this._restoredConnections = (wData.connections || []).map((cn: any) => ({ id: cn.id, fromCommitmentId: cn.fromCommitmentId, toTaskId: cn.toTaskId, @@ -460,7 +486,7 @@ class FolkTimebankApp extends HTMLElement { status: cn.status || 'proposed', })); // Restore exec states - for (const es of (tData.execStates || [])) { + for (const es of (wData.execStates || [])) { if (es.taskId && es.steps) { this.execStepStates[es.taskId] = {}; for (const [k, v] of Object.entries(es.steps)) { @@ -487,13 +513,13 @@ class FolkTimebankApp extends HTMLElement { this.rebuildSidebar(); this.applyRestoredConnections(); - // Auto-place first task if canvas is empty + // Place all tasks using stored canvas positions if (this.weaveNodes.length === 0 && this.tasks.length > 0) { - const wrap = this.shadow.getElementById('canvasWrap'); - const wrapRect = wrap?.getBoundingClientRect(); - const x = wrapRect ? wrapRect.width * 0.4 : 400; - const y = wrapRect ? wrapRect.height * 0.2 : 80; - this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y)); + for (const t of this.tasks) { + const x = t.canvasX ?? 400; + const y = t.canvasY ?? 150; + this.weaveNodes.push(this.mkTaskNode(t, x, y)); + } this.renderAll(); this.rebuildSidebar(); } @@ -536,7 +562,7 @@ class FolkTimebankApp extends HTMLElement {
- +
@@ -1705,7 +1731,14 @@ class FolkTimebankApp extends HTMLElement { hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol); g.appendChild(hm); - g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600')); + const nameLabel = t.name.length > 18 ? t.name.slice(0, 17) + '\u2026' : t.name; + g.appendChild(svgText(nameLabel, 12, 18, 12, '#fff', '600')); + // rTasks status badge + if (t.status && t.status !== 'TODO') { + const badge = t.status === 'DONE' ? '\u2713' : '\u25B6'; + const badgeColor = t.status === 'DONE' ? '#10b981' : '#f59e0b'; + g.appendChild(svgText(badge, node.w - 26, 18, 10, badgeColor, '700', 'middle')); + } const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle'); editPencil.style.pointerEvents = 'none'; g.appendChild(editPencil); @@ -2121,16 +2154,27 @@ class FolkTimebankApp extends HTMLElement { } } } + + // Persist canvas position to weaving overlay + if (this.dragNode?.type === 'task') { + fetch(`${this.getApiBase()}/api/weave/overlay/${this.dragNode.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify({ canvasX: this.dragNode.x, canvasY: this.dragNode.y }), + }).catch(() => {}); + } + this.dragNode = null; } // ── Sidebar (pool panel) ── private rebuildSidebar() { - // Task templates in pool panel const tc = this.shadow.getElementById('sidebarTasks'); if (!tc) return; tc.innerHTML = ''; + + // Show placed tasks not yet on canvas SVG const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id)); this.tasks.forEach(t => { if (usedT.has(t.id)) return; @@ -2143,6 +2187,65 @@ class FolkTimebankApp extends HTMLElement { el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name)); tc.appendChild(el); }); + + // Show unplaced rTasks items (from board) + if (this.unplacedTasks.length > 0) { + const header = document.createElement('div'); + header.className = 'sidebar-section'; + header.textContent = 'rTasks Board'; + tc.appendChild(header); + + this.unplacedTasks.forEach(ut => { + const el = document.createElement('div'); + el.className = 'sidebar-task'; + const badge = ut.status === 'DONE' ? ' \u2713' : ut.status === 'IN_PROGRESS' ? ' \u25B6' : ''; + el.innerHTML = '' + + ''; + el.addEventListener('click', () => this.placeUnplacedTask(ut)); + tc.appendChild(el); + }); + } + } + + private async placeUnplacedTask(ut: UnplacedTask) { + // Prompt for needs via simple dialog + const needsStr = prompt(`Skill needs for "${ut.title}" (e.g. tech:4, design:2):`, ''); + if (needsStr === null) return; + const needs: Record = {}; + if (needsStr.trim()) { + for (const part of needsStr.split(',')) { + const [skill, hrs] = part.trim().split(':'); + if (skill && hrs) needs[skill.trim()] = parseInt(hrs.trim()) || 1; + } + } + + try { + const resp = await fetch(`${this.getApiBase()}/api/weave/place/${ut.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify({ needs, canvasX: 400, canvasY: 150 }), + }); + if (!resp.ok) return; + + // Add to local tasks and canvas + const taskData: TaskData = { + id: ut.id, + name: ut.title, + description: ut.description || '', + needs, + links: [], + notes: '', + status: ut.status, + canvasX: 400, + canvasY: 150, + }; + this.tasks.push(taskData); + this.unplacedTasks = this.unplacedTasks.filter(t => t.id !== ut.id); + this.weaveNodes.push(this.mkTaskNode(taskData, 400, 150)); + this.renderAll(); + this.rebuildSidebar(); + } catch { /* offline */ } } private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) { @@ -2619,7 +2722,7 @@ class FolkTimebankApp extends HTMLElement { const states = this.execStepStates[taskId]; if (!states) return; try { - await fetch(`${this.getApiBase()}/api/tasks/${taskId}/exec-state`, { + await fetch(`${this.getApiBase()}/api/weave/exec-state/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, body: JSON.stringify({ steps: states }), @@ -2909,12 +3012,21 @@ class FolkTimebankApp extends HTMLElement { t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim(); t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim(); t.links = this.getTaskEditLinks(); - // Persist to server + + // Persist to server — uses compat PUT that splits rTasks title/desc + weaving overlay fetch(`${this.getApiBase()}/api/tasks/${t.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }), }).catch(() => {}); + + // Also persist position via overlay update + fetch(`${this.getApiBase()}/api/weave/overlay/${t.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify({ notes: t.notes, links: t.links }), + }).catch(() => {}); + this.renderAll(); this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'); this.editingTaskNode = null; @@ -3189,34 +3301,61 @@ class FolkTimebankApp extends HTMLElement { const skillPairs = skills.length > 0 ? skills : ['facilitation']; // Group skills into task(s) — one task per 2 skills, or one per unique group - const taskDefs: { name: string; needs: Record }[] = []; + const taskDefs: { title: string; needs: Record }[] = []; if (skillPairs.length <= 2) { taskDefs.push({ - name: `Collaboration: ${skillPairs.map((s: string) => SKILL_LABELS[s] || s).join(' + ')}`, + title: `Collaboration: ${skillPairs.map((s: string) => SKILL_LABELS[s] || s).join(' + ')}`, needs: Object.fromEntries(skillPairs.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])), }); } else { for (let i = 0; i < skillPairs.length; i += 2) { const group = skillPairs.slice(i, i + 2); taskDefs.push({ - name: `${group.map((s: string) => SKILL_LABELS[s] || s).join(' + ')} Work`, + title: `${group.map((s: string) => SKILL_LABELS[s] || s).join(' + ')} Work`, needs: Object.fromEntries(group.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])), }); } } - // POST tasks to server and collect created tasks + // POST tasks via create-and-place to create rTasks item + weaving overlay atomically + const wrap = this.shadow.getElementById('canvasWrap'); + const wrapRect = wrap?.getBoundingClientRect(); + const startX = wrapRect ? wrapRect.width * 0.15 : 100; + const startY = wrapRect ? wrapRect.height * 0.3 : 150; + const gap = TASK_W + 40; + const createdTasks: TaskData[] = []; - for (const def of taskDefs) { + for (let i = 0; i < taskDefs.length; i++) { + const def = taskDefs[i]; + const canvasX = startX + i * gap; + const canvasY = startY; try { - const resp = await fetch(`${this.getApiBase()}/api/tasks`, { + const resp = await fetch(`${this.getApiBase()}/api/weave/create-and-place`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, - body: JSON.stringify({ name: def.name, description: `From solver result ${result.id}`, needs: def.needs, intentFrameId: result.id }), + body: JSON.stringify({ + title: def.title, + description: `From solver result ${result.id}`, + needs: def.needs, + canvasX, + canvasY, + intentFrameId: result.id, + }), }); if (resp.ok) { - const task = await resp.json(); - createdTasks.push({ ...task, fulfilled: {} }); + const data = await resp.json(); + const task: TaskData = { + id: data.taskId, + name: def.title, + description: `From solver result ${result.id}`, + needs: def.needs, + links: [], + notes: '', + fulfilled: {}, + canvasX, + canvasY, + }; + createdTasks.push(task); this.tasks.push(task); } } catch { /* offline */ } @@ -3224,17 +3363,9 @@ class FolkTimebankApp extends HTMLElement { if (createdTasks.length === 0) return; - // Auto-layout: horizontal row on canvas - const wrap = this.shadow.getElementById('canvasWrap'); - const wrapRect = wrap?.getBoundingClientRect(); - const startX = wrapRect ? wrapRect.width * 0.15 : 100; - const startY = wrapRect ? wrapRect.height * 0.3 : 150; - const gap = TASK_W + 40; - - createdTasks.forEach((t, i) => { - const x = startX + i * gap; - const y = startY; - this.weaveNodes.push(this.mkTaskNode(t, x, y)); + // Place task nodes on canvas using their stored positions + createdTasks.forEach((t) => { + this.weaveNodes.push(this.mkTaskNode(t, t.canvasX ?? 400, t.canvasY ?? 150)); }); // Draw dashed frame around the group in intentFramesLayer diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index a97e286f..a722f6dd 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -24,14 +24,17 @@ import { renderLanding } from "./landing"; import { notify } from '../../server/notification-service'; import type { SyncServer } from '../../server/local-first/sync-server'; import { - commitmentsSchema, tasksSchema, externalTimeLogsSchema, - commitmentsDocId, tasksDocId, externalTimeLogsDocId, + commitmentsSchema, tasksSchema, weavingSchema, externalTimeLogsSchema, + commitmentsDocId, tasksDocId, weavingDocId, externalTimeLogsDocId, SKILL_LABELS, } from './schemas'; import type { - CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc, + CommitmentsDoc, TasksDoc, WeavingDoc, ExternalTimeLogsDoc, Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog, + WeavingOverlay, } from './schemas'; +import { boardDocId, createTaskItem } from '../rtasks/schemas'; +import type { BoardDoc, TaskItem } from '../rtasks/schemas'; import { intentsSchema, solverResultsSchema, skillCurvesSchema, reputationSchema, @@ -77,6 +80,20 @@ function ensureTasksDoc(space: string): TasksDoc { return doc; } +function ensureWeavingDoc(space: string): WeavingDoc { + const docId = weavingDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init weaving', (d) => { + const init = weavingSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + function newId(): string { return crypto.randomUUID(); } @@ -165,20 +182,24 @@ routes.post("/api/external-time-logs", async (c) => { d.logs[id].status = 'commitment_created' as any; }); - // Auto-connect to rTime task if a linked task exists - const tasksDocRef = _syncServer!.getDoc(tasksDocId(space)); - if (tasksDocRef) { - const linkedTask = Object.values(tasksDocRef.tasks).find( - (t) => t.description?.includes(backlogTaskId) || t.name?.includes(backlogTaskId) - ); + // Auto-connect to weaving task if a linked task exists on canvas + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space)); + const board = getWeavingBoard(space); + if (wDoc && board) { + // Find placed task whose rTasks title/desc contains backlogTaskId + const placedIds = Object.keys(wDoc.weavingOverlays); + const linkedTask = placedIds.find(tid => { + const item = board.doc.tasks[tid]; + return item && (item.description?.includes(backlogTaskId) || item.title?.includes(backlogTaskId)); + }); if (linkedTask) { - ensureTasksDoc(space); const connId = newId(); - _syncServer!.changeDoc(tasksDocId(space), 'auto-connect time log to task', (d) => { + _syncServer!.changeDoc(weavingDocId(space), 'auto-connect time log to task', (d) => { d.connections[connId] = { id: connId, fromCommitmentId: commitmentId, - toTaskId: linkedTask.id, + toTaskId: linkedTask, skill, hours, status: 'committed', @@ -318,6 +339,243 @@ routes.post("/api/tasks/:id/link-backlog", async (c) => { return c.json(updated.tasks[taskId]); }); +// ── Weave API (rTasks integration) ── + +/** Helper: find the rTasks board doc for a space's weaving canvas. */ +function getWeavingBoard(space: string): { docId: string; doc: BoardDoc } | null { + const wDoc = ensureWeavingDoc(space); + const slug = wDoc.boardSlug || space; + const docId = boardDocId(space, slug); + const doc = _syncServer!.getDoc(docId); + if (!doc) return null; + return { docId, doc }; +} + +routes.get("/api/weave", async (c) => { + const space = c.req.param("space") || "demo"; + + let callerRole: SpaceRoleString = 'viewer'; + const token = extractToken(c.req.raw.headers); + if (token) { + try { + const claims = await verifyToken(token); + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } catch {} + } + + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + const board = getWeavingBoard(space); + + const allTasks: Record = board?.doc.tasks || {}; + const placedIds = new Set(Object.keys(wDoc.weavingOverlays)); + + const placedTasks = Object.values(allTasks) + .filter(t => placedIds.has(t.id)) + .map(t => { + const ov = wDoc.weavingOverlays[t.id]; + return { + id: t.id, title: t.title, status: t.status, description: t.description, + priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate, + needs: ov.needs, canvasX: ov.canvasX, canvasY: ov.canvasY, + notes: ov.notes, links: ov.links, intentFrameId: ov.intentFrameId, + }; + }); + + const unplacedTasks = filterArrayByVisibility( + Object.values(allTasks).filter(t => !placedIds.has(t.id)), + callerRole, + ); + + return c.json({ + boardSlug: wDoc.boardSlug || space, + placedTasks, + unplacedTasks: unplacedTasks.map(t => ({ + id: t.id, title: t.title, status: t.status, description: t.description, + priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate, + })), + connections: Object.values(wDoc.connections), + execStates: Object.values(wDoc.execStates), + projectFrames: Object.values(wDoc.projectFrames), + }); +}); + +routes.post("/api/weave/bind-board", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const { boardSlug } = await c.req.json(); + if (!boardSlug) return c.json({ error: "boardSlug required" }, 400); + + // Verify board exists + const docId = boardDocId(space, boardSlug); + if (!_syncServer!.getDoc(docId)) { + return c.json({ error: "Board not found" }, 404); + } + + ensureWeavingDoc(space); + _syncServer!.changeDoc(weavingDocId(space), 'bind board', (d) => { + d.boardSlug = boardSlug; + }); + + return c.json({ ok: true, boardSlug }); +}); + +routes.post("/api/weave/place/:rtasksId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const rtasksId = c.req.param("rtasksId"); + const body = await c.req.json(); + const { needs, canvasX, canvasY, notes, links } = body; + + // Verify task exists in rTasks board + const board = getWeavingBoard(space); + if (!board || !board.doc.tasks[rtasksId]) { + return c.json({ error: "Task not found in rTasks board" }, 404); + } + + ensureWeavingDoc(space); + _syncServer!.changeDoc(weavingDocId(space), 'place task on canvas', (d) => { + d.weavingOverlays[rtasksId] = { + rtasksId, + needs: needs || {}, + canvasX: canvasX ?? 400, + canvasY: canvasY ?? 150, + notes: notes || '', + links: links || [], + } as any; + }); + + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + return c.json(wDoc.weavingOverlays[rtasksId], 201); +}); + +routes.delete("/api/weave/place/:rtasksId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const rtasksId = c.req.param("rtasksId"); + + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404); + + _syncServer!.changeDoc(weavingDocId(space), 'remove from canvas', (d) => { + delete d.weavingOverlays[rtasksId]; + // Remove associated connections + for (const [connId, conn] of Object.entries(d.connections)) { + if (conn.toTaskId === rtasksId) delete d.connections[connId]; + } + // Remove exec state + delete d.execStates[rtasksId]; + }); + + return c.json({ ok: true }); +}); + +routes.put("/api/weave/overlay/:rtasksId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const rtasksId = c.req.param("rtasksId"); + const body = await c.req.json(); + + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404); + + _syncServer!.changeDoc(weavingDocId(space), 'update overlay', (d) => { + const ov = d.weavingOverlays[rtasksId]; + if (body.needs !== undefined) ov.needs = body.needs; + if (body.canvasX !== undefined) ov.canvasX = body.canvasX; + if (body.canvasY !== undefined) ov.canvasY = body.canvasY; + if (body.notes !== undefined) ov.notes = body.notes; + if (body.links !== undefined) ov.links = body.links; + if (body.intentFrameId !== undefined) ov.intentFrameId = body.intentFrameId; + }); + + const updated = _syncServer!.getDoc(weavingDocId(space))!; + return c.json(updated.weavingOverlays[rtasksId]); +}); + +routes.post("/api/weave/create-and-place", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const body = await c.req.json(); + const { title, description, needs, canvasX, canvasY, intentFrameId } = body; + if (!title) return c.json({ error: "title required" }, 400); + + // Find or create the board + const board = getWeavingBoard(space); + if (!board) return c.json({ error: "No rTasks board bound" }, 404); + + // Create TaskItem in rTasks + const taskId = newId(); + const taskItem = createTaskItem(taskId, space, title, { + description: description || '', + createdBy: (claims.did as string) || null, + }); + + _syncServer!.changeDoc(board.docId, 'create task from weave', (d) => { + d.tasks[taskId] = taskItem as any; + }); + + // Create WeavingOverlay + ensureWeavingDoc(space); + _syncServer!.changeDoc(weavingDocId(space), 'place new task on canvas', (d) => { + d.weavingOverlays[taskId] = { + rtasksId: taskId, + needs: needs || {}, + canvasX: canvasX ?? 400, + canvasY: canvasY ?? 150, + notes: '', + links: [], + intentFrameId, + } as any; + }); + + return c.json({ taskId, title, needs: needs || {} }, 201); +}); + +// ── Exec State API (updated: write to WeavingDoc) ── + +routes.put("/api/weave/exec-state/:taskId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const taskId = c.req.param("taskId"); + const body = await c.req.json(); + const { steps, launchedAt } = body; + ensureWeavingDoc(space); + + _syncServer!.changeDoc(weavingDocId(space), 'update exec state', (d) => { + if (!d.execStates[taskId]) { + d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any; + } + if (steps) d.execStates[taskId].steps = steps; + if (launchedAt) d.execStates[taskId].launchedAt = launchedAt; + }); + + const doc = _syncServer!.getDoc(weavingDocId(space))!; + return c.json(doc.execStates[taskId]); +}); + // ── Cyclos proxy config ── const CYCLOS_URL = process.env.CYCLOS_URL || ''; const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || ''; @@ -341,9 +599,9 @@ const DEMO_COMMITMENTS: Omit[] = [ { memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' }, ]; -const DEMO_TASKS: Omit[] = [ - { name: 'Organize Community Event', description: '', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 }, links: [], notes: '' }, - { name: 'Run Harm Reduction Workshop', description: '', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 }, links: [], notes: '' }, +const DEMO_SEED_TASKS: { title: string; needs: Record }[] = [ + { title: 'Organize Community Event', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 } }, + { title: 'Run Harm Reduction Workshop', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 } }, ]; function seedDemoIfEmpty(space: string = 'demo') { @@ -362,15 +620,41 @@ function seedDemoIfEmpty(space: string = 'demo') { (d.meta as any).seeded = true; }); - ensureTasksDoc(space); - _syncServer.changeDoc(tasksDocId(space), 'seed tasks', (d) => { - for (const t of DEMO_TASKS) { + // Seed tasks into rTasks board + place on weaving canvas + const board = getWeavingBoard(space); + if (board) { + ensureWeavingDoc(space); + let canvasX = 300; + for (const t of DEMO_SEED_TASKS) { const id = newId(); - d.tasks[id] = { id, ...t } as any; + const taskItem = createTaskItem(id, space, t.title, { description: '' }); + _syncServer.changeDoc(board.docId, 'seed rtime task', (d) => { + d.tasks[id] = taskItem as any; + }); + _syncServer.changeDoc(weavingDocId(space), 'seed weaving overlay', (d) => { + d.weavingOverlays[id] = { + rtasksId: id, + needs: t.needs, + canvasX, + canvasY: 150, + notes: '', + links: [], + } as any; + }); + canvasX += 280; } - }); - - console.log(`[rTime] Demo data seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_TASKS.length} tasks`); + console.log(`[rTime] Demo seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (rTasks + weaving)`); + } else { + // Fallback: seed into legacy TasksDoc + ensureTasksDoc(space); + _syncServer.changeDoc(tasksDocId(space), 'seed tasks (legacy)', (d) => { + for (const t of DEMO_SEED_TASKS) { + const id = newId(); + d.tasks[id] = { id, name: t.title, description: '', needs: t.needs, links: [], notes: '' } as any; + } + }); + console.log(`[rTime] Demo seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (legacy)`); + } } // ── Commitments API ── @@ -437,12 +721,11 @@ routes.delete("/api/commitments/:id", async (c) => { return c.json({ ok: true }); }); -// ── Tasks API ── +// ── Tasks API (compat shim — reads from WeavingDoc + rTasks board) ── routes.get("/api/tasks", async (c) => { const space = c.req.param("space") || "demo"; - // Resolve caller role for membrane filtering let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { @@ -453,36 +736,93 @@ routes.get("/api/tasks", async (c) => { } catch {} } - ensureTasksDoc(space); - const doc = _syncServer!.getDoc(tasksDocId(space))!; + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + const board = getWeavingBoard(space); + + // Build legacy-shaped task list from placed overlays + rTasks items + const tasks: any[] = []; + for (const [id, ov] of Object.entries(wDoc.weavingOverlays)) { + const item = board?.doc.tasks[id]; + tasks.push({ + id, + name: item?.title || id, + description: item?.description || '', + needs: ov.needs, + links: ov.links, + notes: ov.notes, + intentFrameId: ov.intentFrameId, + }); + } + + // Also include legacy TasksDoc tasks if any (migration compat) + const legacyDoc = _syncServer!.getDoc(tasksDocId(space)); + if (legacyDoc) { + for (const t of Object.values(legacyDoc.tasks)) { + if (!wDoc.weavingOverlays[t.id]) { + tasks.push(t); + } + } + } + return c.json({ - tasks: filterArrayByVisibility(Object.values(doc.tasks), callerRole), - connections: Object.values(doc.connections), - execStates: Object.values(doc.execStates), + tasks: filterArrayByVisibility(tasks, callerRole), + connections: Object.values(wDoc.connections), + execStates: Object.values(wDoc.execStates), }); }); +// POST /api/tasks — compat shim: creates rTasks item + places on canvas routes.post("/api/tasks", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const body = await c.req.json(); - const { name, description, needs } = body; + const { name, description, needs, intentFrameId } = body; if (!name || !needs) return c.json({ error: "name, needs required" }, 400); - const id = newId(); - ensureTasksDoc(space); + const board = getWeavingBoard(space); + if (!board) { + // Fallback to legacy TasksDoc if no board bound + const id = newId(); + ensureTasksDoc(space); + _syncServer!.changeDoc(tasksDocId(space), 'add task (legacy)', (d) => { + d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any; + }); + const doc = _syncServer!.getDoc(tasksDocId(space))!; + return c.json(doc.tasks[id], 201); + } - _syncServer!.changeDoc(tasksDocId(space), 'add task', (d) => { - d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any; + // Create in rTasks + WeavingDoc + const taskId = newId(); + const taskItem = createTaskItem(taskId, space, name, { + description: description || '', + createdBy: (claims.did as string) || null, + }); + _syncServer!.changeDoc(board.docId, 'create task from rtime compat', (d) => { + d.tasks[taskId] = taskItem as any; }); - const doc = _syncServer!.getDoc(tasksDocId(space))!; - return c.json(doc.tasks[id], 201); + ensureWeavingDoc(space); + _syncServer!.changeDoc(weavingDocId(space), 'place task (compat)', (d) => { + d.weavingOverlays[taskId] = { + rtasksId: taskId, + needs: needs || {}, + canvasX: 400, + canvasY: 150, + notes: '', + links: [], + intentFrameId, + } as any; + }); + + return c.json({ id: taskId, name, description: description || '', needs, links: [], notes: '' }, 201); }); +// PUT /api/tasks/:id — compat shim: updates rTasks title/desc + WeavingDoc overlay routes.put("/api/tasks/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); @@ -491,25 +831,60 @@ routes.put("/api/tasks/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const body = await c.req.json(); - ensureTasksDoc(space); - const doc = _syncServer!.getDoc(tasksDocId(space))!; - if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404); + // Update rTasks item (title, description) + const board = getWeavingBoard(space); + if (board?.doc.tasks[id]) { + _syncServer!.changeDoc(board.docId, 'update task from rtime', (d) => { + const t = d.tasks[id]; + if (body.name !== undefined) t.title = body.name; + if (body.description !== undefined) t.description = body.description; + t.updatedAt = Date.now(); + }); + } - _syncServer!.changeDoc(tasksDocId(space), 'update task', (d) => { - const t = d.tasks[id]; - if (body.name !== undefined) t.name = body.name; - if (body.description !== undefined) t.description = body.description; - if (body.needs !== undefined) t.needs = body.needs; - if (body.links !== undefined) t.links = body.links; - if (body.notes !== undefined) t.notes = body.notes; + // Update WeavingDoc overlay (needs, notes, links) + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + if (wDoc.weavingOverlays[id]) { + _syncServer!.changeDoc(weavingDocId(space), 'update overlay (compat)', (d) => { + const ov = d.weavingOverlays[id]; + if (body.needs !== undefined) ov.needs = body.needs; + if (body.links !== undefined) ov.links = body.links; + if (body.notes !== undefined) ov.notes = body.notes; + }); + } else { + // Legacy fallback + ensureTasksDoc(space); + const legacyDoc = _syncServer!.getDoc(tasksDocId(space))!; + if (legacyDoc.tasks[id]) { + _syncServer!.changeDoc(tasksDocId(space), 'update task (legacy)', (d) => { + const t = d.tasks[id]; + if (body.name !== undefined) t.name = body.name; + if (body.description !== undefined) t.description = body.description; + if (body.needs !== undefined) t.needs = body.needs; + if (body.links !== undefined) t.links = body.links; + if (body.notes !== undefined) t.notes = body.notes; + }); + } else { + return c.json({ error: "Not found" }, 404); + } + } + + // Return merged view + const item = board?.doc ? _syncServer!.getDoc(board.docId)?.tasks[id] : null; + const ov = _syncServer!.getDoc(weavingDocId(space))?.weavingOverlays[id]; + return c.json({ + id, + name: item?.title || id, + description: item?.description || '', + needs: ov?.needs || {}, + links: ov?.links || [], + notes: ov?.notes || '', }); - - const updated = _syncServer!.getDoc(tasksDocId(space))!; - return c.json(updated.tasks[id]); }); -// ── Connections API ── +// ── Connections API (now writes to WeavingDoc) ── routes.post("/api/connections", async (c) => { const token = extractToken(c.req.raw.headers); @@ -524,35 +899,42 @@ routes.post("/api/connections", async (c) => { if (typeof hours !== 'number' || hours <= 0) return c.json({ error: "hours must be a positive number" }, 400); const id = newId(); - ensureTasksDoc(space); + ensureWeavingDoc(space); ensureCommitmentsDoc(space); // Validate: hours <= commitment's available hours const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); const commitment = cDoc?.items?.[fromCommitmentId]; if (!commitment) return c.json({ error: "Commitment not found" }, 404); - const tDoc = _syncServer!.getDoc(tasksDocId(space))!; - const usedHours = Object.values(tDoc.connections) + + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + + // Validate task is placed on canvas + if (!wDoc.weavingOverlays[toTaskId]) { + return c.json({ error: "Task not placed on weaving canvas" }, 400); + } + + const usedHours = Object.values(wDoc.connections) .filter(cn => cn.fromCommitmentId === fromCommitmentId) .reduce((sum, cn) => sum + (cn.hours || 0), 0); if (hours > commitment.hours - usedHours) { return c.json({ error: "Requested hours exceed available hours" }, 400); } - _syncServer!.changeDoc(tasksDocId(space), 'add connection', (d) => { + _syncServer!.changeDoc(weavingDocId(space), 'add connection', (d) => { d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any; }); - // Notify commitment owner that their time was requested - const updatedTDoc = _syncServer!.getDoc(tasksDocId(space))!; - const task = updatedTDoc.tasks?.[toTaskId]; + // Notify commitment owner + const board = getWeavingBoard(space); + const taskItem = board?.doc.tasks[toTaskId]; if (commitment?.ownerDid && commitment.ownerDid !== claims.did) { notify({ userDid: commitment.ownerDid, category: 'module', eventType: 'commitment_requested', title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was requested`, - body: task ? `Task: ${task.name}` : undefined, + body: taskItem ? `Task: ${taskItem.title}` : undefined, spaceSlug: space, moduleId: 'rtime', actionUrl: `/rtime`, @@ -561,7 +943,8 @@ routes.post("/api/connections", async (c) => { }).catch(() => {}); } - return c.json(updatedTDoc.connections[id], 201); + const updatedW = _syncServer!.getDoc(weavingDocId(space))!; + return c.json(updatedW.connections[id], 201); }); routes.delete("/api/connections/:id", async (c) => { @@ -572,22 +955,20 @@ routes.delete("/api/connections/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); - ensureTasksDoc(space); + ensureWeavingDoc(space); ensureCommitmentsDoc(space); - const doc = _syncServer!.getDoc(tasksDocId(space))!; - const connection = doc.connections[id]; + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + const connection = wDoc.connections[id]; if (!connection) return c.json({ error: "Not found" }, 404); - // Look up commitment owner to notify them const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); const commitment = cDoc?.items?.[connection.fromCommitmentId]; - _syncServer!.changeDoc(tasksDocId(space), 'remove connection', (d) => { + _syncServer!.changeDoc(weavingDocId(space), 'remove connection', (d) => { delete d.connections[id]; }); - // Notify commitment owner that the request was declined if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) { notify({ userDid: commitment.ownerDid, @@ -616,30 +997,29 @@ routes.patch("/api/connections/:id", async (c) => { const { status } = body; if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400); - ensureTasksDoc(space); + ensureWeavingDoc(space); ensureCommitmentsDoc(space); - const doc = _syncServer!.getDoc(tasksDocId(space))!; - const connection = doc.connections[id]; + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + const connection = wDoc.connections[id]; if (!connection) return c.json({ error: "Not found" }, 404); if (status === 'declined') { - _syncServer!.changeDoc(tasksDocId(space), 'decline connection', (d) => { + _syncServer!.changeDoc(weavingDocId(space), 'decline connection', (d) => { delete d.connections[id]; }); return c.json({ ok: true, deleted: true }); } - // status === 'committed' - _syncServer!.changeDoc(tasksDocId(space), 'approve connection', (d) => { + _syncServer!.changeDoc(weavingDocId(space), 'approve connection', (d) => { d.connections[id].status = 'committed' as any; }); - const updated = _syncServer!.getDoc(tasksDocId(space))!; + const updated = _syncServer!.getDoc(weavingDocId(space))!; return c.json(updated.connections[id]); }); -// ── Exec State API ── +// ── Exec State API (compat — redirects to WeavingDoc) ── routes.put("/api/tasks/:id/exec-state", async (c) => { const token = extractToken(c.req.raw.headers); @@ -650,9 +1030,9 @@ routes.put("/api/tasks/:id/exec-state", async (c) => { const taskId = c.req.param("id"); const body = await c.req.json(); const { steps, launchedAt } = body; - ensureTasksDoc(space); + ensureWeavingDoc(space); - _syncServer!.changeDoc(tasksDocId(space), 'update exec state', (d) => { + _syncServer!.changeDoc(weavingDocId(space), 'update exec state', (d) => { if (!d.execStates[taskId]) { d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any; } @@ -660,7 +1040,7 @@ routes.put("/api/tasks/:id/exec-state", async (c) => { if (launchedAt) d.execStates[taskId].launchedAt = launchedAt; }); - const doc = _syncServer!.getDoc(tasksDocId(space))!; + const doc = _syncServer!.getDoc(weavingDocId(space))!; return c.json(doc.execStates[taskId]); }); @@ -750,20 +1130,40 @@ routes.post("/api/tasks/:id/export-to-backlog", async (c) => { const space = c.req.param("space") || "demo"; const taskId = c.req.param("id"); - ensureTasksDoc(space); + // Try WeavingDoc + rTasks first, fallback to legacy TasksDoc + ensureWeavingDoc(space); + const wDoc = _syncServer!.getDoc(weavingDocId(space))!; + const ov = wDoc.weavingOverlays[taskId]; + const board = getWeavingBoard(space); + const item = board?.doc.tasks[taskId]; + + if (ov && item) { + const estimatedHours = Object.values(ov.needs).reduce((sum, h) => sum + h, 0); + const acceptanceCriteria = Object.entries(ov.needs).map(([skill, hours]) => + `${SKILL_LABELS[skill as Skill] || skill}: ${hours}h` + ); + return c.json({ + title: item.title, + description: item.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '', + estimatedHours, + labels: Object.keys(ov.needs), + notes: ov.notes || '', + acceptanceCriteria, + rtime: { taskId, space }, + }); + } + + // Legacy fallback + ensureTasksDoc(space); const doc = _syncServer!.getDoc(tasksDocId(space))!; const task = doc.tasks[taskId]; if (!task) return c.json({ error: "Task not found" }, 404); - // Calculate total needs as estimated hours const estimatedHours = Object.values(task.needs).reduce((sum, h) => sum + h, 0); - - // Map task needs to acceptance criteria const acceptanceCriteria = Object.entries(task.needs).map(([skill, hours]) => `${SKILL_LABELS[skill as Skill] || skill}: ${hours}h` ); - return c.json({ title: task.name, description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '', @@ -771,10 +1171,7 @@ routes.post("/api/tasks/:id/export-to-backlog", async (c) => { labels: Object.keys(task.needs), notes: task.notes || '', acceptanceCriteria, - rtime: { - taskId: task.id, - space, - }, + rtime: { taskId: task.id, space }, }); }); @@ -805,7 +1202,8 @@ export const timeModule: RSpaceModule = { scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [ { pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init }, - { pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init }, + { pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states (legacy)', init: tasksSchema.init }, + { pattern: '{space}:rtime:weaving', description: 'Weaving overlays, connections, exec states (rTasks integration)', init: weavingSchema.init }, { pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init }, { pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.init }, { pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init }, diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 68d4ba1e..70003486 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -144,6 +144,33 @@ export interface ExternalTimeLogsDoc { logs: Record; } +// ── Weaving overlay (rTasks integration) ── + +export interface WeavingOverlay { + rtasksId: string; + needs: Record; + canvasX: number; + canvasY: number; + notes: string; + links: { label: string; url: string }[]; + intentFrameId?: string; +} + +export interface WeavingDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + boardSlug: string; + weavingOverlays: Record; + connections: Record; + execStates: Record; + projectFrames: Record; +} + // ── DocId helpers ── export function commitmentsDocId(space: string) { @@ -154,6 +181,10 @@ export function tasksDocId(space: string) { return `${space}:rtime:tasks` as const; } +export function weavingDocId(space: string) { + return `${space}:rtime:weaving` as const; +} + export function externalTimeLogsDocId(space: string) { return `${space}:rtime:external-time-logs` as const; } @@ -195,6 +226,26 @@ export const tasksSchema: DocSchema = { }), }; +export const weavingSchema: DocSchema = { + module: 'rtime', + collection: 'weaving', + version: 1, + init: (): WeavingDoc => ({ + meta: { + module: 'rtime', + collection: 'weaving', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + boardSlug: '', + weavingOverlays: {}, + connections: {}, + execStates: {}, + projectFrames: {}, + }), +}; + export const externalTimeLogsSchema: DocSchema = { module: 'rtime', collection: 'external-time-logs', diff --git a/modules/rtime/settlement.ts b/modules/rtime/settlement.ts index bc5dd215..ca38c6bf 100644 --- a/modules/rtime/settlement.ts +++ b/modules/rtime/settlement.ts @@ -16,8 +16,11 @@ import * as Automerge from '@automerge/automerge'; import type { SyncServer } from '../../server/local-first/sync-server'; import { confirmBurn, reverseBurn } from '../../server/token-service'; -import { commitmentsDocId, tasksDocId } from './schemas'; -import type { CommitmentsDoc, TasksDoc, Skill } from './schemas'; +import { commitmentsDocId, tasksDocId, weavingDocId } from './schemas'; +import type { CommitmentsDoc, TasksDoc, WeavingDoc, Skill } from './schemas'; +import { weavingSchema } from './schemas'; +import { boardDocId, createTaskItem } from '../rtasks/schemas'; +import type { BoardDoc } from '../rtasks/schemas'; import { intentsDocId, solverResultsDocId, skillCurvesDocId, reputationDocId, @@ -114,7 +117,7 @@ export async function settleResult( const offerIntents = intents.filter(i => i.type === 'offer'); const needIntentsAll = intents.filter(i => i.type === 'need'); - // Create a task for this collaboration + // Create a task in rTasks board + weaving overlay const taskId = crypto.randomUUID(); const taskSkills = [...new Set(intents.map(i => i.skill))]; const taskNeeds: Record = {}; @@ -122,28 +125,65 @@ export async function settleResult( taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours; } - syncServer.changeDoc(tasksDocId(space), 'settlement: create task', (d) => { - d.tasks[taskId] = { - id: taskId, - name: `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`, - description: `Auto-generated from solver result. Members: ${result.members.length}`, - needs: taskNeeds, - links: [], - notes: `Settled from solver result ${resultId}`, - } as any; - }); + const taskTitle = `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`; + const taskDesc = `Auto-generated from solver result. Members: ${result.members.length}`; + + // Try to create in rTasks board + const boardSlug = space; // default board + const bDocId = boardDocId(space, boardSlug); + const boardDoc = syncServer.getDoc(bDocId); + + if (boardDoc) { + const taskItem = createTaskItem(taskId, space, taskTitle, { description: taskDesc }); + syncServer.changeDoc(bDocId, 'settlement: create rTasks item', (d) => { + d.tasks[taskId] = taskItem as any; + }); + } else { + // Fallback: create in legacy TasksDoc + syncServer.changeDoc(tasksDocId(space), 'settlement: create task (legacy)', (d) => { + d.tasks[taskId] = { + id: taskId, name: taskTitle, description: taskDesc, + needs: taskNeeds, links: [], notes: `Settled from solver result ${resultId}`, + } as any; + }); + } outcome.tasksCreated = 1; - // Create connections (offer → task) - syncServer.changeDoc(tasksDocId(space), 'settlement: create connections', (d) => { + // Ensure WeavingDoc and create overlay + connections + let wDoc = syncServer.getDoc(weavingDocId(space)); + if (!wDoc) { + wDoc = Automerge.change(Automerge.init(), 'init weaving', (d) => { + const init = weavingSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(weavingDocId(space), wDoc); + } + + // Create weaving overlay + syncServer.changeDoc(weavingDocId(space), 'settlement: create overlay', (d) => { + d.weavingOverlays[taskId] = { + rtasksId: taskId, + needs: taskNeeds, + canvasX: 400, + canvasY: 150, + notes: `Settled from solver result ${resultId}`, + links: [], + intentFrameId: resultId, + } as any; + }); + + // Create connections in WeavingDoc (offer → task) + syncServer.changeDoc(weavingDocId(space), 'settlement: create connections', (d) => { for (const offer of offerIntents) { - // Find or create a commitment for this offer in CommitmentsDoc const connId = crypto.randomUUID(); d.connections[connId] = { id: connId, - fromCommitmentId: offer.id, // Using intent ID as reference + fromCommitmentId: offer.id, toTaskId: taskId, skill: offer.skill, + hours: offer.hours, + status: 'committed', } as any; outcome.connectionsCreated++; } diff --git a/scripts/migrate-rtime-tasks.ts b/scripts/migrate-rtime-tasks.ts new file mode 100644 index 00000000..3667f5d9 --- /dev/null +++ b/scripts/migrate-rtime-tasks.ts @@ -0,0 +1,165 @@ +/** + * Migration: rTime TasksDoc → rTasks BoardDoc + WeavingDoc + * + * Reads all {space}:rtime:tasks docs and: + * 1. Creates TaskItem entries in the rTasks board (reusing Task.id) + * 2. Creates WeavingOverlay entries in the WeavingDoc + * 3. Moves connections + execStates from TasksDoc → WeavingDoc + * + * Usage: npx tsx scripts/migrate-rtime-tasks.ts [space] + * Default space: demo + * + * This script must be run on the server where Automerge data lives. + * It operates on the SyncServer data files directly. + */ + +import * as Automerge from '@automerge/automerge'; +import type { SyncServer } from '../server/local-first/sync-server'; +import { + tasksDocId, weavingDocId, weavingSchema, +} from '../modules/rtime/schemas'; +import type { TasksDoc, WeavingDoc, Task, Connection, ExecState } from '../modules/rtime/schemas'; +import { boardDocId, createTaskItem, boardSchema } from '../modules/rtasks/schemas'; +import type { BoardDoc, TaskItem } from '../modules/rtasks/schemas'; + +const space = process.argv[2] || 'demo'; + +console.log(`[migrate] Starting rTime → rTasks migration for space: ${space}`); + +/** + * Standalone migration logic. Call with a SyncServer instance. + * This is exported so it can also be called from server startup if needed. + */ +export function migrateRTimeTasks(syncServer: SyncServer, spaceSlug: string): { + tasksCreated: number; + overlaysCreated: number; + connectionsMoved: number; + execStatesMoved: number; +} { + const result = { tasksCreated: 0, overlaysCreated: 0, connectionsMoved: 0, execStatesMoved: 0 }; + + // 1. Read legacy TasksDoc + const oldDocId = tasksDocId(spaceSlug); + const oldDoc = syncServer.getDoc(oldDocId); + if (!oldDoc) { + console.log(`[migrate] No TasksDoc found at ${oldDocId}, nothing to migrate.`); + return result; + } + + const oldTasks = Object.values(oldDoc.tasks || {}); + const oldConnections = Object.values(oldDoc.connections || {}); + const oldExecStates = Object.values(oldDoc.execStates || {}); + + if (oldTasks.length === 0 && oldConnections.length === 0) { + console.log(`[migrate] TasksDoc is empty, nothing to migrate.`); + return result; + } + + console.log(`[migrate] Found ${oldTasks.length} tasks, ${oldConnections.length} connections, ${oldExecStates.length} exec states`); + + // 2. Ensure rTasks board exists + const bDocId = boardDocId(spaceSlug, spaceSlug); + let boardDoc = syncServer.getDoc(bDocId); + if (!boardDoc) { + boardDoc = Automerge.change(Automerge.init(), 'init board for migration', (d) => { + const init = boardSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = spaceSlug; + d.board.id = spaceSlug; + d.board.slug = spaceSlug; + d.board.name = `${spaceSlug} Board`; + }); + syncServer.setDoc(bDocId, boardDoc); + console.log(`[migrate] Created rTasks board: ${bDocId}`); + } + + // 3. Create TaskItem for each rTime Task (reuse ID) + for (const task of oldTasks) { + if (boardDoc.tasks[task.id]) { + console.log(`[migrate] Task ${task.id} already exists in board, skipping.`); + continue; + } + const taskItem = createTaskItem(task.id, spaceSlug, task.name, { + description: task.description || '', + }); + syncServer.changeDoc(bDocId, `migrate task: ${task.name}`, (d) => { + d.tasks[task.id] = taskItem as any; + }); + result.tasksCreated++; + } + + // 4. Ensure WeavingDoc exists + const wDocId = weavingDocId(spaceSlug); + let wDoc = syncServer.getDoc(wDocId); + if (!wDoc) { + wDoc = Automerge.change(Automerge.init(), 'init weaving for migration', (d) => { + const init = weavingSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = spaceSlug; + d.boardSlug = spaceSlug; + }); + syncServer.setDoc(wDocId, wDoc); + } + + // 5. Create WeavingOverlay for each task (with needs, notes, links) + let canvasX = 300; + for (const task of oldTasks) { + if (wDoc.weavingOverlays[task.id]) { + console.log(`[migrate] Overlay for ${task.id} already exists, skipping.`); + continue; + } + syncServer.changeDoc(wDocId, `migrate overlay: ${task.name}`, (d) => { + d.weavingOverlays[task.id] = { + rtasksId: task.id, + needs: task.needs || {}, + canvasX, + canvasY: 150, + notes: task.notes || '', + links: task.links || [], + intentFrameId: task.intentFrameId, + } as any; + }); + canvasX += 280; + result.overlaysCreated++; + } + + // 6. Move connections from TasksDoc → WeavingDoc + for (const conn of oldConnections) { + syncServer.changeDoc(wDocId, `migrate connection: ${conn.id}`, (d) => { + if (!d.connections[conn.id]) { + d.connections[conn.id] = { ...conn } as any; + result.connectionsMoved++; + } + }); + } + + // 7. Move execStates from TasksDoc → WeavingDoc + for (const es of oldExecStates) { + syncServer.changeDoc(wDocId, `migrate exec state: ${es.taskId}`, (d) => { + if (!d.execStates[es.taskId]) { + d.execStates[es.taskId] = { ...es } as any; + result.execStatesMoved++; + } + }); + } + + // Refresh doc after changes + const finalW = syncServer.getDoc(wDocId)!; + result.connectionsMoved = Object.keys(finalW.connections).length; + result.execStatesMoved = Object.keys(finalW.execStates).length; + + console.log(`[migrate] Migration complete: + Tasks created in rTasks: ${result.tasksCreated} + Weaving overlays created: ${result.overlaysCreated} + Connections moved: ${result.connectionsMoved} + Exec states moved: ${result.execStatesMoved}`); + + return result; +} + +// CLI entry point — only runs when executed directly +if (process.argv[1]?.includes('migrate-rtime-tasks')) { + console.log('[migrate] This script must be imported and called with a SyncServer instance.'); + console.log('[migrate] Example: import { migrateRTimeTasks } from "./scripts/migrate-rtime-tasks";'); + console.log('[migrate] migrateRTimeTasks(syncServer, "demo");'); +} diff --git a/server/mcp-tools/rtime.ts b/server/mcp-tools/rtime.ts index 90570497..1663629e 100644 --- a/server/mcp-tools/rtime.ts +++ b/server/mcp-tools/rtime.ts @@ -1,7 +1,7 @@ /** - * MCP tools for rTime (commitments, tasks, external time logs). + * MCP tools for rTime (commitments, woven tasks, external time logs). * - * Tools: rtime_list_commitments, rtime_list_tasks, + * Tools: rtime_list_commitments, rtime_list_woven_tasks, rtime_place_task, * rtime_list_time_logs, rtime_create_commitment */ @@ -10,15 +10,17 @@ import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { commitmentsDocId, - tasksDocId, + weavingDocId, externalTimeLogsDocId, } from "../../modules/rtime/schemas"; import type { CommitmentsDoc, - TasksDoc, + WeavingDoc, ExternalTimeLogsDoc, Skill, } from "../../modules/rtime/schemas"; +import { boardDocId } from "../../modules/rtasks/schemas"; +import type { BoardDoc } from "../../modules/rtasks/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; import { filterArrayByVisibility } from "../../shared/membrane"; @@ -66,8 +68,8 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) { ); server.tool( - "rtime_list_tasks", - "List rTime tasks with their needs maps", + "rtime_list_woven_tasks", + "List tasks placed on the rTime weaving canvas with overlay data (needs, position, connections)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), @@ -77,24 +79,80 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - const doc = syncServer.getDoc(tasksDocId(space)); - if (!doc) { - return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] }; + const wDoc = syncServer.getDoc(weavingDocId(space)); + if (!wDoc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No weaving data found" }) }] }; } - const tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role) + // Look up rTasks board for task titles + const boardSlug = wDoc.boardSlug || space; + const board = syncServer.getDoc(boardDocId(space, boardSlug)); + + const tasks = Object.entries(wDoc.weavingOverlays || {}) .slice(0, limit || 50) - .map(t => ({ - id: t.id, - name: t.name, - description: t.description, - needs: t.needs, - })); + .map(([id, ov]) => { + const item = board?.tasks[id]; + return { + id, + title: item?.title || id, + status: item?.status || 'TODO', + description: item?.description || '', + needs: ov.needs, + canvasX: ov.canvasX, + canvasY: ov.canvasY, + notes: ov.notes, + links: ov.links, + connectionCount: Object.values(wDoc.connections || {}).filter(c => c.toTaskId === id).length, + }; + }); return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] }; }, ); + server.tool( + "rtime_place_task", + "Place an rTasks item onto the weaving canvas with skill needs (requires auth token)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + task_id: z.string().describe("rTasks task ID to place on canvas"), + needs: z.record(z.number()).describe("Skill-to-hours map (e.g. {tech: 4, design: 2})"), + canvas_x: z.number().optional().describe("Canvas X position (default 400)"), + canvas_y: z.number().optional().describe("Canvas Y position (default 150)"), + }, + async ({ space, token, task_id, needs, canvas_x, canvas_y }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const wDoc = syncServer.getDoc(weavingDocId(space)); + if (!wDoc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No weaving doc found" }) }], isError: true }; + } + + // Verify task exists in rTasks board + const boardSlug = wDoc.boardSlug || space; + const board = syncServer.getDoc(boardDocId(space, boardSlug)); + if (!board?.tasks[task_id]) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found in rTasks board" }) }], isError: true }; + } + + syncServer.changeDoc(weavingDocId(space), `Place task ${task_id} on canvas`, (d) => { + if (!d.weavingOverlays) (d as any).weavingOverlays = {}; + d.weavingOverlays[task_id] = { + rtasksId: task_id, + needs, + canvasX: canvas_x ?? 400, + canvasY: canvas_y ?? 150, + notes: '', + links: [], + }; + }); + + return { content: [{ type: "text", text: JSON.stringify({ id: task_id, placed: true, needs }) }] }; + }, + ); + server.tool( "rtime_list_time_logs", "List external time logs (imported from backlog-md)",