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:
parent
240131ae70
commit
2ba20fabbb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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 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 (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 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', (d) => {
|
||||
_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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
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,12 +831,34 @@ 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();
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
||||
// 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 doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||
if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update task', (d) => {
|
||||
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;
|
||||
|
|
@ -504,12 +866,25 @@ routes.put("/api/tasks/:id", async (c) => {
|
|||
if (body.links !== undefined) t.links = body.links;
|
||||
if (body.notes !== undefined) t.notes = body.notes;
|
||||
});
|
||||
} else {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||
return c.json(updated.tasks[id]);
|
||||
// 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 || '',
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
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: `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}`,
|
||||
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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");');
|
||||
}
|
||||
|
|
@ -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)",
|
||||
|
|
|
|||
Loading…
Reference in New Issue