[taskboard] migrate fleet console to nextjs
This commit is contained in:
268
lib/agents.ts
Normal file
268
lib/agents.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ARCHITECTURE_DOCUMENT,
|
||||
OPENCLAW_AGENTS_DIR,
|
||||
OPENCLAW_CONFIG_PATH,
|
||||
ZEROCLAW_CONTROL_DIR,
|
||||
ZEROCLAW_PRIMARY_DIR,
|
||||
} from "@/lib/fleet-config";
|
||||
import { all } from "@/lib/db";
|
||||
import { normalizeTask } from "@/lib/tasks";
|
||||
import type { AgentStatus, FleetAgent, TaskRecord } from "@/lib/types";
|
||||
|
||||
type OpenClawAgentConfig = {
|
||||
id: string;
|
||||
name?: string;
|
||||
model?: { primary?: string };
|
||||
identity?: { name?: string; emoji?: string; theme?: string };
|
||||
subagents?: { allowAgents?: string[] };
|
||||
};
|
||||
|
||||
type OpenClawConfigShape = {
|
||||
agents?: { list?: OpenClawAgentConfig[] };
|
||||
channels?: {
|
||||
telegram?: {
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
topics?: Record<string, { systemPrompt?: string }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function readTextFile(filePath: string) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function parseBulletValues(content: string) {
|
||||
return content
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("- "))
|
||||
.map((line) => line.replace(/^- /, "").replace(/`/g, "").trim());
|
||||
}
|
||||
|
||||
function parseRoleFromAgentsMd(content: string) {
|
||||
const identityMatch = content.match(/- Scope:\s*(.+)/);
|
||||
if (identityMatch) {
|
||||
return identityMatch[1].trim();
|
||||
}
|
||||
return "Host-scoped agent";
|
||||
}
|
||||
|
||||
function parseResponsibilities(content: string) {
|
||||
const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/);
|
||||
return sectionMatch ? parseBulletValues(sectionMatch[1]) : [];
|
||||
}
|
||||
|
||||
function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
|
||||
const workspaceRoot = path.join(agentRoot, "workspace");
|
||||
const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md"));
|
||||
const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md"));
|
||||
const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md"));
|
||||
const heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.md"));
|
||||
|
||||
const tools = parseBulletValues(toolsMd);
|
||||
const capabilities = parseResponsibilities(agentsMd);
|
||||
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
|
||||
|
||||
return {
|
||||
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) =>
|
||||
fs.existsSync(path.join(workspaceRoot, fileName)),
|
||||
),
|
||||
tools,
|
||||
capabilities,
|
||||
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
|
||||
role: parseRoleFromAgentsMd(agentsMd),
|
||||
noteValues: parseBulletValues(identityMd),
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function readOpenClawConfig(): OpenClawConfigShape {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8")) as OpenClawConfigShape;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenClawChannels(agentId: string, config: OpenClawConfigShape) {
|
||||
const summaries: { label: string; value: string }[] = [
|
||||
{ label: "Family", value: "OpenClaw telegram + gateway" },
|
||||
];
|
||||
|
||||
const topicGroups = config.channels?.telegram?.groups?.["-1003809447066"]?.topics;
|
||||
if (!topicGroups) {
|
||||
return summaries;
|
||||
}
|
||||
|
||||
const topicEntries = Object.entries(topicGroups).filter(([, topic]) => {
|
||||
const prompt = topic.systemPrompt || "";
|
||||
return prompt.toLowerCase().includes(agentId.toLowerCase()) || agentId === "main";
|
||||
});
|
||||
|
||||
if (topicEntries.length === 0 && agentId === "main") {
|
||||
summaries.push({ label: "Forum", value: "Homelab HQ default route" });
|
||||
return summaries;
|
||||
}
|
||||
|
||||
topicEntries.forEach(([topicId]) => {
|
||||
summaries.push({ label: "Topic", value: `Homelab HQ topic ${topicId}` });
|
||||
});
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function fetchTaskBuckets(aliases: string[]) {
|
||||
const placeholders = aliases.map(() => "?").join(", ");
|
||||
const activeRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
|
||||
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status IN ('Todo', 'In Progress', 'Review')
|
||||
ORDER BY priority DESC, created_at ASC`,
|
||||
aliases,
|
||||
);
|
||||
const completedRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
|
||||
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status = 'Done'
|
||||
ORDER BY completed_at DESC LIMIT 5`,
|
||||
aliases,
|
||||
);
|
||||
|
||||
return {
|
||||
activeTasks: activeRows.map(normalizeTask),
|
||||
completedTasks: completedRows.map(normalizeTask),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildOpenClawAgents() {
|
||||
const config = readOpenClawConfig();
|
||||
const agents = config.agents?.list || [];
|
||||
|
||||
return Promise.all(
|
||||
agents.map(async (agentConfig) => {
|
||||
const agentRoot = path.join(OPENCLAW_AGENTS_DIR, agentConfig.id);
|
||||
const workspace = readWorkspaceAgent(agentRoot, agentConfig.identity?.name || agentConfig.id);
|
||||
const aliases = [
|
||||
agentConfig.id,
|
||||
agentConfig.identity?.name || agentConfig.name || agentConfig.id,
|
||||
];
|
||||
const taskBuckets = await fetchTaskBuckets(aliases);
|
||||
|
||||
return {
|
||||
slug: agentConfig.id,
|
||||
assignmentKey: agentConfig.id,
|
||||
aliases,
|
||||
family: "openclaw",
|
||||
name: agentConfig.identity?.name || agentConfig.name || agentConfig.id,
|
||||
host: "ubuntu",
|
||||
role: agentConfig.identity?.theme || workspace.role,
|
||||
runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR,
|
||||
configPath: OPENCLAW_CONFIG_PATH,
|
||||
model: agentConfig.model?.primary || null,
|
||||
emoji: agentConfig.identity?.emoji || "🦞",
|
||||
channels: getOpenClawChannels(agentConfig.id, config),
|
||||
tools: workspace.tools,
|
||||
capabilities: workspace.capabilities,
|
||||
files: workspace.files,
|
||||
status: deriveStatus(taskBuckets.activeTasks.length),
|
||||
workload: taskBuckets.activeTasks.length,
|
||||
activeTasks: taskBuckets.activeTasks,
|
||||
completedTasks: taskBuckets.completedTasks,
|
||||
currentTask: workspace.currentTask,
|
||||
notes: workspace.noteValues,
|
||||
} satisfies FleetAgent;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildZeroClawAgents() {
|
||||
const configuredAgents = [
|
||||
{
|
||||
slug: "grizzley-zeroclaw",
|
||||
assignmentKey: "grizzley-zeroclaw",
|
||||
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
|
||||
name: "ZeroClaw Grizzley",
|
||||
host: "grizzley",
|
||||
role: "Edge host operator for grizzley",
|
||||
runtimePath: ZEROCLAW_PRIMARY_DIR,
|
||||
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
|
||||
model: "glm-4.7",
|
||||
emoji: "🛰️",
|
||||
channels: [
|
||||
{ label: "Gateway", value: "HTTP gateway :3000" },
|
||||
{ label: "Access", value: "paired remote gateway via ice" },
|
||||
],
|
||||
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
|
||||
},
|
||||
{
|
||||
slug: "ice-zeroclaw",
|
||||
assignmentKey: "ice-zeroclaw",
|
||||
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
|
||||
name: "ZeroClaw Ice",
|
||||
host: "ice",
|
||||
role: "Control-plane operator for ice",
|
||||
runtimePath: ZEROCLAW_CONTROL_DIR,
|
||||
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
|
||||
model: "glm-5",
|
||||
emoji: "🧊",
|
||||
channels: [
|
||||
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
|
||||
{ label: "Gateway", value: "paired webhook + status routing" },
|
||||
],
|
||||
notes: ["Control-plane runtime and topic router for remote host delegation."],
|
||||
},
|
||||
];
|
||||
|
||||
return Promise.all(
|
||||
configuredAgents.map(async (configuredAgent) => {
|
||||
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
|
||||
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
|
||||
return {
|
||||
...configuredAgent,
|
||||
family: "zeroclaw" as const,
|
||||
tools: workspace.tools,
|
||||
capabilities: workspace.capabilities,
|
||||
files: workspace.files,
|
||||
status: deriveStatus(taskBuckets.activeTasks.length),
|
||||
workload: taskBuckets.activeTasks.length,
|
||||
activeTasks: taskBuckets.activeTasks,
|
||||
completedTasks: taskBuckets.completedTasks,
|
||||
currentTask: workspace.currentTask,
|
||||
notes: [...configuredAgent.notes, ...workspace.noteValues],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listFleetAgents() {
|
||||
const [openclawAgents, zeroclawAgents] = await Promise.all([
|
||||
buildOpenClawAgents(),
|
||||
buildZeroClawAgents(),
|
||||
]);
|
||||
|
||||
return [...openclawAgents, ...zeroclawAgents];
|
||||
}
|
||||
|
||||
export async function listArchitecture() {
|
||||
const agents = await listFleetAgents();
|
||||
return {
|
||||
...ARCHITECTURE_DOCUMENT,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: ARCHITECTURE_DOCUMENT.sections.map((section) => ({
|
||||
...section,
|
||||
configuredAgents: agents
|
||||
.filter((agent) => agent.family === section.id)
|
||||
.map((agent) => `${agent.name} (${agent.host})`),
|
||||
})),
|
||||
};
|
||||
}
|
||||
function deriveStatus(activeTaskCount: number): AgentStatus {
|
||||
return activeTaskCount > 0 ? "busy" : "active";
|
||||
}
|
||||
86
lib/db.ts
Normal file
86
lib/db.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import sqlite3 from "sqlite3";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "tasks.db");
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
let database: sqlite3.Database | null = null;
|
||||
|
||||
function getDatabase() {
|
||||
if (database) {
|
||||
return database;
|
||||
}
|
||||
|
||||
database = new sqlite3.Database(DB_PATH);
|
||||
database.serialize(() => {
|
||||
database?.run(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
assignee TEXT DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT 'Medium',
|
||||
status TEXT NOT NULL DEFAULT 'Backlog',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
`);
|
||||
database?.run(`
|
||||
CREATE TABLE IF NOT EXISTS usage_tracking (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_type TEXT DEFAULT 'chat',
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
cost_estimate REAL DEFAULT 0,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
export function all<T>(sql: string, params: unknown[] = []) {
|
||||
const db = getDatabase();
|
||||
return new Promise<T[]>((resolve, reject) => {
|
||||
db.all(sql, params, (error, rows) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(rows as T[]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get<T>(sql: string, params: unknown[] = []) {
|
||||
const db = getDatabase();
|
||||
return new Promise<T | undefined>((resolve, reject) => {
|
||||
db.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row as T | undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function run(sql: string, params: unknown[] = []) {
|
||||
const db = getDatabase();
|
||||
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
|
||||
db.run(sql, params, function onRun(error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ lastID: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
124
lib/fleet-config.ts
Normal file
124
lib/fleet-config.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ArchitectureDocument } from "@/lib/types";
|
||||
|
||||
export const OPENCLAW_RUNTIME_ROOT =
|
||||
process.env.OPENCLAW_RUNTIME_ROOT || "/home/bear/.openclaw";
|
||||
export const OPENCLAW_AGENTS_DIR =
|
||||
process.env.AGENTS_DIR || "/home/bear/.openclaw/agents";
|
||||
export const OPENCLAW_CONFIG_PATH =
|
||||
process.env.OPENCLAW_CONFIG || "/home/bear/.openclaw/openclaw.json";
|
||||
export const ZEROCLAW_PRIMARY_DIR =
|
||||
process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
|
||||
export const ZEROCLAW_CONTROL_DIR =
|
||||
process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
|
||||
|
||||
export const ARCHITECTURE_DOCUMENT: ArchitectureDocument = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
title: "Claw Fleet Architecture",
|
||||
overview: [
|
||||
"OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.",
|
||||
"ZeroClaw provides host-scoped remote administration on grizzley and ice.",
|
||||
"Task assignment is shared across both families in a single board.",
|
||||
],
|
||||
topologyDiagram: String.raw`
|
||||
Telegram / Forum Topics
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
v v
|
||||
OpenClaw gateway ZeroClaw control
|
||||
ubuntu :18789 ice zeroclaw-admin
|
||||
local swarm topic router / paired gateway
|
||||
| |
|
||||
+------------+--------------------+
|
||||
|
|
||||
v
|
||||
shared taskboard UI
|
||||
|
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
OpenClaw agents ZeroClaw runtimes
|
||||
ubuntu-local swarm grizzley / ice
|
||||
`,
|
||||
sections: [
|
||||
{
|
||||
id: "openclaw",
|
||||
title: "OpenClaw",
|
||||
summary:
|
||||
"Primary orchestration family on ubuntu. Owns local swarm execution, HQ Telegram bindings, and ubuntu-host workflows.",
|
||||
runtime: [
|
||||
{ label: "Host", value: "ubuntu (192.168.50.61)" },
|
||||
{ label: "Service", value: "openclaw.service" },
|
||||
{ label: "Runtime", value: "/srv/state/openclaw/current" },
|
||||
{ label: "Config", value: "/home/bear/.openclaw/openclaw.json" },
|
||||
],
|
||||
channels: [
|
||||
{ label: "Telegram DM", value: "allowlist: tg:5512934365" },
|
||||
{ label: "Forum Group", value: "Homelab HQ (-1003809447066)" },
|
||||
{ label: "Gateway", value: "LAN bind :18789 with token auth" },
|
||||
],
|
||||
configuredAgents: [
|
||||
"main",
|
||||
"ubuntu",
|
||||
"docs",
|
||||
"gitea-admin",
|
||||
"planner",
|
||||
"builder",
|
||||
"reviewer",
|
||||
],
|
||||
diagram: String.raw`
|
||||
OpenClaw HQ topics
|
||||
topic 2 -> ubuntu
|
||||
topic 3 -> docs
|
||||
topic 4 -> gitea-admin
|
||||
topics 5-9 -> main, then delegate to host-scoped ZeroClaw paths
|
||||
|
||||
main
|
||||
|- ubuntu
|
||||
|- docs
|
||||
|- gitea-admin
|
||||
|- planner
|
||||
|- builder
|
||||
\- reviewer
|
||||
`,
|
||||
notes: [
|
||||
"Remote host personas were removed from OpenClaw.",
|
||||
"OpenClaw remains gateway-only on ubuntu.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "zeroclaw",
|
||||
title: "ZeroClaw",
|
||||
summary:
|
||||
"Host-scoped runtime family for remote administration. Grizzley is the primary active gateway. Ice is the control-plane runtime and topic router.",
|
||||
runtime: [
|
||||
{ label: "Primary", value: "/srv/state/zeroclaw/current on grizzley" },
|
||||
{ label: "Control", value: "/home/bear/.zeroclaw-admin on ice" },
|
||||
{ label: "Primary Service", value: "zeroclaw.service" },
|
||||
{ label: "Control Service", value: "zeroclaw-admin.service" },
|
||||
],
|
||||
channels: [
|
||||
{ label: "Grizzley Gateway", value: "HTTP gateway :3000, pairing required" },
|
||||
{ label: "Ice Telegram", value: "Homelab-Ice (-1003728617160)" },
|
||||
{ label: "Remote Routing", value: "paired status/webhook to grizzley and pve" },
|
||||
],
|
||||
configuredAgents: ["grizzley-zeroclaw", "ice-zeroclaw"],
|
||||
diagram: String.raw`
|
||||
Homelab-Ice topics
|
||||
11 -> local ice operations
|
||||
12 -> grizzley paired gateway
|
||||
13 -> pve paired gateway
|
||||
14 -> truenas blocker message
|
||||
15 -> panda rollout pending
|
||||
|
||||
ice zeroclaw-admin
|
||||
-> zeroclaw-remote-gateway.sh status grizzley|pve
|
||||
-> zeroclaw-remote-gateway.sh webhook grizzley|pve "<message>"
|
||||
`,
|
||||
notes: [
|
||||
"Grizzley is host-scoped and should not proxy other hosts directly.",
|
||||
"Ice still uses host-local secret/encryption state under /home/bear/.zeroclaw-admin.",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
162
lib/tasks.ts
Normal file
162
lib/tasks.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { all, get, run } from "@/lib/db";
|
||||
import type { TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
|
||||
|
||||
const VALID_STATUSES: TaskStatus[] = [
|
||||
"Backlog",
|
||||
"Todo",
|
||||
"In Progress",
|
||||
"Review",
|
||||
"Done",
|
||||
];
|
||||
|
||||
const VALID_PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
|
||||
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
|
||||
|
||||
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
||||
|
||||
type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string };
|
||||
|
||||
export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
tags = JSON.parse(row.tags || "[]");
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTasks() {
|
||||
const rows = await all<DatabaseTaskRow>("SELECT * FROM tasks ORDER BY id DESC");
|
||||
return rows.map(normalizeTask);
|
||||
}
|
||||
|
||||
export async function findTask(id: number) {
|
||||
const row = await get<DatabaseTaskRow>("SELECT * FROM tasks WHERE id = ?", [id]);
|
||||
return row ? normalizeTask(row) : null;
|
||||
}
|
||||
|
||||
export function validateTaskPayload(
|
||||
payload: Partial<TaskRecord> & { tags?: unknown },
|
||||
partial = false,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!partial || payload.title !== undefined) {
|
||||
if (typeof payload.title !== "string" || payload.title.trim().length === 0) {
|
||||
errors.push("title is required");
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.status !== undefined && !VALID_STATUSES.includes(payload.status)) {
|
||||
errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (payload.priority !== undefined && !VALID_PRIORITIES.includes(payload.priority)) {
|
||||
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (payload.tags !== undefined && !Array.isArray(payload.tags)) {
|
||||
errors.push("tags must be an array of strings");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function buildWikiMarkdown(task: TaskRecord) {
|
||||
const renderedTags = task.tags.length ? task.tags.join(", ") : "None";
|
||||
return `# ${task.title}
|
||||
|
||||
- Task ID: ${task.id}
|
||||
- Assignee: ${task.assignee || "Unassigned"}
|
||||
- Priority: ${task.priority}
|
||||
- Status: ${task.status}
|
||||
- Tags: ${renderedTags}
|
||||
- Created: ${task.created_at}
|
||||
- Completed: ${task.completed_at || new Date().toISOString()}
|
||||
|
||||
## Description
|
||||
|
||||
${task.description || "No description provided."}
|
||||
`;
|
||||
}
|
||||
|
||||
async function writeWikiForTask(task: TaskRecord) {
|
||||
const safeTitle = task.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle || `task-${task.id}`}.md`;
|
||||
fs.writeFileSync(path.join(WIKI_DIR, fileName), buildWikiMarkdown(task), "utf8");
|
||||
}
|
||||
|
||||
export async function createTask(input: Partial<TaskRecord>) {
|
||||
const result = await run(
|
||||
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
input.title?.trim() || "",
|
||||
input.description || "",
|
||||
input.assignee || "",
|
||||
input.priority || "Medium",
|
||||
input.status || "Backlog",
|
||||
JSON.stringify(Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : []),
|
||||
],
|
||||
);
|
||||
|
||||
const task = await findTask(result.lastID);
|
||||
if (!task) {
|
||||
throw new Error("failed_to_fetch_created_task");
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function updateTask(id: number, input: Partial<TaskRecord>) {
|
||||
const existing = await findTask(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextStatus = input.status ?? existing.status;
|
||||
const completedAt =
|
||||
nextStatus === "Done"
|
||||
? existing.completed_at || new Date().toISOString()
|
||||
: null;
|
||||
|
||||
await run(
|
||||
`UPDATE tasks
|
||||
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
|
||||
completed_at = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[
|
||||
input.title?.trim() || existing.title,
|
||||
input.description ?? existing.description,
|
||||
input.assignee ?? existing.assignee,
|
||||
input.priority ?? existing.priority,
|
||||
nextStatus,
|
||||
JSON.stringify(input.tags ?? existing.tags),
|
||||
completedAt,
|
||||
id,
|
||||
],
|
||||
);
|
||||
|
||||
const updated = await findTask(id);
|
||||
if (!updated) {
|
||||
throw new Error("failed_to_fetch_updated_task");
|
||||
}
|
||||
|
||||
if (nextStatus === "Done" && existing.status !== "Done") {
|
||||
await writeWikiForTask(updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
84
lib/types.ts
Normal file
84
lib/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
|
||||
export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
|
||||
export type AgentFamily = "openclaw" | "zeroclaw";
|
||||
export type AgentStatus = "active" | "busy" | "idle";
|
||||
|
||||
export type TaskRecord = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
assignee: string;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type WikiPageSummary = {
|
||||
filename: string;
|
||||
title: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type WikiPage = {
|
||||
filename: string;
|
||||
content: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
tags: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentRouteSummary = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type FleetAgent = {
|
||||
slug: string;
|
||||
assignmentKey: string;
|
||||
aliases: string[];
|
||||
family: AgentFamily;
|
||||
name: string;
|
||||
host: string;
|
||||
role: string;
|
||||
runtimePath: string;
|
||||
configPath: string | null;
|
||||
model: string | null;
|
||||
emoji: string;
|
||||
channels: AgentRouteSummary[];
|
||||
tools: string[];
|
||||
capabilities: string[];
|
||||
files: string[];
|
||||
status: AgentStatus;
|
||||
workload: number;
|
||||
activeTasks: TaskRecord[];
|
||||
completedTasks: TaskRecord[];
|
||||
currentTask: string | null;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type FleetSection = {
|
||||
id: AgentFamily;
|
||||
title: string;
|
||||
summary: string;
|
||||
runtime: AgentRouteSummary[];
|
||||
channels: AgentRouteSummary[];
|
||||
configuredAgents: string[];
|
||||
diagram: string;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type ArchitectureDocument = {
|
||||
generatedAt: string;
|
||||
title: string;
|
||||
overview: string[];
|
||||
sections: FleetSection[];
|
||||
topologyDiagram: string;
|
||||
};
|
||||
29
lib/utils.ts
Normal file
29
lib/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
130
lib/wiki.ts
Normal file
130
lib/wiki.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WikiPage, WikiPageSummary } from "@/lib/types";
|
||||
|
||||
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
|
||||
|
||||
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
||||
|
||||
function assertSafeFilename(filename: string) {
|
||||
if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
|
||||
throw new Error("invalid_filename");
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetadata(content: string) {
|
||||
const metadata = {
|
||||
title: "",
|
||||
created: "",
|
||||
modified: "",
|
||||
tags: [] as string[],
|
||||
};
|
||||
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
|
||||
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
|
||||
if (titleMatch) {
|
||||
metadata.title = titleMatch[1].trim();
|
||||
}
|
||||
if (tagsMatch) {
|
||||
metadata.tags = tagsMatch[1].split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.title) {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (headingMatch) {
|
||||
metadata.title = headingMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function listWikiPages(): WikiPageSummary[] {
|
||||
if (!fs.existsSync(WIKI_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(WIKI_DIR)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((filename) => {
|
||||
const filePath = path.join(WIKI_DIR, filename);
|
||||
const stats = fs.statSync(filePath);
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const metadata = extractMetadata(content);
|
||||
return {
|
||||
filename,
|
||||
title: metadata.title || filename.replace(".md", "").replace(/-/g, " "),
|
||||
created: stats.birthtime.toISOString(),
|
||||
modified: stats.mtime.toISOString(),
|
||||
tags: metadata.tags,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => new Date(right.modified).getTime() - new Date(left.modified).getTime());
|
||||
}
|
||||
|
||||
export function readWikiPage(filename: string): WikiPage | null {
|
||||
assertSafeFilename(filename);
|
||||
const filePath = path.join(WIKI_DIR, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const stats = fs.statSync(filePath);
|
||||
const metadata = extractMetadata(content);
|
||||
|
||||
return {
|
||||
filename,
|
||||
content,
|
||||
metadata: {
|
||||
title: metadata.title || filename.replace(".md", ""),
|
||||
created: stats.birthtime.toISOString(),
|
||||
modified: stats.mtime.toISOString(),
|
||||
tags: metadata.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWikiPage(title: string) {
|
||||
const safeTitle = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
let filename = `${timestamp}-${safeTitle}.md`;
|
||||
let counter = 1;
|
||||
|
||||
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
|
||||
filename = `${timestamp}-${safeTitle}-${counter}.md`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
const content = `# ${title}
|
||||
|
||||
## Summary
|
||||
|
||||
Document the architecture, deployment notes, or runbook here.
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
|
||||
return filename;
|
||||
}
|
||||
|
||||
export function updateWikiPage(filename: string, content: string) {
|
||||
assertSafeFilename(filename);
|
||||
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
|
||||
}
|
||||
|
||||
export function deleteWikiPage(filename: string) {
|
||||
assertSafeFilename(filename);
|
||||
fs.unlinkSync(path.join(WIKI_DIR, filename));
|
||||
}
|
||||
Reference in New Issue
Block a user