feat(rtime): use rTasks as task source — WeavingDoc overlay integration

rTime now pulls tasks from rTasks boards instead of maintaining its own
Task type. New WeavingDoc stores canvas overlay data (needs, position,
notes, links) while rTasks BoardDoc remains source of truth for task
metadata. 6 new /api/weave routes, updated connections/exec-state to
WeavingDoc, compat shims on legacy endpoints, task picker for unplaced
rTasks items, MCP tools updated (rtime_list_woven_tasks, rtime_place_task),
migration script for existing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-14 17:09:01 -04:00
parent 240131ae70
commit 2ba20fabbb
6 changed files with 1002 additions and 159 deletions

View File

@ -53,6 +53,18 @@ interface TaskData {
links: { label: string; url: string }[]; links: { label: string; url: string }[];
notes: string; notes: string;
fulfilled?: Record<string, number>; fulfilled?: Record<string, number>;
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 { interface WeaveNode {
id: string; id: string;
@ -318,6 +330,7 @@ class FolkTimebankApp extends HTMLElement {
// Data // Data
private commitments: Commitment[] = []; private commitments: Commitment[] = [];
private tasks: TaskData[] = []; private tasks: TaskData[] = [];
private unplacedTasks: UnplacedTask[] = [];
private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = []; private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = [];
// Collaborate state // Collaborate state
@ -439,19 +452,32 @@ class FolkTimebankApp extends HTMLElement {
private async fetchData() { private async fetchData() {
const base = this.getApiBase(); const base = this.getApiBase();
try { try {
const [cResp, tResp] = await Promise.all([ const [cResp, wResp] = await Promise.all([
fetch(`${base}/api/commitments`), fetch(`${base}/api/commitments`),
fetch(`${base}/api/tasks`), fetch(`${base}/api/weave`),
]); ]);
if (cResp.ok) { if (cResp.ok) {
const cData = await cResp.json(); const cData = await cResp.json();
this.commitments = cData.commitments || []; this.commitments = cData.commitments || [];
} }
if (tResp.ok) { if (wResp.ok) {
const tData = await tResp.json(); const wData = await wResp.json();
this.tasks = tData.tasks || []; // Map placed tasks to TaskData format
// Restore connections (with hours + status) this.tasks = (wData.placedTasks || []).map((t: any) => ({
this._restoredConnections = (tData.connections || []).map((cn: 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, id: cn.id,
fromCommitmentId: cn.fromCommitmentId, fromCommitmentId: cn.fromCommitmentId,
toTaskId: cn.toTaskId, toTaskId: cn.toTaskId,
@ -460,7 +486,7 @@ class FolkTimebankApp extends HTMLElement {
status: cn.status || 'proposed', status: cn.status || 'proposed',
})); }));
// Restore exec states // Restore exec states
for (const es of (tData.execStates || [])) { for (const es of (wData.execStates || [])) {
if (es.taskId && es.steps) { if (es.taskId && es.steps) {
this.execStepStates[es.taskId] = {}; this.execStepStates[es.taskId] = {};
for (const [k, v] of Object.entries(es.steps)) { for (const [k, v] of Object.entries(es.steps)) {
@ -487,13 +513,13 @@ class FolkTimebankApp extends HTMLElement {
this.rebuildSidebar(); this.rebuildSidebar();
this.applyRestoredConnections(); 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) { if (this.weaveNodes.length === 0 && this.tasks.length > 0) {
const wrap = this.shadow.getElementById('canvasWrap'); for (const t of this.tasks) {
const wrapRect = wrap?.getBoundingClientRect(); const x = t.canvasX ?? 400;
const x = wrapRect ? wrapRect.width * 0.4 : 400; const y = t.canvasY ?? 150;
const y = wrapRect ? wrapRect.height * 0.2 : 80; this.weaveNodes.push(this.mkTaskNode(t, x, y));
this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y)); }
this.renderAll(); this.renderAll();
this.rebuildSidebar(); this.rebuildSidebar();
} }
@ -536,7 +562,7 @@ class FolkTimebankApp extends HTMLElement {
<button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button> <button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button>
</div> </div>
<div class="pool-panel-sidebar" id="poolSidebar"> <div class="pool-panel-sidebar" id="poolSidebar">
<div class="sidebar-section">Task Templates</div> <div class="sidebar-section">Woven Tasks</div>
<div id="sidebarTasks"></div> <div id="sidebarTasks"></div>
</div> </div>
<button class="add-btn" id="addBtn">+ Pledge Time</button> <button class="add-btn" id="addBtn">+ Pledge Time</button>
@ -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); hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol);
g.appendChild(hm); 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'); const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle');
editPencil.style.pointerEvents = 'none'; editPencil.style.pointerEvents = 'none';
g.appendChild(editPencil); 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; this.dragNode = null;
} }
// ── Sidebar (pool panel) ── // ── Sidebar (pool panel) ──
private rebuildSidebar() { private rebuildSidebar() {
// Task templates in pool panel
const tc = this.shadow.getElementById('sidebarTasks'); const tc = this.shadow.getElementById('sidebarTasks');
if (!tc) return; if (!tc) return;
tc.innerHTML = ''; 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)); const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id));
this.tasks.forEach(t => { this.tasks.forEach(t => {
if (usedT.has(t.id)) return; 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)); el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name));
tc.appendChild(el); 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 = '<div class="sidebar-task-icon" style="opacity:0.6">\u25CB</div>' +
'<div class="sidebar-item-info"><div class="sidebar-item-name">' + ut.title + badge + '</div>' +
'<div class="sidebar-item-meta">' + ut.status + '</div></div>';
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<string, number> = {};
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) { private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) {
@ -2619,7 +2722,7 @@ class FolkTimebankApp extends HTMLElement {
const states = this.execStepStates[taskId]; const states = this.execStepStates[taskId];
if (!states) return; if (!states) return;
try { try {
await fetch(`${this.getApiBase()}/api/tasks/${taskId}/exec-state`, { await fetch(`${this.getApiBase()}/api/weave/exec-state/${taskId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({ steps: states }), body: JSON.stringify({ steps: states }),
@ -2909,12 +3012,21 @@ class FolkTimebankApp extends HTMLElement {
t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim(); t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim();
t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim(); t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim();
t.links = this.getTaskEditLinks(); 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}`, { fetch(`${this.getApiBase()}/api/tasks/${t.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }), body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }),
}).catch(() => {}); }).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.renderAll();
this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'); this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
this.editingTaskNode = null; this.editingTaskNode = null;
@ -3189,34 +3301,61 @@ class FolkTimebankApp extends HTMLElement {
const skillPairs = skills.length > 0 ? skills : ['facilitation']; const skillPairs = skills.length > 0 ? skills : ['facilitation'];
// Group skills into task(s) — one task per 2 skills, or one per unique group // Group skills into task(s) — one task per 2 skills, or one per unique group
const taskDefs: { name: string; needs: Record<string, number> }[] = []; const taskDefs: { title: string; needs: Record<string, number> }[] = [];
if (skillPairs.length <= 2) { if (skillPairs.length <= 2) {
taskDefs.push({ 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)])), needs: Object.fromEntries(skillPairs.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])),
}); });
} else { } else {
for (let i = 0; i < skillPairs.length; i += 2) { for (let i = 0; i < skillPairs.length; i += 2) {
const group = skillPairs.slice(i, i + 2); const group = skillPairs.slice(i, i + 2);
taskDefs.push({ 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)])), 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[] = []; 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 { try {
const resp = await fetch(`${this.getApiBase()}/api/tasks`, { const resp = await fetch(`${this.getApiBase()}/api/weave/create-and-place`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, 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) { if (resp.ok) {
const task = await resp.json(); const data = await resp.json();
createdTasks.push({ ...task, fulfilled: {} }); 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); this.tasks.push(task);
} }
} catch { /* offline */ } } catch { /* offline */ }
@ -3224,17 +3363,9 @@ class FolkTimebankApp extends HTMLElement {
if (createdTasks.length === 0) return; if (createdTasks.length === 0) return;
// Auto-layout: horizontal row on canvas // Place task nodes on canvas using their stored positions
const wrap = this.shadow.getElementById('canvasWrap'); createdTasks.forEach((t) => {
const wrapRect = wrap?.getBoundingClientRect(); this.weaveNodes.push(this.mkTaskNode(t, t.canvasX ?? 400, t.canvasY ?? 150));
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));
}); });
// Draw dashed frame around the group in intentFramesLayer // Draw dashed frame around the group in intentFramesLayer

View File

@ -24,14 +24,17 @@ import { renderLanding } from "./landing";
import { notify } from '../../server/notification-service'; import { notify } from '../../server/notification-service';
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { import {
commitmentsSchema, tasksSchema, externalTimeLogsSchema, commitmentsSchema, tasksSchema, weavingSchema, externalTimeLogsSchema,
commitmentsDocId, tasksDocId, externalTimeLogsDocId, commitmentsDocId, tasksDocId, weavingDocId, externalTimeLogsDocId,
SKILL_LABELS, SKILL_LABELS,
} from './schemas'; } from './schemas';
import type { import type {
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc, CommitmentsDoc, TasksDoc, WeavingDoc, ExternalTimeLogsDoc,
Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog, Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
WeavingOverlay,
} from './schemas'; } from './schemas';
import { boardDocId, createTaskItem } from '../rtasks/schemas';
import type { BoardDoc, TaskItem } from '../rtasks/schemas';
import { import {
intentsSchema, solverResultsSchema, intentsSchema, solverResultsSchema,
skillCurvesSchema, reputationSchema, skillCurvesSchema, reputationSchema,
@ -77,6 +80,20 @@ function ensureTasksDoc(space: string): TasksDoc {
return doc; return doc;
} }
function ensureWeavingDoc(space: string): WeavingDoc {
const docId = weavingDocId(space);
let doc = _syncServer!.getDoc<WeavingDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<WeavingDoc>(), 'init weaving', (d) => {
const init = weavingSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function newId(): string { function newId(): string {
return crypto.randomUUID(); return crypto.randomUUID();
} }
@ -165,20 +182,24 @@ routes.post("/api/external-time-logs", async (c) => {
d.logs[id].status = 'commitment_created' as any; d.logs[id].status = 'commitment_created' as any;
}); });
// Auto-connect to rTime task if a linked task exists // Auto-connect to weaving task if a linked task exists on canvas
const tasksDocRef = _syncServer!.getDoc<TasksDoc>(tasksDocId(space)); ensureWeavingDoc(space);
if (tasksDocRef) { const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space));
const linkedTask = Object.values(tasksDocRef.tasks).find( const board = getWeavingBoard(space);
(t) => t.description?.includes(backlogTaskId) || t.name?.includes(backlogTaskId) 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) { if (linkedTask) {
ensureTasksDoc(space);
const connId = newId(); const connId = newId();
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'auto-connect time log to task', (d) => { _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'auto-connect time log to task', (d) => {
d.connections[connId] = { d.connections[connId] = {
id: connId, id: connId,
fromCommitmentId: commitmentId, fromCommitmentId: commitmentId,
toTaskId: linkedTask.id, toTaskId: linkedTask,
skill, skill,
hours, hours,
status: 'committed', status: 'committed',
@ -318,6 +339,243 @@ routes.post("/api/tasks/:id/link-backlog", async (c) => {
return c.json(updated.tasks[taskId]); 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<BoardDoc>(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<WeavingDoc>(weavingDocId(space))!;
const board = getWeavingBoard(space);
const allTasks: Record<string, TaskItem> = 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<BoardDoc>(docId)) {
return c.json({ error: "Board not found" }, 404);
}
ensureWeavingDoc(space);
_syncServer!.changeDoc<WeavingDoc>(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<WeavingDoc>(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<WeavingDoc>(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<WeavingDoc>(weavingDocId(space))!;
if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404);
_syncServer!.changeDoc<WeavingDoc>(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<WeavingDoc>(weavingDocId(space))!;
if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404);
_syncServer!.changeDoc<WeavingDoc>(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<WeavingDoc>(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<BoardDoc>(board.docId, 'create task from weave', (d) => {
d.tasks[taskId] = taskItem as any;
});
// Create WeavingOverlay
ensureWeavingDoc(space);
_syncServer!.changeDoc<WeavingDoc>(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<WeavingDoc>(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<WeavingDoc>(weavingDocId(space))!;
return c.json(doc.execStates[taskId]);
});
// ── Cyclos proxy config ── // ── Cyclos proxy config ──
const CYCLOS_URL = process.env.CYCLOS_URL || ''; const CYCLOS_URL = process.env.CYCLOS_URL || '';
const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || ''; const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || '';
@ -341,9 +599,9 @@ const DEMO_COMMITMENTS: Omit<Commitment, 'id' | 'createdAt'>[] = [
{ memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' }, { memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' },
]; ];
const DEMO_TASKS: Omit<Task, 'id'>[] = [ const DEMO_SEED_TASKS: { title: string; needs: Record<string, number> }[] = [
{ name: 'Organize Community Event', description: '', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 }, links: [], notes: '' }, { title: 'Organize Community Event', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 } },
{ name: 'Run Harm Reduction Workshop', description: '', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 }, links: [], notes: '' }, { title: 'Run Harm Reduction Workshop', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 } },
]; ];
function seedDemoIfEmpty(space: string = 'demo') { function seedDemoIfEmpty(space: string = 'demo') {
@ -362,15 +620,41 @@ function seedDemoIfEmpty(space: string = 'demo') {
(d.meta as any).seeded = true; (d.meta as any).seeded = true;
}); });
ensureTasksDoc(space); // Seed tasks into rTasks board + place on weaving canvas
_syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'seed tasks', (d) => { const board = getWeavingBoard(space);
for (const t of DEMO_TASKS) { if (board) {
ensureWeavingDoc(space);
let canvasX = 300;
for (const t of DEMO_SEED_TASKS) {
const id = newId(); const id = newId();
d.tasks[id] = { id, ...t } as any; const taskItem = createTaskItem(id, space, t.title, { description: '' });
_syncServer.changeDoc<BoardDoc>(board.docId, 'seed rtime task', (d) => {
d.tasks[id] = taskItem as any;
});
_syncServer.changeDoc<WeavingDoc>(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 seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (rTasks + weaving)`);
} else {
console.log(`[rTime] Demo data seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_TASKS.length} tasks`); // Fallback: seed into legacy TasksDoc
ensureTasksDoc(space);
_syncServer.changeDoc<TasksDoc>(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 ── // ── Commitments API ──
@ -437,12 +721,11 @@ routes.delete("/api/commitments/:id", async (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// ── Tasks API ── // ── Tasks API (compat shim — reads from WeavingDoc + rTasks board) ──
routes.get("/api/tasks", async (c) => { routes.get("/api/tasks", async (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
// Resolve caller role for membrane filtering
let callerRole: SpaceRoleString = 'viewer'; let callerRole: SpaceRoleString = 'viewer';
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
if (token) { if (token) {
@ -453,36 +736,93 @@ routes.get("/api/tasks", async (c) => {
} catch {} } catch {}
} }
ensureTasksDoc(space); ensureWeavingDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const wDoc = _syncServer!.getDoc<WeavingDoc>(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<TasksDoc>(tasksDocId(space));
if (legacyDoc) {
for (const t of Object.values(legacyDoc.tasks)) {
if (!wDoc.weavingOverlays[t.id]) {
tasks.push(t);
}
}
}
return c.json({ return c.json({
tasks: filterArrayByVisibility(Object.values(doc.tasks), callerRole), tasks: filterArrayByVisibility(tasks, callerRole),
connections: Object.values(doc.connections), connections: Object.values(wDoc.connections),
execStates: Object.values(doc.execStates), execStates: Object.values(wDoc.execStates),
}); });
}); });
// POST /api/tasks — compat shim: creates rTasks item + places on canvas
routes.post("/api/tasks", async (c) => { routes.post("/api/tasks", async (c) => {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401); 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 space = c.req.param("space") || "demo";
const body = await c.req.json(); 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); if (!name || !needs) return c.json({ error: "name, needs required" }, 400);
const id = newId(); const board = getWeavingBoard(space);
ensureTasksDoc(space); if (!board) {
// Fallback to legacy TasksDoc if no board bound
const id = newId();
ensureTasksDoc(space);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add task (legacy)', (d) => {
d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.tasks[id], 201);
}
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add task', (d) => { // Create in rTasks + WeavingDoc
d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any; const taskId = newId();
const taskItem = createTaskItem(taskId, space, name, {
description: description || '',
createdBy: (claims.did as string) || null,
});
_syncServer!.changeDoc<BoardDoc>(board.docId, 'create task from rtime compat', (d) => {
d.tasks[taskId] = taskItem as any;
}); });
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; ensureWeavingDoc(space);
return c.json(doc.tasks[id], 201); _syncServer!.changeDoc<WeavingDoc>(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) => { routes.put("/api/tasks/:id", async (c) => {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401); 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 space = c.req.param("space") || "demo";
const id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; // Update rTasks item (title, description)
if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404); const board = getWeavingBoard(space);
if (board?.doc.tasks[id]) {
_syncServer!.changeDoc<BoardDoc>(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<TasksDoc>(tasksDocId(space), 'update task', (d) => { // Update WeavingDoc overlay (needs, notes, links)
const t = d.tasks[id]; ensureWeavingDoc(space);
if (body.name !== undefined) t.name = body.name; const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
if (body.description !== undefined) t.description = body.description; if (wDoc.weavingOverlays[id]) {
if (body.needs !== undefined) t.needs = body.needs; _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'update overlay (compat)', (d) => {
if (body.links !== undefined) t.links = body.links; const ov = d.weavingOverlays[id];
if (body.notes !== undefined) t.notes = body.notes; 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<TasksDoc>(tasksDocId(space))!;
if (legacyDoc.tasks[id]) {
_syncServer!.changeDoc<TasksDoc>(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<BoardDoc>(board.docId)?.tasks[id] : null;
const ov = _syncServer!.getDoc<WeavingDoc>(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<TasksDoc>(tasksDocId(space))!;
return c.json(updated.tasks[id]);
}); });
// ── Connections API ── // ── Connections API (now writes to WeavingDoc) ──
routes.post("/api/connections", async (c) => { routes.post("/api/connections", async (c) => {
const token = extractToken(c.req.raw.headers); 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); if (typeof hours !== 'number' || hours <= 0) return c.json({ error: "hours must be a positive number" }, 400);
const id = newId(); const id = newId();
ensureTasksDoc(space); ensureWeavingDoc(space);
ensureCommitmentsDoc(space); ensureCommitmentsDoc(space);
// Validate: hours <= commitment's available hours // Validate: hours <= commitment's available hours
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space)); const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
const commitment = cDoc?.items?.[fromCommitmentId]; const commitment = cDoc?.items?.[fromCommitmentId];
if (!commitment) return c.json({ error: "Commitment not found" }, 404); if (!commitment) return c.json({ error: "Commitment not found" }, 404);
const tDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
const usedHours = Object.values(tDoc.connections) const wDoc = _syncServer!.getDoc<WeavingDoc>(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) .filter(cn => cn.fromCommitmentId === fromCommitmentId)
.reduce((sum, cn) => sum + (cn.hours || 0), 0); .reduce((sum, cn) => sum + (cn.hours || 0), 0);
if (hours > commitment.hours - usedHours) { if (hours > commitment.hours - usedHours) {
return c.json({ error: "Requested hours exceed available hours" }, 400); return c.json({ error: "Requested hours exceed available hours" }, 400);
} }
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add connection', (d) => { _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'add connection', (d) => {
d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any; d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any;
}); });
// Notify commitment owner that their time was requested // Notify commitment owner
const updatedTDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const board = getWeavingBoard(space);
const task = updatedTDoc.tasks?.[toTaskId]; const taskItem = board?.doc.tasks[toTaskId];
if (commitment?.ownerDid && commitment.ownerDid !== claims.did) { if (commitment?.ownerDid && commitment.ownerDid !== claims.did) {
notify({ notify({
userDid: commitment.ownerDid, userDid: commitment.ownerDid,
category: 'module', category: 'module',
eventType: 'commitment_requested', eventType: 'commitment_requested',
title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was 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, spaceSlug: space,
moduleId: 'rtime', moduleId: 'rtime',
actionUrl: `/rtime`, actionUrl: `/rtime`,
@ -561,7 +943,8 @@ routes.post("/api/connections", async (c) => {
}).catch(() => {}); }).catch(() => {});
} }
return c.json(updatedTDoc.connections[id], 201); const updatedW = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
return c.json(updatedW.connections[id], 201);
}); });
routes.delete("/api/connections/:id", async (c) => { 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 space = c.req.param("space") || "demo";
const id = c.req.param("id"); const id = c.req.param("id");
ensureTasksDoc(space); ensureWeavingDoc(space);
ensureCommitmentsDoc(space); ensureCommitmentsDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
const connection = doc.connections[id]; const connection = wDoc.connections[id];
if (!connection) return c.json({ error: "Not found" }, 404); if (!connection) return c.json({ error: "Not found" }, 404);
// Look up commitment owner to notify them
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space)); const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
const commitment = cDoc?.items?.[connection.fromCommitmentId]; const commitment = cDoc?.items?.[connection.fromCommitmentId];
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'remove connection', (d) => { _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'remove connection', (d) => {
delete d.connections[id]; delete d.connections[id];
}); });
// Notify commitment owner that the request was declined
if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) { if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) {
notify({ notify({
userDid: commitment.ownerDid, userDid: commitment.ownerDid,
@ -616,30 +997,29 @@ routes.patch("/api/connections/:id", async (c) => {
const { status } = body; const { status } = body;
if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400); if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400);
ensureTasksDoc(space); ensureWeavingDoc(space);
ensureCommitmentsDoc(space); ensureCommitmentsDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
const connection = doc.connections[id]; const connection = wDoc.connections[id];
if (!connection) return c.json({ error: "Not found" }, 404); if (!connection) return c.json({ error: "Not found" }, 404);
if (status === 'declined') { if (status === 'declined') {
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'decline connection', (d) => { _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'decline connection', (d) => {
delete d.connections[id]; delete d.connections[id];
}); });
return c.json({ ok: true, deleted: true }); return c.json({ ok: true, deleted: true });
} }
// status === 'committed' _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'approve connection', (d) => {
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'approve connection', (d) => {
d.connections[id].status = 'committed' as any; d.connections[id].status = 'committed' as any;
}); });
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const updated = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
return c.json(updated.connections[id]); 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) => { routes.put("/api/tasks/:id/exec-state", async (c) => {
const token = extractToken(c.req.raw.headers); 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 taskId = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { steps, launchedAt } = body; const { steps, launchedAt } = body;
ensureTasksDoc(space); ensureWeavingDoc(space);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update exec state', (d) => { _syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'update exec state', (d) => {
if (!d.execStates[taskId]) { if (!d.execStates[taskId]) {
d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any; 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; if (launchedAt) d.execStates[taskId].launchedAt = launchedAt;
}); });
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const doc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
return c.json(doc.execStates[taskId]); 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 space = c.req.param("space") || "demo";
const taskId = c.req.param("id"); const taskId = c.req.param("id");
ensureTasksDoc(space);
// Try WeavingDoc + rTasks first, fallback to legacy TasksDoc
ensureWeavingDoc(space);
const wDoc = _syncServer!.getDoc<WeavingDoc>(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<TasksDoc>(tasksDocId(space))!; const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
const task = doc.tasks[taskId]; const task = doc.tasks[taskId];
if (!task) return c.json({ error: "Task not found" }, 404); 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); 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]) => const acceptanceCriteria = Object.entries(task.needs).map(([skill, hours]) =>
`${SKILL_LABELS[skill as Skill] || skill}: ${hours}h` `${SKILL_LABELS[skill as Skill] || skill}: ${hours}h`
); );
return c.json({ return c.json({
title: task.name, title: task.name,
description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '', 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), labels: Object.keys(task.needs),
notes: task.notes || '', notes: task.notes || '',
acceptanceCriteria, acceptanceCriteria,
rtime: { rtime: { taskId: task.id, space },
taskId: task.id,
space,
},
}); });
}); });
@ -805,7 +1202,8 @@ export const timeModule: RSpaceModule = {
scoping: { defaultScope: 'space', userConfigurable: false }, scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [ docSchemas: [
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init }, { 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:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init },
{ pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.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 }, { pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init },

View File

@ -144,6 +144,33 @@ export interface ExternalTimeLogsDoc {
logs: Record<string, ExternalTimeLog>; logs: Record<string, ExternalTimeLog>;
} }
// ── Weaving overlay (rTasks integration) ──
export interface WeavingOverlay {
rtasksId: string;
needs: Record<string, number>;
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<string, WeavingOverlay>;
connections: Record<string, Connection>;
execStates: Record<string, ExecState>;
projectFrames: Record<string, ProjectFrame>;
}
// ── DocId helpers ── // ── DocId helpers ──
export function commitmentsDocId(space: string) { export function commitmentsDocId(space: string) {
@ -154,6 +181,10 @@ export function tasksDocId(space: string) {
return `${space}:rtime:tasks` as const; return `${space}:rtime:tasks` as const;
} }
export function weavingDocId(space: string) {
return `${space}:rtime:weaving` as const;
}
export function externalTimeLogsDocId(space: string) { export function externalTimeLogsDocId(space: string) {
return `${space}:rtime:external-time-logs` as const; return `${space}:rtime:external-time-logs` as const;
} }
@ -195,6 +226,26 @@ export const tasksSchema: DocSchema<TasksDoc> = {
}), }),
}; };
export const weavingSchema: DocSchema<WeavingDoc> = {
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<ExternalTimeLogsDoc> = { export const externalTimeLogsSchema: DocSchema<ExternalTimeLogsDoc> = {
module: 'rtime', module: 'rtime',
collection: 'external-time-logs', collection: 'external-time-logs',

View File

@ -16,8 +16,11 @@
import * as Automerge from '@automerge/automerge'; import * as Automerge from '@automerge/automerge';
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { confirmBurn, reverseBurn } from '../../server/token-service'; import { confirmBurn, reverseBurn } from '../../server/token-service';
import { commitmentsDocId, tasksDocId } from './schemas'; import { commitmentsDocId, tasksDocId, weavingDocId } from './schemas';
import type { CommitmentsDoc, TasksDoc, Skill } 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 { import {
intentsDocId, solverResultsDocId, intentsDocId, solverResultsDocId,
skillCurvesDocId, reputationDocId, skillCurvesDocId, reputationDocId,
@ -114,7 +117,7 @@ export async function settleResult(
const offerIntents = intents.filter(i => i.type === 'offer'); const offerIntents = intents.filter(i => i.type === 'offer');
const needIntentsAll = intents.filter(i => i.type === 'need'); 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 taskId = crypto.randomUUID();
const taskSkills = [...new Set(intents.map(i => i.skill))]; const taskSkills = [...new Set(intents.map(i => i.skill))];
const taskNeeds: Record<string, number> = {}; const taskNeeds: Record<string, number> = {};
@ -122,28 +125,65 @@ export async function settleResult(
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours; taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
} }
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create task', (d) => { const taskTitle = `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`;
d.tasks[taskId] = { const taskDesc = `Auto-generated from solver result. Members: ${result.members.length}`;
id: taskId,
name: `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`, // Try to create in rTasks board
description: `Auto-generated from solver result. Members: ${result.members.length}`, const boardSlug = space; // default board
needs: taskNeeds, const bDocId = boardDocId(space, boardSlug);
links: [], const boardDoc = syncServer.getDoc<BoardDoc>(bDocId);
notes: `Settled from solver result ${resultId}`,
} as any; if (boardDoc) {
}); const taskItem = createTaskItem(taskId, space, taskTitle, { description: taskDesc });
syncServer.changeDoc<BoardDoc>(bDocId, 'settlement: create rTasks item', (d) => {
d.tasks[taskId] = taskItem as any;
});
} else {
// Fallback: create in legacy TasksDoc
syncServer.changeDoc<TasksDoc>(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; outcome.tasksCreated = 1;
// Create connections (offer → task) // Ensure WeavingDoc and create overlay + connections
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create connections', (d) => { let wDoc = syncServer.getDoc<WeavingDoc>(weavingDocId(space));
if (!wDoc) {
wDoc = Automerge.change(Automerge.init<WeavingDoc>(), '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<WeavingDoc>(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<WeavingDoc>(weavingDocId(space), 'settlement: create connections', (d) => {
for (const offer of offerIntents) { for (const offer of offerIntents) {
// Find or create a commitment for this offer in CommitmentsDoc
const connId = crypto.randomUUID(); const connId = crypto.randomUUID();
d.connections[connId] = { d.connections[connId] = {
id: connId, id: connId,
fromCommitmentId: offer.id, // Using intent ID as reference fromCommitmentId: offer.id,
toTaskId: taskId, toTaskId: taskId,
skill: offer.skill, skill: offer.skill,
hours: offer.hours,
status: 'committed',
} as any; } as any;
outcome.connectionsCreated++; outcome.connectionsCreated++;
} }

View File

@ -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<TasksDoc>(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<BoardDoc>(bDocId);
if (!boardDoc) {
boardDoc = Automerge.change(Automerge.init<BoardDoc>(), '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<BoardDoc>(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<WeavingDoc>(wDocId);
if (!wDoc) {
wDoc = Automerge.change(Automerge.init<WeavingDoc>(), '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<WeavingDoc>(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<WeavingDoc>(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<WeavingDoc>(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<WeavingDoc>(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");');
}

View File

@ -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 * rtime_list_time_logs, rtime_create_commitment
*/ */
@ -10,15 +10,17 @@ import { z } from "zod";
import type { SyncServer } from "../local-first/sync-server"; import type { SyncServer } from "../local-first/sync-server";
import { import {
commitmentsDocId, commitmentsDocId,
tasksDocId, weavingDocId,
externalTimeLogsDocId, externalTimeLogsDocId,
} from "../../modules/rtime/schemas"; } from "../../modules/rtime/schemas";
import type { import type {
CommitmentsDoc, CommitmentsDoc,
TasksDoc, WeavingDoc,
ExternalTimeLogsDoc, ExternalTimeLogsDoc,
Skill, Skill,
} from "../../modules/rtime/schemas"; } from "../../modules/rtime/schemas";
import { boardDocId } from "../../modules/rtasks/schemas";
import type { BoardDoc } from "../../modules/rtasks/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility } from "../../shared/membrane"; import { filterArrayByVisibility } from "../../shared/membrane";
@ -66,8 +68,8 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) {
); );
server.tool( server.tool(
"rtime_list_tasks", "rtime_list_woven_tasks",
"List rTime tasks with their needs maps", "List tasks placed on the rTime weaving canvas with overlay data (needs, position, connections)",
{ {
space: z.string().describe("Space slug"), space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token"), 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); const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!); if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<TasksDoc>(tasksDocId(space)); const wDoc = syncServer.getDoc<WeavingDoc>(weavingDocId(space));
if (!doc) { if (!wDoc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] }; 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<BoardDoc>(boardDocId(space, boardSlug));
const tasks = Object.entries(wDoc.weavingOverlays || {})
.slice(0, limit || 50) .slice(0, limit || 50)
.map(t => ({ .map(([id, ov]) => {
id: t.id, const item = board?.tasks[id];
name: t.name, return {
description: t.description, id,
needs: t.needs, 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) }] }; 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<WeavingDoc>(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<BoardDoc>(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<WeavingDoc>(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( server.tool(
"rtime_list_time_logs", "rtime_list_time_logs",
"List external time logs (imported from backlog-md)", "List external time logs (imported from backlog-md)",