Initial: Fastify API + DB schema + collab setup
- Fastify API on :3000 (tasks, groups, auth, connectors) - PostgreSQL schema: users, tasks, task_groups, goals, connectors - 8 default task groups - JWT auth (register/login/me) - API Bridge framework with webhooks - CLAUDE.md + polling scripts - Collab worker integration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.claude/
|
||||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Task Team — Claude CLI Agent
|
||||||
|
|
||||||
|
## Tvoje role
|
||||||
|
Jsi vývojový agent na VM-APP (10.10.10.30, public 136.243.43.144).
|
||||||
|
Pracuješ autonomně na projektu Task Team.
|
||||||
|
|
||||||
|
## Notion projekt
|
||||||
|
- Master: https://www.notion.so/32ebc92a17cd813f9414fd2fae4337a3
|
||||||
|
- Dev Tasks DB: 659a5381-564a-453a-9e2b-1345c457cca9
|
||||||
|
- Komunikační protokol: https://www.notion.so/332bc92a17cd8156bc73cb5721be3999
|
||||||
|
|
||||||
|
## Pravidla spolupráce
|
||||||
|
1. Kontroluj Notion Dev Tasks každých 5 minut
|
||||||
|
2. Vezmi tasky se statusem "Ready for Dev" — implementuj je
|
||||||
|
3. Po dokončení: změň status na "Done", napiš Server Feedback
|
||||||
|
4. Pokud potřebuješ upřesnění: vytvoř task "❓ Question:" se statusem "Needs Clarification"
|
||||||
|
5. Nikdy neimplementuj bez záznamu v Notionu
|
||||||
|
|
||||||
|
## Infrastruktura
|
||||||
|
- VM-DB: 10.10.10.10 (PostgreSQL 18.3:5432, Redis 8.6.2:6379)
|
||||||
|
- VM-ODOO: 10.10.10.20 (Odoo 19 Enterprise :8069, Community :8070)
|
||||||
|
- VM-APP: 10.10.10.30 (tento server — Task Team API, micro-apps, n8n, Claude CLI)
|
||||||
|
- VM-DEV: 10.10.10.40 (Gitea 1.25.5 :3000)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Runtime: Node.js 24 LTS
|
||||||
|
- API: Fastify
|
||||||
|
- Frontend: Next.js 14 PWA (micro-apps)
|
||||||
|
- DB: PostgreSQL 18 @ 10.10.10.10 (user: taskteam, db: taskteam)
|
||||||
|
- Cache: Redis 8 @ 10.10.10.10
|
||||||
|
- Automation: n8n
|
||||||
|
- Proxy: Nginx 1.28.3
|
||||||
|
- Docker: 29.3.1
|
||||||
|
|
||||||
|
## Domény
|
||||||
|
- api.hasdo.info → :3000
|
||||||
|
- tasks.hasdo.info → :3001
|
||||||
|
- cal.hasdo.info → :3002
|
||||||
|
- plans.hasdo.info → :3003
|
||||||
|
- goals.hasdo.info → :3004
|
||||||
|
- chat.hasdo.info → :3005
|
||||||
|
- n8n.hasdo.info → :5678
|
||||||
|
|
||||||
|
## Adresáře
|
||||||
|
/opt/task-team/api/src/{routes,models,middleware,services}
|
||||||
|
/opt/task-team/apps/
|
||||||
|
/opt/task-team/assets/brand/
|
||||||
|
/opt/task-team/db/migrations/
|
||||||
|
/opt/n8n/
|
||||||
7
NOTION_POLL_PROMPT.txt
Normal file
7
NOTION_POLL_PROMPT.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Přečti CLAUDE.md. Zkontroluj Notion Dev Tasks databázi (ID: 659a5381-564a-453a-9e2b-1345c457cca9).
|
||||||
|
Najdi všechny tasky se statusem "Ready for Dev".
|
||||||
|
Vezmi první podle priority (Critical > High > Medium > Low).
|
||||||
|
Ihned změň jeho status na "In Progress".
|
||||||
|
Implementuj ho podle Description.
|
||||||
|
Po dokončení: změň na "Done" a zapiš Server Feedback ve formátu z komunikačního protokolu.
|
||||||
|
Pokud není žádný Ready for Dev task — nedělej nic.
|
||||||
12
STATUS_REPORT_PROMPT.txt
Normal file
12
STATUS_REPORT_PROMPT.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
Vytvoř nový task v Notion Dev Tasks databázi (ID: 659a5381-564a-453a-9e2b-1345c457cca9).
|
||||||
|
Název: "📊 Status Report [aktuální datum a čas]"
|
||||||
|
Status: Done
|
||||||
|
Priorita: Low
|
||||||
|
Server Feedback musí obsahovat:
|
||||||
|
- Všechny Done tasky (dnes)
|
||||||
|
- Všechny In Progress tasky
|
||||||
|
- Všechny Ready for Dev tasky
|
||||||
|
- Všechny Needs Clarification tasky
|
||||||
|
- Výstup: df -h | grep "/$" (disk)
|
||||||
|
- Výstup: free -h | grep Mem (paměť)
|
||||||
|
- Výstup: docker ps --format "table {{.Names}}\t{{.Status}}" (containery)
|
||||||
1805
api/package-lock.json
generated
Normal file
1805
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
api/package.json
Normal file
31
api/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "task-team-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Task Team API Server",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"test": "echo No tests yet"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/swagger": "^9.7.0",
|
||||||
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"fastify": "^5.8.4",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"redis": "^5.11.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
api/src/index.js
Normal file
41
api/src/index.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Task Team — API Server — 2026-03-29
|
||||||
|
require('dotenv').config();
|
||||||
|
const Fastify = require('fastify');
|
||||||
|
const cors = require('@fastify/cors');
|
||||||
|
const jwt = require('@fastify/jwt');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// Database pool
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://taskteam:TaskTeam2026!@10.10.10.10:5432/taskteam'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
app.register(cors, { origin: true });
|
||||||
|
app.register(jwt, { secret: process.env.JWT_SECRET || 'taskteam-jwt-secret-2026' });
|
||||||
|
|
||||||
|
// Decorate with db
|
||||||
|
app.decorate('db', pool);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
app.register(require('./routes/tasks'), { prefix: '/api/v1' });
|
||||||
|
app.register(require('./routes/groups'), { prefix: '/api/v1' });
|
||||||
|
app.register(require('./routes/auth'), { prefix: '/api/v1' });
|
||||||
|
app.register(require('./routes/connectors'), { prefix: '/api/v1' });
|
||||||
|
|
||||||
|
// Start
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
await app.listen({ port: process.env.PORT || 3000, host: '0.0.0.0' });
|
||||||
|
console.log('Task Team API listening on port ' + (process.env.PORT || 3000));
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
start();
|
||||||
29
api/src/routes/auth.js
Normal file
29
api/src/routes/auth.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Task Team — Auth Routes — 2026-03-29
|
||||||
|
async function authRoutes(app) {
|
||||||
|
// Simple JWT auth for now, Supabase integration later
|
||||||
|
app.post('/auth/register', async (req) => {
|
||||||
|
const { email, name, phone, password } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'INSERT INTO users (email, name, phone) VALUES ($1, $2, $3) RETURNING id, email, name',
|
||||||
|
[email, name, phone]
|
||||||
|
);
|
||||||
|
const token = app.jwt.sign({ id: rows[0].id, email: rows[0].email });
|
||||||
|
return { data: { user: rows[0], token } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/login', async (req) => {
|
||||||
|
const { email } = req.body;
|
||||||
|
const { rows } = await app.db.query('SELECT id, email, name FROM users WHERE email = $1', [email]);
|
||||||
|
if (!rows.length) throw { statusCode: 401, message: 'User not found' };
|
||||||
|
const token = app.jwt.sign({ id: rows[0].id, email: rows[0].email });
|
||||||
|
return { data: { user: rows[0], token } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/me', { preHandler: [async (req) => { await req.jwtVerify() }] }, async (req) => {
|
||||||
|
const { rows } = await app.db.query('SELECT id, email, name, phone, language, settings FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'User not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = authRoutes;
|
||||||
55
api/src/routes/connectors.js
Normal file
55
api/src/routes/connectors.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Task Team — API Bridge / Connectors — 2026-03-29
|
||||||
|
async function connectorRoutes(app) {
|
||||||
|
// List connectors
|
||||||
|
app.get('/connectors', async (req) => {
|
||||||
|
const { rows } = await app.db.query('SELECT id, type, enabled, last_sync_at, created_at FROM connectors ORDER BY created_at DESC');
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get connector
|
||||||
|
app.get('/connectors/:id', async (req) => {
|
||||||
|
const { rows } = await app.db.query('SELECT * FROM connectors WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Connector not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create connector
|
||||||
|
app.post('/connectors', async (req) => {
|
||||||
|
const { type, config, user_id } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'INSERT INTO connectors (user_id, type, config) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[user_id, type, JSON.stringify(config || {})]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync connector
|
||||||
|
app.post('/connectors/:id/sync', async (req) => {
|
||||||
|
const { rows } = await app.db.query('SELECT * FROM connectors WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Connector not found' };
|
||||||
|
// TODO: dispatch sync job to n8n or Redis queue
|
||||||
|
await app.db.query('UPDATE connectors SET last_sync_at = NOW() WHERE id = $1', [req.params.id]);
|
||||||
|
return { status: 'sync_started', connector: rows[0].type };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic webhook
|
||||||
|
app.post('/connectors/webhook/:type', async (req) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
// Log webhook
|
||||||
|
app.log.info({ type, body: req.body }, 'Webhook received');
|
||||||
|
// TODO: process based on type (odoo, moodle, pohoda, generic)
|
||||||
|
return { status: 'received', type };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle connector
|
||||||
|
app.put('/connectors/:id/toggle', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'UPDATE connectors SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1 RETURNING id, type, enabled',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Connector not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = connectorRoutes;
|
||||||
62
api/src/routes/groups.js
Normal file
62
api/src/routes/groups.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Task Team — Groups CRUD — 2026-03-29
|
||||||
|
async function groupRoutes(app) {
|
||||||
|
app.get('/groups', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'SELECT * FROM task_groups ORDER BY order_index ASC'
|
||||||
|
);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/groups/:id', async (req) => {
|
||||||
|
const { rows } = await app.db.query('SELECT * FROM task_groups WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/groups', async (req) => {
|
||||||
|
const { name, color, icon, order_index, time_zones, user_id } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'INSERT INTO task_groups (user_id, name, color, icon, order_index, time_zones) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
|
||||||
|
[user_id, name, color, icon || '', order_index || 0, JSON.stringify(time_zones || [])]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/groups/:id', async (req) => {
|
||||||
|
const { name, color, icon, order_index, time_zones } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon),
|
||||||
|
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), updated_at=NOW()
|
||||||
|
WHERE id=$6 RETURNING *`,
|
||||||
|
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/groups/:id', async (req) => {
|
||||||
|
const { rowCount } = await app.db.query('DELETE FROM task_groups WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rowCount) throw { statusCode: 404, message: 'Group not found' };
|
||||||
|
return { status: 'deleted' };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/groups/reorder', async (req) => {
|
||||||
|
const { order } = req.body; // [{id, order_index}]
|
||||||
|
const client = await app.db.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (const item of order) {
|
||||||
|
await client.query('UPDATE task_groups SET order_index=$1, updated_at=NOW() WHERE id=$2', [item.order_index, item.id]);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
return { status: 'ok' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = groupRoutes;
|
||||||
88
api/src/routes/tasks.js
Normal file
88
api/src/routes/tasks.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Task Team — Tasks CRUD — 2026-03-29
|
||||||
|
async function taskRoutes(app) {
|
||||||
|
// List tasks
|
||||||
|
app.get('/tasks', async (req, reply) => {
|
||||||
|
const { status, group_id, limit = 50, offset = 0 } = req.query;
|
||||||
|
let query = 'SELECT t.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon FROM tasks t LEFT JOIN task_groups tg ON t.group_id = tg.id WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
if (status) { params.push(status); query += ` AND t.status = $${params.length}`; }
|
||||||
|
if (group_id) { params.push(group_id); query += ` AND t.group_id = $${params.length}`; }
|
||||||
|
query += ' ORDER BY t.scheduled_at ASC NULLS LAST, t.priority DESC, t.created_at DESC';
|
||||||
|
params.push(limit); query += ` LIMIT $${params.length}`;
|
||||||
|
params.push(offset); query += ` OFFSET $${params.length}`;
|
||||||
|
const { rows } = await app.db.query(query, params);
|
||||||
|
return { data: rows, total: rows.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single task
|
||||||
|
app.get('/tasks/:id', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'SELECT t.*, tg.name as group_name, tg.color as group_color FROM tasks t LEFT JOIN task_groups tg ON t.group_id = tg.id WHERE t.id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Task not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create task
|
||||||
|
app.post('/tasks', async (req) => {
|
||||||
|
const { title, description, status, group_id, priority, scheduled_at, due_at, assigned_to } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`INSERT INTO tasks (title, description, status, group_id, priority, scheduled_at, due_at, assigned_to)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||||
|
[title, description || '', status || 'pending', group_id, priority || 'medium', scheduled_at, due_at, assigned_to || []]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update task
|
||||||
|
app.put('/tasks/:id', async (req) => {
|
||||||
|
const fields = req.body;
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
let i = 1;
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (['title','description','status','group_id','priority','scheduled_at','due_at','assigned_to','completed_at'].includes(key)) {
|
||||||
|
sets.push(`${key} = $${i}`);
|
||||||
|
params.push(value);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fields.status === 'completed' && !fields.completed_at) {
|
||||||
|
sets.push(`completed_at = NOW()`);
|
||||||
|
}
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
params.push(req.params.id);
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`UPDATE tasks SET ${sets.join(', ')} WHERE id = $${i} RETURNING *`, params
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Task not found' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete task
|
||||||
|
app.delete('/tasks/:id', async (req) => {
|
||||||
|
const { rowCount } = await app.db.query('DELETE FROM tasks WHERE id = $1', [req.params.id]);
|
||||||
|
if (!rowCount) throw { statusCode: 404, message: 'Task not found' };
|
||||||
|
return { status: 'deleted' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task comments
|
||||||
|
app.get('/tasks/:id/comments', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'SELECT * FROM task_comments WHERE task_id = $1 ORDER BY created_at ASC', [req.params.id]
|
||||||
|
);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/tasks/:id/comments', async (req) => {
|
||||||
|
const { content, is_ai } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'INSERT INTO task_comments (task_id, content, is_ai) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[req.params.id, content, is_ai || false]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = taskRoutes;
|
||||||
137
collab-worker.py
Normal file
137
collab-worker.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Task Team Collab Worker — HTTP-based agent that polls mngmt for tasks,
|
||||||
|
executes them with Claude CLI, reports results back.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Config
|
||||||
|
MNGMT_URL = os.environ.get("COLLAB_URL", "https://mngmt.it-enterprise.pro")
|
||||||
|
TOKEN = os.environ.get("COLLAB_TOKEN", "")
|
||||||
|
HOSTNAME = os.environ.get("HOSTNAME", "unknown")
|
||||||
|
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "/usr/bin/claude")
|
||||||
|
WORK_DIR = os.environ.get("WORK_DIR", "/opt/task-team")
|
||||||
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
|
||||||
|
|
||||||
|
API = f"{MNGMT_URL}/api/v2/collab"
|
||||||
|
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
log = logging.getLogger("collab-worker")
|
||||||
|
|
||||||
|
running = True
|
||||||
|
def stop(sig, frame):
|
||||||
|
global running
|
||||||
|
running = False
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat():
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{API}/heartbeat", headers=HEADERS,
|
||||||
|
json={"node": HOSTNAME, "server": "taskteam"}, timeout=10)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Heartbeat failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_tasks():
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{API}/tasks", headers=HEADERS, timeout=10)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
return [t for t in data.get("data", []) if t.get("status") == "open" and
|
||||||
|
(t.get("assignee") == HOSTNAME or t.get("assignee") is None)]
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Get tasks failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def claim_task(task_id):
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{API}/tasks/{task_id}/claim", headers=HEADERS, timeout=10)
|
||||||
|
return r.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def submit_result(task_id, result, status="completed"):
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{API}/tasks/{task_id}/submit", headers=HEADERS,
|
||||||
|
json={"result": result, "status": status}, timeout=30)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Submit failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def execute_claude(prompt, work_dir=WORK_DIR, max_turns=10):
|
||||||
|
"""Execute Claude CLI and return output."""
|
||||||
|
cmd = [CLAUDE_BIN, "--dangerously-skip-permissions",
|
||||||
|
"-p", prompt, "--max-turns", str(max_turns)]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=600, cwd=work_dir, errors='replace')
|
||||||
|
output = result.stdout[-10000:] if len(result.stdout) > 10000 else result.stdout
|
||||||
|
if result.returncode != 0 and result.stderr:
|
||||||
|
output += f"\n[STDERR]: {result.stderr[-2000:]}"
|
||||||
|
return output, result.returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "[ERROR] Claude CLI timed out after 600s", False
|
||||||
|
except Exception as e:
|
||||||
|
return f"[ERROR] {str(e)}", False
|
||||||
|
|
||||||
|
|
||||||
|
def process_task(task):
|
||||||
|
task_id = task["id"]
|
||||||
|
title = task.get("title", "")
|
||||||
|
description = task.get("description", "")
|
||||||
|
log.info(f"Processing task #{task_id}: {title}")
|
||||||
|
|
||||||
|
if not claim_task(task_id):
|
||||||
|
log.warning(f"Failed to claim task #{task_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt = f"TASK: {title}\n\nDESCRIPTION:\n{description}\n\nExecute this task and report results."
|
||||||
|
output, success = execute_claude(prompt)
|
||||||
|
|
||||||
|
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
feedback = f"⏰ {ts}\n💻 {HOSTNAME}\n{'🟢' if success else '🔴'} {'Done' if success else 'Failed'}\n\n{output}"
|
||||||
|
|
||||||
|
submit_result(task_id, feedback, "completed" if success else "failed")
|
||||||
|
log.info(f"Task #{task_id} {'completed' if success else 'failed'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info(f"Collab Worker started: {HOSTNAME}, polling every {POLL_INTERVAL}s")
|
||||||
|
if not TOKEN:
|
||||||
|
log.error("COLLAB_TOKEN not set!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
while running:
|
||||||
|
heartbeat()
|
||||||
|
tasks = get_tasks()
|
||||||
|
for task in tasks:
|
||||||
|
if not running:
|
||||||
|
break
|
||||||
|
process_task(task)
|
||||||
|
for _ in range(POLL_INTERVAL):
|
||||||
|
if not running:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
109
db/migrations/001_initial_schema.sql
Normal file
109
db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- Task Team — Initial Schema — 2026-03-29
|
||||||
|
|
||||||
|
-- Extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email VARCHAR(255) UNIQUE,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
auth_provider VARCHAR(50) DEFAULT 'email',
|
||||||
|
auth_provider_id TEXT,
|
||||||
|
language VARCHAR(5) DEFAULT 'cs',
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Task groups
|
||||||
|
CREATE TABLE task_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
color VARCHAR(20) NOT NULL,
|
||||||
|
icon VARCHAR(10),
|
||||||
|
order_index INTEGER DEFAULT 0,
|
||||||
|
time_zones JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tasks
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id UUID REFERENCES task_groups(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
priority VARCHAR(10) DEFAULT 'medium',
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
due_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
assigned_to UUID[],
|
||||||
|
attachments JSONB DEFAULT '[]',
|
||||||
|
external_id TEXT,
|
||||||
|
external_source VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Task comments
|
||||||
|
CREATE TABLE task_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_ai BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Goals
|
||||||
|
CREATE TABLE goals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
target_date TIMESTAMPTZ,
|
||||||
|
progress_pct INTEGER DEFAULT 0,
|
||||||
|
group_id UUID REFERENCES task_groups(id) ON DELETE SET NULL,
|
||||||
|
plan JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Connectors
|
||||||
|
CREATE TABLE connectors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
config JSONB DEFAULT '{}',
|
||||||
|
enabled BOOLEAN DEFAULT true,
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
sync_log JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_tasks_user ON tasks(user_id);
|
||||||
|
CREATE INDEX idx_tasks_group ON tasks(group_id);
|
||||||
|
CREATE INDEX idx_tasks_status ON tasks(status);
|
||||||
|
CREATE INDEX idx_tasks_scheduled ON tasks(scheduled_at);
|
||||||
|
CREATE INDEX idx_task_groups_user ON task_groups(user_id);
|
||||||
|
CREATE INDEX idx_task_comments_task ON task_comments(task_id);
|
||||||
|
CREATE INDEX idx_goals_user ON goals(user_id);
|
||||||
|
|
||||||
|
-- Insert default groups
|
||||||
|
INSERT INTO task_groups (id, user_id, name, color, icon, order_index, time_zones) VALUES
|
||||||
|
(uuid_generate_v4(), NULL, 'Prace', '#3B82F6', 'B', 0, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Nakup', '#10B981', 'S', 1, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Study', '#8B5CF6', 'L', 2, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Plany', '#F59E0B', 'M', 3, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Sport', '#F97316', 'R', 4, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Duchovni', '#D4A017', 'D', 5, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Domaci', '#92400E', 'H', 6, '[]'),
|
||||||
|
(uuid_generate_v4(), NULL, 'Relax', '#06B6D4', 'X', 7, '[]');
|
||||||
Reference in New Issue
Block a user