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 }[];
notes: string;
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 {
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 {
<button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button>
</div>
<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>
<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);
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 = '<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) {
@ -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<string, number> }[] = [];
const taskDefs: { title: string; needs: Record<string, number> }[] = [];
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

View File

@ -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<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 {
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<TasksDoc>(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<WeavingDoc>(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<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] = {
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<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 ──
const CYCLOS_URL = process.env.CYCLOS_URL || '';
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' },
];
const DEMO_TASKS: Omit<Task, 'id'>[] = [
{ 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<string, number> }[] = [
{ 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<TasksDoc>(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<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 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<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 ──
@ -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<TasksDoc>(tasksDocId(space))!;
ensureWeavingDoc(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({
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<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) => {
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<BoardDoc>(board.docId, 'create task from rtime compat', (d) => {
d.tasks[taskId] = taskItem as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.tasks[id], 201);
ensureWeavingDoc(space);
_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) => {
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<TasksDoc>(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<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) => {
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<WeavingDoc>(weavingDocId(space))!;
if (wDoc.weavingOverlays[id]) {
_syncServer!.changeDoc<WeavingDoc>(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<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) => {
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<CommitmentsDoc>(commitmentsDocId(space));
const commitment = cDoc?.items?.[fromCommitmentId];
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)
.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<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;
});
// Notify commitment owner that their time was requested
const updatedTDoc = _syncServer!.getDoc<TasksDoc>(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<WeavingDoc>(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<TasksDoc>(tasksDocId(space))!;
const connection = doc.connections[id];
const wDoc = _syncServer!.getDoc<WeavingDoc>(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<CommitmentsDoc>(commitmentsDocId(space));
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];
});
// 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<TasksDoc>(tasksDocId(space))!;
const connection = doc.connections[id];
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
const connection = wDoc.connections[id];
if (!connection) return c.json({ error: "Not found" }, 404);
if (status === 'declined') {
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'decline connection', (d) => {
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'decline connection', (d) => {
delete d.connections[id];
});
return c.json({ ok: true, deleted: true });
}
// status === 'committed'
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'approve connection', (d) => {
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'approve connection', (d) => {
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]);
});
// ── 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<TasksDoc>(tasksDocId(space), 'update exec state', (d) => {
_syncServer!.changeDoc<WeavingDoc>(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<TasksDoc>(tasksDocId(space))!;
const doc = _syncServer!.getDoc<WeavingDoc>(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<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 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 },

View File

@ -144,6 +144,33 @@ export interface ExternalTimeLogsDoc {
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 ──
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<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> = {
module: 'rtime',
collection: 'external-time-logs',

View File

@ -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<string, number> = {};
@ -122,28 +125,65 @@ export async function settleResult(
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
}
syncServer.changeDoc<TasksDoc>(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<BoardDoc>(bDocId);
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;
// Create connections (offer → task)
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create connections', (d) => {
// Ensure WeavingDoc and create overlay + connections
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) {
// 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++;
}

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
*/
@ -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<TasksDoc>(tasksDocId(space));
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] };
const wDoc = syncServer.getDoc<WeavingDoc>(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<BoardDoc>(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<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(
"rtime_list_time_logs",
"List external time logs (imported from backlog-md)",