feat(lib-core): biblioteca atomica @pulse-libs/core v1.0.0-beta.1
Esta commit conteudo a estrutura atomica completa:
- types: Result<T,E>, AsyncState<T>, Paginated<T>, SortConfig<T>
- utils: date, str, num, cn, debounce, throttle, storage, arr, obj
- validators: Zod schemas — email, password, uuid, url, phone, CPF/CNPJ, sanitizedStr, safeParse
- hooks: useToggle, useAsync, useDebounce, useLocalStorage, useMedia, useInterval, useOnClickOutside, useClipboard, useFetch
- components: Button, Input, Alert, Card, Spinner (atomic design pattern)
- build: tsup v8 ESM+CJS + DTS + sourcemaps — 0 erros
- tests: 57 testes 100% usuarios
- docker: multi-stage Dockerfile (node 20-alpine)
- config: vitest, tsup, tsconfig strict, .npmignore
Filosofia atomica:/utils ← /types ← /validators ← /hooks ← /components
Build: npm run build | Test: npm test | Publish: npm publish
🤖 Generated with Pulse (openclaw + nova-self-improver)
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* task-sync.mjs -- Sync task markdown files to/from SQLite
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/task-sync.mjs files-to-db # parse markdown, update DB
|
||||
* node scripts/task-sync.mjs check # detect drift, exit 1 if mismatch
|
||||
* node scripts/task-sync.mjs db-to-files # project DB state into markdown
|
||||
*
|
||||
* Environment:
|
||||
* OPENCLAW_WORKSPACE Path to the workspace root (defaults to process.cwd())
|
||||
*/
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
// === PATHS (taskflow-001: portable, no hardcoded paths) ===
|
||||
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
|
||||
const dbPath = path.join(workspace, 'memory', 'taskflow.sqlite')
|
||||
const tasksDir = path.join(workspace, 'tasks')
|
||||
|
||||
const mode = process.argv[2] || 'check'
|
||||
if (!['files-to-db', 'check', 'db-to-files'].includes(mode)) {
|
||||
console.error('Usage: node scripts/task-sync.mjs [files-to-db|check|db-to-files]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// === STARTUP CHECKS (taskflow-020: clear errors for missing paths) ===
|
||||
if (!existsSync(workspace)) {
|
||||
console.error(`ERROR: Workspace directory not found: ${workspace}`)
|
||||
console.error('Set OPENCLAW_WORKSPACE to your workspace root and try again.')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(tasksDir)) {
|
||||
console.error(`ERROR: Tasks directory not found: ${tasksDir}`)
|
||||
console.error(`Create it with: mkdir -p "${tasksDir}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(dbPath)) {
|
||||
console.error(`ERROR: Database file not found: ${dbPath}`)
|
||||
console.error('Run init-db.mjs first to create the database schema.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
|
||||
// === LOCK (taskflow-002: atomic single-UPDATE acquisition) ===
|
||||
let lockAcquired = false
|
||||
|
||||
function acquireLock() {
|
||||
const owner = `task-sync:${mode}`
|
||||
const until = new Date(Date.now() + 60_000).toISOString() // 60s TTL
|
||||
|
||||
// Ensure the singleton row exists
|
||||
db.exec(`INSERT OR IGNORE INTO sync_state (id, lock_owner, lock_until) VALUES (1, NULL, NULL)`)
|
||||
|
||||
// Single atomic UPDATE: succeeds only when no valid lock is held
|
||||
const result = db.prepare(`
|
||||
UPDATE sync_state
|
||||
SET lock_owner = ?, lock_until = ?
|
||||
WHERE id = 1
|
||||
AND (lock_owner IS NULL OR lock_until < datetime('now'))
|
||||
`).run(owner, until)
|
||||
|
||||
if (result.changes === 0) {
|
||||
const row = db.prepare('SELECT lock_owner, lock_until FROM sync_state WHERE id = 1').get()
|
||||
console.error(`Sync locked by ${row?.lock_owner} until ${row?.lock_until}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
lockAcquired = true
|
||||
}
|
||||
|
||||
function releaseLock(result) {
|
||||
const now = new Date().toISOString()
|
||||
db.prepare(
|
||||
'UPDATE sync_state SET lock_owner = NULL, lock_until = NULL, last_sync_at = ?, last_result = ? WHERE id = 1'
|
||||
).run(now, result)
|
||||
lockAcquired = false
|
||||
}
|
||||
|
||||
// === SIGNAL HANDLERS (taskflow-019: release lock on exit) ===
|
||||
function handleSignal(signal) {
|
||||
if (lockAcquired) {
|
||||
try { releaseLock(`interrupted: ${signal}`) } catch (_) {}
|
||||
}
|
||||
process.exit(signal === 'SIGTERM' ? 0 : 130)
|
||||
}
|
||||
process.on('SIGTERM', () => handleSignal('SIGTERM'))
|
||||
process.on('SIGINT', () => handleSignal('SIGINT'))
|
||||
|
||||
// === PARSE MARKDOWN ===
|
||||
const STATUS_MAP = {
|
||||
'in progress': 'in_progress',
|
||||
'pending validation': 'pending_validation',
|
||||
'backlog': 'backlog',
|
||||
'done': 'done',
|
||||
'blocked': 'blocked',
|
||||
}
|
||||
|
||||
const TASK_RE = /^- \[([ x])\] \(task:([a-z0-9-]+)\)\s*(?:\[([^\]]*)\])?\s*(?:\[([^\]]*)\])?\s*(.+)$/
|
||||
|
||||
function parseTaskFile(filePath) {
|
||||
const content = readFileSync(filePath, 'utf8')
|
||||
const lines = content.split(/\r?\n/)
|
||||
const tasks = []
|
||||
let currentStatus = null
|
||||
const slug = path.basename(filePath).replace(/-tasks\.md$/, '')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Section header
|
||||
const headerMatch = line.match(/^## (.+)$/)
|
||||
if (headerMatch) {
|
||||
const normalized = headerMatch[1].trim().toLowerCase()
|
||||
currentStatus = STATUS_MAP[normalized] || null
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentStatus) continue
|
||||
|
||||
const taskMatch = line.match(TASK_RE)
|
||||
if (taskMatch) {
|
||||
const [, , id, tag1, tag2, title] = taskMatch
|
||||
|
||||
// Parse priority and model from tags
|
||||
let priority = 'P2', model = null
|
||||
for (const tag of [tag1, tag2]) {
|
||||
if (!tag) continue
|
||||
if (/^P\d$/.test(tag)) priority = tag
|
||||
else model = tag
|
||||
}
|
||||
|
||||
// Check for note on next line
|
||||
let notes = null
|
||||
if (i + 1 < lines.length) {
|
||||
const noteMatch = lines[i + 1].match(/^\s+- note:\s*(.+)$/)
|
||||
if (noteMatch) notes = noteMatch[1].trim()
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
id,
|
||||
project_id: slug,
|
||||
title: title.trim(),
|
||||
status: currentStatus,
|
||||
priority,
|
||||
owner_model: model,
|
||||
notes,
|
||||
source_file: `tasks/${slug}-tasks.md`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
function parseAllFiles() {
|
||||
const files = readdirSync(tasksDir).filter(f => f.endsWith('-tasks.md'))
|
||||
const allTasks = []
|
||||
for (const f of files) {
|
||||
allTasks.push(...parseTaskFile(path.join(tasksDir, f)))
|
||||
}
|
||||
return allTasks
|
||||
}
|
||||
|
||||
// === DB READ ===
|
||||
function getDbTasks() {
|
||||
return db.prepare(
|
||||
'SELECT id, project_id, title, status, priority, owner_model, notes, source_file FROM tasks_v2 ORDER BY id'
|
||||
).all()
|
||||
}
|
||||
|
||||
// === HASH (taskflow-012: include notes field) ===
|
||||
function hashTasks(tasks) {
|
||||
const sorted = [...tasks].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const data = sorted
|
||||
.map(t => `${t.id}|${t.status}|${t.priority}|${t.owner_model || ''}|${t.title}|${t.notes || ''}`)
|
||||
.join('\n')
|
||||
return createHash('sha256').update(data).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
// === DIFF ===
|
||||
function diffTasks(fileTasks, dbTasks) {
|
||||
const fileMap = new Map(fileTasks.map(t => [t.id, t]))
|
||||
const dbMap = new Map(dbTasks.map(t => [t.id, t]))
|
||||
const diffs = []
|
||||
|
||||
for (const [id, ft] of fileMap) {
|
||||
const dt = dbMap.get(id)
|
||||
if (!dt) {
|
||||
diffs.push({ type: 'file_only', id, task: ft })
|
||||
} else {
|
||||
const changes = []
|
||||
if (ft.status !== dt.status) changes.push(`status: ${dt.status} → ${ft.status}`)
|
||||
if (ft.priority !== dt.priority) changes.push(`priority: ${dt.priority} → ${ft.priority}`)
|
||||
if ((ft.owner_model || null) !== (dt.owner_model || null)) changes.push(`model: ${dt.owner_model} → ${ft.owner_model}`)
|
||||
if (ft.title !== dt.title) changes.push('title changed')
|
||||
if (changes.length) diffs.push({ type: 'changed', id, changes, fileTask: ft, dbTask: dt })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id] of dbMap) {
|
||||
if (!fileMap.has(id)) {
|
||||
diffs.push({ type: 'db_only', id, task: dbMap.get(id) })
|
||||
}
|
||||
}
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
// === WRITE TO DB ===
|
||||
function syncFilesToDb(fileTasks, dbTasks, diffs) {
|
||||
// Auto-create projects referenced in files but missing from the projects table.
|
||||
const knownProjects = new Set(db.prepare('SELECT id FROM projects').all().map(r => r.id))
|
||||
const upsertProject = db.prepare(`
|
||||
INSERT OR IGNORE INTO projects (id, name, description, status, source_file)
|
||||
VALUES (?, ?, '', 'active', ?)
|
||||
`)
|
||||
const missingProjects = new Set()
|
||||
|
||||
for (const t of fileTasks) {
|
||||
if (!knownProjects.has(t.project_id) && !missingProjects.has(t.project_id)) {
|
||||
missingProjects.add(t.project_id)
|
||||
const name = t.project_id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
upsertProject.run(t.project_id, name, `tasks/${t.project_id}-tasks.md`)
|
||||
console.log(` Auto-created missing project: ${t.project_id} ("${name}")`)
|
||||
}
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO tasks_v2 (id, project_id, title, status, priority, owner_model, notes, source_file, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
priority = excluded.priority,
|
||||
owner_model = excluded.owner_model,
|
||||
notes = COALESCE(excluded.notes, tasks_v2.notes),
|
||||
source_file = excluded.source_file,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
`)
|
||||
const insertTransition = db.prepare(`
|
||||
INSERT INTO task_transitions_v2 (task_id, from_status, to_status, reason, actor)
|
||||
VALUES (?, ?, ?, ?, 'sync')
|
||||
`)
|
||||
|
||||
let updated = 0, added = 0
|
||||
|
||||
for (const diff of diffs) {
|
||||
if (diff.type === 'file_only') {
|
||||
const t = diff.task
|
||||
upsert.run(t.id, t.project_id, t.title, t.status, t.priority, t.owner_model, t.notes, t.source_file)
|
||||
insertTransition.run(t.id, null, t.status, 'added via file sync')
|
||||
added++
|
||||
} else if (diff.type === 'changed') {
|
||||
const t = diff.fileTask
|
||||
upsert.run(t.id, t.project_id, t.title, t.status, t.priority, t.owner_model, t.notes, t.source_file)
|
||||
if (diff.dbTask.status !== t.status) {
|
||||
insertTransition.run(t.id, diff.dbTask.status, t.status, `file sync: ${diff.changes.join(', ')}`)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
// db_only tasks stay in DB (no silent drops)
|
||||
}
|
||||
|
||||
// Update hashes
|
||||
const allFileTasks = parseAllFiles()
|
||||
const allDbTasks = getDbTasks()
|
||||
db.prepare('UPDATE sync_state SET files_hash = ?, db_hash = ? WHERE id = 1').run(
|
||||
hashTasks(allFileTasks), hashTasks(allDbTasks)
|
||||
)
|
||||
|
||||
return { added, updated, dbOnly: diffs.filter(d => d.type === 'db_only').length }
|
||||
}
|
||||
|
||||
// === WRITE TO FILES (taskflow-003: blocked is first-class) ===
|
||||
const STATUS_ORDER = ['in_progress', 'pending_validation', 'blocked', 'backlog', 'done']
|
||||
const STATUS_HEADERS = {
|
||||
in_progress: 'In Progress',
|
||||
pending_validation: 'Pending Validation',
|
||||
blocked: 'Blocked',
|
||||
backlog: 'Backlog',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
function projectDbToFiles(dbTasks) {
|
||||
const byProject = new Map()
|
||||
for (const t of dbTasks) {
|
||||
if (!byProject.has(t.project_id)) byProject.set(t.project_id, [])
|
||||
byProject.get(t.project_id).push(t)
|
||||
}
|
||||
|
||||
// Preserve existing file headers (everything before the first ## section)
|
||||
const files = readdirSync(tasksDir).filter(f => f.endsWith('-tasks.md'))
|
||||
const headers = new Map()
|
||||
for (const f of files) {
|
||||
const content = readFileSync(path.join(tasksDir, f), 'utf8')
|
||||
const slug = f.replace(/-tasks\.md$/, '')
|
||||
const lines = content.split(/\r?\n/)
|
||||
const headerLines = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('## ')) break
|
||||
headerLines.push(line)
|
||||
}
|
||||
headers.set(slug, headerLines.join('\n').trimEnd())
|
||||
}
|
||||
|
||||
// All project slugs from DB
|
||||
const slugs = db.prepare('SELECT id FROM projects ORDER BY id').all().map(r => r.id)
|
||||
let filesWritten = 0
|
||||
|
||||
for (const slug of slugs) {
|
||||
const tasks = byProject.get(slug) || []
|
||||
const header = headers.get(slug) || `# Tasks: ${slug}`
|
||||
|
||||
let md = header + '\n\n'
|
||||
for (const section of STATUS_ORDER) {
|
||||
md += `## ${STATUS_HEADERS[section]}\n`
|
||||
const sectionTasks = tasks
|
||||
.filter(t => t.status === section)
|
||||
.sort((a, b) => a.priority.localeCompare(b.priority) || a.id.localeCompare(b.id))
|
||||
|
||||
if (sectionTasks.length === 0) {
|
||||
md += '\n'
|
||||
} else {
|
||||
for (const t of sectionTasks) {
|
||||
const checkbox = t.status === 'done' ? '[x]' : '[ ]'
|
||||
const modelTag = t.owner_model ? ` [${t.owner_model}]` : ''
|
||||
md += `- ${checkbox} (task:${t.id}) [${t.priority}]${modelTag} ${t.title}\n`
|
||||
if (t.notes) md += ` - note: ${t.notes}\n`
|
||||
}
|
||||
md += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(path.join(tasksDir, `${slug}-tasks.md`), md)
|
||||
filesWritten++
|
||||
}
|
||||
|
||||
return { filesWritten }
|
||||
}
|
||||
|
||||
// === MAIN ===
|
||||
try {
|
||||
if (mode !== 'check') acquireLock()
|
||||
|
||||
const fileTasks = parseAllFiles()
|
||||
const dbTasks = getDbTasks()
|
||||
const diffs = diffTasks(fileTasks, dbTasks)
|
||||
|
||||
if (mode === 'check') {
|
||||
const fileHash = hashTasks(fileTasks)
|
||||
const dbHash = hashTasks(dbTasks)
|
||||
if (diffs.length === 0) {
|
||||
console.log(`OK: ${fileTasks.length} tasks in sync (hash: ${fileHash})`)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.log(`DRIFT DETECTED: ${diffs.length} differences`)
|
||||
console.log(` File hash: ${fileHash}`)
|
||||
console.log(` DB hash: ${dbHash}`)
|
||||
for (const d of diffs) {
|
||||
if (d.type === 'file_only') console.log(` + ${d.id} (in files, not DB)`)
|
||||
else if (d.type === 'db_only') console.log(` - ${d.id} (in DB, not files)`)
|
||||
else console.log(` ~ ${d.id}: ${d.changes.join(', ')}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (mode === 'files-to-db') {
|
||||
const result = syncFilesToDb(fileTasks, dbTasks, diffs)
|
||||
console.log(`Synced files → DB: ${result.added} added, ${result.updated} updated, ${result.dbOnly} DB-only preserved`)
|
||||
releaseLock('ok')
|
||||
} else if (mode === 'db-to-files') {
|
||||
const freshDbTasks = getDbTasks()
|
||||
const result = projectDbToFiles(freshDbTasks)
|
||||
console.log(`Projected DB → files: ${result.filesWritten} files written`)
|
||||
releaseLock('ok')
|
||||
}
|
||||
} catch (err) {
|
||||
if (mode !== 'check') {
|
||||
try { releaseLock(`failed: ${err.message}`) } catch (_) {}
|
||||
}
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user