optimal-cli 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/.gitkeep +0 -0
- package/agents/content-ops.md +227 -0
- package/agents/financial-ops.md +184 -0
- package/agents/infra-ops.md +206 -0
- package/agents/profiles.json +5 -0
- package/dist/bin/optimal.d.ts +1 -1
- package/dist/bin/optimal.js +706 -111
- package/dist/lib/assets/index.d.ts +79 -0
- package/dist/lib/assets/index.js +153 -0
- package/dist/lib/assets.d.ts +20 -0
- package/dist/lib/assets.js +112 -0
- package/dist/lib/auth/index.d.ts +83 -0
- package/dist/lib/auth/index.js +146 -0
- package/dist/lib/board/index.d.ts +39 -0
- package/dist/lib/board/index.js +285 -0
- package/dist/lib/board/types.d.ts +111 -0
- package/dist/lib/board/types.js +1 -0
- package/dist/lib/bot/claim.d.ts +3 -0
- package/dist/lib/bot/claim.js +20 -0
- package/dist/lib/bot/coordinator.d.ts +27 -0
- package/dist/lib/bot/coordinator.js +178 -0
- package/dist/lib/bot/heartbeat.d.ts +6 -0
- package/dist/lib/bot/heartbeat.js +30 -0
- package/dist/lib/bot/index.d.ts +9 -0
- package/dist/lib/bot/index.js +6 -0
- package/dist/lib/bot/protocol.d.ts +12 -0
- package/dist/lib/bot/protocol.js +74 -0
- package/dist/lib/bot/reporter.d.ts +3 -0
- package/dist/lib/bot/reporter.js +27 -0
- package/dist/lib/bot/skills.d.ts +26 -0
- package/dist/lib/bot/skills.js +69 -0
- package/dist/lib/config/registry.d.ts +17 -0
- package/dist/lib/config/registry.js +182 -0
- package/dist/lib/config/schema.d.ts +31 -0
- package/dist/lib/config/schema.js +25 -0
- package/dist/lib/errors.d.ts +25 -0
- package/dist/lib/errors.js +91 -0
- package/dist/lib/format.d.ts +28 -0
- package/dist/lib/format.js +98 -0
- package/dist/lib/returnpro/validate.d.ts +37 -0
- package/dist/lib/returnpro/validate.js +124 -0
- package/dist/lib/social/meta.d.ts +90 -0
- package/dist/lib/social/meta.js +160 -0
- package/docs/CLI-REFERENCE.md +361 -0
- package/package.json +13 -24
- package/dist/lib/kanban.d.ts +0 -46
- package/dist/lib/kanban.js +0 -118
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { getSupabase } from '../supabase.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
const sb = () => getSupabase('optimal');
|
|
4
|
+
// --- Helpers ---
|
|
5
|
+
export function formatBoardTable(tasks) {
|
|
6
|
+
if (tasks.length === 0)
|
|
7
|
+
return 'No tasks found.';
|
|
8
|
+
const lines = [
|
|
9
|
+
'| Status | P | Title | Agent | Skill | Effort |',
|
|
10
|
+
'|-------------|---|--------------------------------|---------|-----------------|--------|',
|
|
11
|
+
];
|
|
12
|
+
const order = ['in_progress', 'claimed', 'blocked', 'ready', 'review', 'backlog', 'done'];
|
|
13
|
+
const sorted = [...tasks].sort((a, b) => {
|
|
14
|
+
const ai = order.indexOf(a.status);
|
|
15
|
+
const bi = order.indexOf(b.status);
|
|
16
|
+
if (ai !== bi)
|
|
17
|
+
return ai - bi;
|
|
18
|
+
return a.priority - b.priority;
|
|
19
|
+
});
|
|
20
|
+
for (const t of sorted) {
|
|
21
|
+
const title = t.title.length > 30 ? t.title.slice(0, 27) + '...' : t.title.padEnd(30);
|
|
22
|
+
const agent = (t.claimed_by ?? t.assigned_to ?? '—').padEnd(7);
|
|
23
|
+
const skill = (t.skill_required ?? '—').padEnd(15);
|
|
24
|
+
const effort = (t.estimated_effort ?? '—').padEnd(6);
|
|
25
|
+
lines.push(`| ${t.status.padEnd(11)} | ${t.priority} | ${title} | ${agent} | ${skill} | ${effort} |`);
|
|
26
|
+
}
|
|
27
|
+
lines.push(`\nTotal: ${tasks.length} tasks`);
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
export function getNextClaimable(readyTasks, allTasks) {
|
|
31
|
+
for (const task of readyTasks) {
|
|
32
|
+
if (!task.blocked_by || task.blocked_by.length === 0)
|
|
33
|
+
return task;
|
|
34
|
+
const allDone = task.blocked_by.every(depId => {
|
|
35
|
+
const dep = allTasks.find(t => t.id === depId);
|
|
36
|
+
return dep && (dep.status === 'done');
|
|
37
|
+
});
|
|
38
|
+
if (allDone)
|
|
39
|
+
return task;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// --- Projects ---
|
|
44
|
+
export async function createProject(input) {
|
|
45
|
+
const { data, error } = await sb()
|
|
46
|
+
.from('projects')
|
|
47
|
+
.insert({
|
|
48
|
+
slug: input.slug,
|
|
49
|
+
name: input.name,
|
|
50
|
+
description: input.description ?? null,
|
|
51
|
+
owner: input.owner ?? null,
|
|
52
|
+
priority: input.priority ?? 3,
|
|
53
|
+
})
|
|
54
|
+
.select()
|
|
55
|
+
.single();
|
|
56
|
+
if (error)
|
|
57
|
+
throw new Error(`Failed to create project: ${error.message}`);
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
export async function getProjectBySlug(slug) {
|
|
61
|
+
const { data, error } = await sb()
|
|
62
|
+
.from('projects')
|
|
63
|
+
.select('*')
|
|
64
|
+
.eq('slug', slug)
|
|
65
|
+
.single();
|
|
66
|
+
if (error)
|
|
67
|
+
throw new Error(`Project not found: ${slug} — ${error.message}`);
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
export async function listProjects() {
|
|
71
|
+
const { data, error } = await sb()
|
|
72
|
+
.from('projects')
|
|
73
|
+
.select('*')
|
|
74
|
+
.neq('status', 'archived')
|
|
75
|
+
.order('priority', { ascending: true });
|
|
76
|
+
if (error)
|
|
77
|
+
throw new Error(`Failed to list projects: ${error.message}`);
|
|
78
|
+
return (data ?? []);
|
|
79
|
+
}
|
|
80
|
+
export async function updateProject(slug, updates) {
|
|
81
|
+
const { data, error } = await sb()
|
|
82
|
+
.from('projects')
|
|
83
|
+
.update(updates)
|
|
84
|
+
.eq('slug', slug)
|
|
85
|
+
.select()
|
|
86
|
+
.single();
|
|
87
|
+
if (error)
|
|
88
|
+
throw new Error(`Failed to update project: ${error.message}`);
|
|
89
|
+
return data;
|
|
90
|
+
}
|
|
91
|
+
// --- Milestones ---
|
|
92
|
+
export async function createMilestone(input) {
|
|
93
|
+
const { data, error } = await sb()
|
|
94
|
+
.from('milestones')
|
|
95
|
+
.insert({
|
|
96
|
+
project_id: input.project_id,
|
|
97
|
+
name: input.name,
|
|
98
|
+
description: input.description ?? null,
|
|
99
|
+
due_date: input.due_date ?? null,
|
|
100
|
+
})
|
|
101
|
+
.select()
|
|
102
|
+
.single();
|
|
103
|
+
if (error)
|
|
104
|
+
throw new Error(`Failed to create milestone: ${error.message}`);
|
|
105
|
+
return data;
|
|
106
|
+
}
|
|
107
|
+
export async function listMilestones(projectId) {
|
|
108
|
+
let query = sb().from('milestones').select('*').order('due_date', { ascending: true });
|
|
109
|
+
if (projectId)
|
|
110
|
+
query = query.eq('project_id', projectId);
|
|
111
|
+
const { data, error } = await query;
|
|
112
|
+
if (error)
|
|
113
|
+
throw new Error(`Failed to list milestones: ${error.message}`);
|
|
114
|
+
return (data ?? []);
|
|
115
|
+
}
|
|
116
|
+
// --- Labels ---
|
|
117
|
+
export async function createLabel(name, color) {
|
|
118
|
+
const { data, error } = await sb()
|
|
119
|
+
.from('labels')
|
|
120
|
+
.insert({ name, color: color ?? null })
|
|
121
|
+
.select()
|
|
122
|
+
.single();
|
|
123
|
+
if (error)
|
|
124
|
+
throw new Error(`Failed to create label: ${error.message}`);
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
127
|
+
export async function listLabels() {
|
|
128
|
+
const { data, error } = await sb().from('labels').select('*').order('name');
|
|
129
|
+
if (error)
|
|
130
|
+
throw new Error(`Failed to list labels: ${error.message}`);
|
|
131
|
+
return (data ?? []);
|
|
132
|
+
}
|
|
133
|
+
export async function getLabelByName(name) {
|
|
134
|
+
const { data } = await sb().from('labels').select('*').eq('name', name).single();
|
|
135
|
+
return data ?? null;
|
|
136
|
+
}
|
|
137
|
+
// --- Tasks ---
|
|
138
|
+
export async function createTask(input) {
|
|
139
|
+
const { labels: labelNames, ...rest } = input;
|
|
140
|
+
const { data, error } = await sb()
|
|
141
|
+
.from('tasks')
|
|
142
|
+
.insert({
|
|
143
|
+
...rest,
|
|
144
|
+
milestone_id: rest.milestone_id ?? null,
|
|
145
|
+
description: rest.description ?? null,
|
|
146
|
+
priority: rest.priority ?? 3,
|
|
147
|
+
skill_required: rest.skill_required ?? null,
|
|
148
|
+
source_repo: rest.source_repo ?? null,
|
|
149
|
+
target_module: rest.target_module ?? null,
|
|
150
|
+
estimated_effort: rest.estimated_effort ?? null,
|
|
151
|
+
blocked_by: rest.blocked_by ?? [],
|
|
152
|
+
})
|
|
153
|
+
.select()
|
|
154
|
+
.single();
|
|
155
|
+
if (error)
|
|
156
|
+
throw new Error(`Failed to create task: ${error.message}`);
|
|
157
|
+
const task = data;
|
|
158
|
+
if (labelNames && labelNames.length > 0) {
|
|
159
|
+
for (const name of labelNames) {
|
|
160
|
+
const label = await getLabelByName(name);
|
|
161
|
+
if (label) {
|
|
162
|
+
await sb().from('task_labels').insert({ task_id: task.id, label_id: label.id });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await logActivity({ task_id: task.id, project_id: task.project_id, actor: 'system', action: 'created', new_value: { title: task.title } });
|
|
167
|
+
return task;
|
|
168
|
+
}
|
|
169
|
+
export async function updateTask(taskId, updates, actor) {
|
|
170
|
+
const old = await getTask(taskId);
|
|
171
|
+
const { data, error } = await sb()
|
|
172
|
+
.from('tasks')
|
|
173
|
+
.update(updates)
|
|
174
|
+
.eq('id', taskId)
|
|
175
|
+
.select()
|
|
176
|
+
.single();
|
|
177
|
+
if (error)
|
|
178
|
+
throw new Error(`Failed to update task ${taskId}: ${error.message}`);
|
|
179
|
+
const task = data;
|
|
180
|
+
if (actor) {
|
|
181
|
+
await logActivity({
|
|
182
|
+
task_id: taskId,
|
|
183
|
+
project_id: task.project_id,
|
|
184
|
+
actor,
|
|
185
|
+
action: updates.status ? 'status_changed' : 'updated',
|
|
186
|
+
old_value: { status: old.status, assigned_to: old.assigned_to },
|
|
187
|
+
new_value: updates,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return task;
|
|
191
|
+
}
|
|
192
|
+
export async function getTask(taskId) {
|
|
193
|
+
const { data, error } = await sb()
|
|
194
|
+
.from('tasks')
|
|
195
|
+
.select('*')
|
|
196
|
+
.eq('id', taskId)
|
|
197
|
+
.single();
|
|
198
|
+
if (error)
|
|
199
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
200
|
+
return data;
|
|
201
|
+
}
|
|
202
|
+
export async function listTasks(opts) {
|
|
203
|
+
let query = sb().from('tasks').select('*');
|
|
204
|
+
if (opts?.project_id)
|
|
205
|
+
query = query.eq('project_id', opts.project_id);
|
|
206
|
+
if (opts?.status)
|
|
207
|
+
query = query.eq('status', opts.status);
|
|
208
|
+
if (opts?.claimed_by)
|
|
209
|
+
query = query.eq('claimed_by', opts.claimed_by);
|
|
210
|
+
if (opts?.assigned_to)
|
|
211
|
+
query = query.eq('assigned_to', opts.assigned_to);
|
|
212
|
+
query = query.order('priority', { ascending: true }).order('sort_order', { ascending: true });
|
|
213
|
+
const { data, error } = await query;
|
|
214
|
+
if (error)
|
|
215
|
+
throw new Error(`Failed to list tasks: ${error.message}`);
|
|
216
|
+
return (data ?? []);
|
|
217
|
+
}
|
|
218
|
+
export async function claimTask(taskId, agent) {
|
|
219
|
+
const task = await updateTask(taskId, {
|
|
220
|
+
status: 'claimed',
|
|
221
|
+
claimed_by: agent,
|
|
222
|
+
claimed_at: new Date().toISOString(),
|
|
223
|
+
}, agent);
|
|
224
|
+
await addComment({ task_id: taskId, author: agent, body: `Claimed by ${agent}`, comment_type: 'claim' });
|
|
225
|
+
return task;
|
|
226
|
+
}
|
|
227
|
+
export async function completeTask(taskId, actor) {
|
|
228
|
+
return updateTask(taskId, {
|
|
229
|
+
status: 'done',
|
|
230
|
+
completed_at: new Date().toISOString(),
|
|
231
|
+
}, actor);
|
|
232
|
+
}
|
|
233
|
+
// --- Comments ---
|
|
234
|
+
export async function addComment(input) {
|
|
235
|
+
const { data, error } = await sb()
|
|
236
|
+
.from('comments')
|
|
237
|
+
.insert({
|
|
238
|
+
task_id: input.task_id,
|
|
239
|
+
author: input.author,
|
|
240
|
+
body: input.body,
|
|
241
|
+
comment_type: input.comment_type ?? 'comment',
|
|
242
|
+
})
|
|
243
|
+
.select()
|
|
244
|
+
.single();
|
|
245
|
+
if (error)
|
|
246
|
+
throw new Error(`Failed to add comment: ${error.message}`);
|
|
247
|
+
return data;
|
|
248
|
+
}
|
|
249
|
+
export async function listComments(taskId) {
|
|
250
|
+
const { data, error } = await sb()
|
|
251
|
+
.from('comments')
|
|
252
|
+
.select('*')
|
|
253
|
+
.eq('task_id', taskId)
|
|
254
|
+
.order('created_at', { ascending: true });
|
|
255
|
+
if (error)
|
|
256
|
+
throw new Error(`Failed to list comments: ${error.message}`);
|
|
257
|
+
return (data ?? []);
|
|
258
|
+
}
|
|
259
|
+
// --- Activity Log ---
|
|
260
|
+
export async function logActivity(entry) {
|
|
261
|
+
const { error } = await sb()
|
|
262
|
+
.from('activity_log')
|
|
263
|
+
.insert({
|
|
264
|
+
task_id: entry.task_id ?? null,
|
|
265
|
+
project_id: entry.project_id ?? null,
|
|
266
|
+
actor: entry.actor,
|
|
267
|
+
action: entry.action,
|
|
268
|
+
old_value: entry.old_value ?? null,
|
|
269
|
+
new_value: entry.new_value ?? null,
|
|
270
|
+
});
|
|
271
|
+
if (error)
|
|
272
|
+
throw new Error(`Failed to log activity: ${error.message}`);
|
|
273
|
+
}
|
|
274
|
+
export async function listActivity(opts) {
|
|
275
|
+
let query = sb().from('activity_log').select('*');
|
|
276
|
+
if (opts?.task_id)
|
|
277
|
+
query = query.eq('task_id', opts.task_id);
|
|
278
|
+
if (opts?.actor)
|
|
279
|
+
query = query.eq('actor', opts.actor);
|
|
280
|
+
query = query.order('created_at', { ascending: false }).limit(opts?.limit ?? 50);
|
|
281
|
+
const { data, error } = await query;
|
|
282
|
+
if (error)
|
|
283
|
+
throw new Error(`Failed to list activity: ${error.message}`);
|
|
284
|
+
return (data ?? []);
|
|
285
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export interface Project {
|
|
2
|
+
id: string;
|
|
3
|
+
slug: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string | null;
|
|
6
|
+
status: 'active' | 'paused' | 'completed' | 'archived';
|
|
7
|
+
owner: string | null;
|
|
8
|
+
priority: 1 | 2 | 3 | 4;
|
|
9
|
+
created_at: string;
|
|
10
|
+
updated_at: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Milestone {
|
|
13
|
+
id: string;
|
|
14
|
+
project_id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description: string | null;
|
|
17
|
+
due_date: string | null;
|
|
18
|
+
status: 'open' | 'completed' | 'missed';
|
|
19
|
+
created_at: string;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
}
|
|
22
|
+
export interface Label {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
color: string | null;
|
|
26
|
+
created_at: string;
|
|
27
|
+
}
|
|
28
|
+
export type TaskStatus = 'backlog' | 'ready' | 'claimed' | 'in_progress' | 'review' | 'done' | 'blocked';
|
|
29
|
+
export type Priority = 1 | 2 | 3 | 4;
|
|
30
|
+
export type Effort = 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
31
|
+
export interface Task {
|
|
32
|
+
id: string;
|
|
33
|
+
project_id: string;
|
|
34
|
+
milestone_id: string | null;
|
|
35
|
+
title: string;
|
|
36
|
+
description: string | null;
|
|
37
|
+
status: TaskStatus;
|
|
38
|
+
priority: Priority;
|
|
39
|
+
assigned_to: string | null;
|
|
40
|
+
claimed_by: string | null;
|
|
41
|
+
claimed_at: string | null;
|
|
42
|
+
skill_required: string | null;
|
|
43
|
+
source_repo: string | null;
|
|
44
|
+
target_module: string | null;
|
|
45
|
+
estimated_effort: Effort | null;
|
|
46
|
+
blocked_by: string[];
|
|
47
|
+
sort_order: number;
|
|
48
|
+
created_at: string;
|
|
49
|
+
updated_at: string;
|
|
50
|
+
completed_at: string | null;
|
|
51
|
+
}
|
|
52
|
+
export interface Comment {
|
|
53
|
+
id: string;
|
|
54
|
+
task_id: string;
|
|
55
|
+
author: string;
|
|
56
|
+
body: string;
|
|
57
|
+
comment_type: 'comment' | 'status_change' | 'claim' | 'review';
|
|
58
|
+
created_at: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ActivityEntry {
|
|
61
|
+
id: string;
|
|
62
|
+
task_id: string | null;
|
|
63
|
+
project_id: string | null;
|
|
64
|
+
actor: string;
|
|
65
|
+
action: string;
|
|
66
|
+
old_value: Record<string, unknown> | null;
|
|
67
|
+
new_value: Record<string, unknown> | null;
|
|
68
|
+
created_at: string;
|
|
69
|
+
}
|
|
70
|
+
export interface CreateProjectInput {
|
|
71
|
+
slug: string;
|
|
72
|
+
name: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
owner?: string;
|
|
75
|
+
priority?: Priority;
|
|
76
|
+
}
|
|
77
|
+
export interface CreateMilestoneInput {
|
|
78
|
+
project_id: string;
|
|
79
|
+
name: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
due_date?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface CreateTaskInput {
|
|
84
|
+
project_id: string;
|
|
85
|
+
title: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
priority?: Priority;
|
|
88
|
+
milestone_id?: string;
|
|
89
|
+
skill_required?: string;
|
|
90
|
+
source_repo?: string;
|
|
91
|
+
target_module?: string;
|
|
92
|
+
estimated_effort?: Effort;
|
|
93
|
+
blocked_by?: string[];
|
|
94
|
+
labels?: string[];
|
|
95
|
+
}
|
|
96
|
+
export interface UpdateTaskInput {
|
|
97
|
+
status?: TaskStatus;
|
|
98
|
+
priority?: Priority;
|
|
99
|
+
assigned_to?: string | null;
|
|
100
|
+
claimed_by?: string | null;
|
|
101
|
+
claimed_at?: string | null;
|
|
102
|
+
milestone_id?: string | null;
|
|
103
|
+
description?: string;
|
|
104
|
+
completed_at?: string | null;
|
|
105
|
+
}
|
|
106
|
+
export interface CreateCommentInput {
|
|
107
|
+
task_id: string;
|
|
108
|
+
author: string;
|
|
109
|
+
body: string;
|
|
110
|
+
comment_type?: 'comment' | 'status_change' | 'claim' | 'review';
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { listTasks, claimTask, updateTask, getNextClaimable, } from '../board/index.js';
|
|
2
|
+
export async function claimNextTask(agentId, skills) {
|
|
3
|
+
const readyTasks = await listTasks({ status: 'ready' });
|
|
4
|
+
const allTasks = await listTasks();
|
|
5
|
+
let candidates = readyTasks;
|
|
6
|
+
if (skills && skills.length > 0) {
|
|
7
|
+
candidates = readyTasks.filter((t) => !t.skill_required || skills.includes(t.skill_required));
|
|
8
|
+
}
|
|
9
|
+
const next = getNextClaimable(candidates, allTasks);
|
|
10
|
+
if (!next)
|
|
11
|
+
return null;
|
|
12
|
+
return claimTask(next.id, agentId);
|
|
13
|
+
}
|
|
14
|
+
export async function releaseTask(taskId, agentId, reason) {
|
|
15
|
+
return updateTask(taskId, {
|
|
16
|
+
status: 'ready',
|
|
17
|
+
claimed_by: null,
|
|
18
|
+
claimed_at: null,
|
|
19
|
+
}, agentId);
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Task } from '../board/index.js';
|
|
2
|
+
import { type AgentProfile } from './skills.js';
|
|
3
|
+
export interface CoordinatorConfig {
|
|
4
|
+
pollIntervalMs: number;
|
|
5
|
+
maxAgents: number;
|
|
6
|
+
autoAssign: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface CoordinatorStatus {
|
|
9
|
+
activeAgents: {
|
|
10
|
+
agent: string;
|
|
11
|
+
status: string;
|
|
12
|
+
lastSeen: string;
|
|
13
|
+
}[];
|
|
14
|
+
idleAgents: AgentProfile[];
|
|
15
|
+
tasksInProgress: number;
|
|
16
|
+
tasksReady: number;
|
|
17
|
+
tasksBlocked: number;
|
|
18
|
+
lastPollAt: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface RebalanceResult {
|
|
21
|
+
releasedTasks: Task[];
|
|
22
|
+
reassignedTasks: Task[];
|
|
23
|
+
}
|
|
24
|
+
export declare function runCoordinatorLoop(config?: Partial<CoordinatorConfig>): Promise<void>;
|
|
25
|
+
export declare function getCoordinatorStatus(): Promise<CoordinatorStatus>;
|
|
26
|
+
export declare function assignTask(taskId: string, agentId: string): Promise<Task>;
|
|
27
|
+
export declare function rebalance(): Promise<RebalanceResult>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { listTasks, updateTask, claimTask, logActivity, listActivity, } from '../board/index.js';
|
|
2
|
+
import { sendHeartbeat, getActiveAgents } from './heartbeat.js';
|
|
3
|
+
import { claimNextTask } from './claim.js';
|
|
4
|
+
import { getAgentProfiles, matchTasksToAgent } from './skills.js';
|
|
5
|
+
// --- State ---
|
|
6
|
+
const DEFAULT_CONFIG = {
|
|
7
|
+
pollIntervalMs: 30_000,
|
|
8
|
+
maxAgents: 10,
|
|
9
|
+
autoAssign: true,
|
|
10
|
+
};
|
|
11
|
+
let lastPollAt = null;
|
|
12
|
+
// --- Coordinator loop ---
|
|
13
|
+
export async function runCoordinatorLoop(config) {
|
|
14
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
15
|
+
let running = true;
|
|
16
|
+
const shutdown = () => {
|
|
17
|
+
running = false;
|
|
18
|
+
console.log('\nCoordinator shutting down...');
|
|
19
|
+
};
|
|
20
|
+
process.on('SIGINT', shutdown);
|
|
21
|
+
console.log(`Coordinator started — poll every ${cfg.pollIntervalMs}ms, max ${cfg.maxAgents} agents, autoAssign=${cfg.autoAssign}`);
|
|
22
|
+
while (running) {
|
|
23
|
+
try {
|
|
24
|
+
await pollOnce(cfg);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
28
|
+
console.error(`Coordinator poll error: ${msg}`);
|
|
29
|
+
await logActivity({
|
|
30
|
+
actor: 'coordinator',
|
|
31
|
+
action: 'poll_error',
|
|
32
|
+
new_value: { error: msg },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// Wait for next poll or until interrupted
|
|
36
|
+
await new Promise((resolve) => {
|
|
37
|
+
const timer = setTimeout(resolve, cfg.pollIntervalMs);
|
|
38
|
+
if (!running) {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
process.removeListener('SIGINT', shutdown);
|
|
45
|
+
console.log('Coordinator stopped.');
|
|
46
|
+
}
|
|
47
|
+
async function pollOnce(cfg) {
|
|
48
|
+
lastPollAt = new Date().toISOString();
|
|
49
|
+
const profiles = getAgentProfiles().slice(0, cfg.maxAgents);
|
|
50
|
+
const readyTasks = await listTasks({ status: 'ready' });
|
|
51
|
+
const claimedTasks = await listTasks({ status: 'claimed' });
|
|
52
|
+
// Send heartbeats for all active agents that have claimed tasks
|
|
53
|
+
const activeAgentIds = new Set(claimedTasks.map((t) => t.claimed_by).filter(Boolean));
|
|
54
|
+
for (const agentId of activeAgentIds) {
|
|
55
|
+
await sendHeartbeat(agentId, 'working');
|
|
56
|
+
}
|
|
57
|
+
if (!cfg.autoAssign) {
|
|
58
|
+
await logActivity({
|
|
59
|
+
actor: 'coordinator',
|
|
60
|
+
action: 'poll',
|
|
61
|
+
new_value: {
|
|
62
|
+
readyTasks: readyTasks.length,
|
|
63
|
+
activeAgents: activeAgentIds.size,
|
|
64
|
+
autoAssign: false,
|
|
65
|
+
ts: lastPollAt,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Find idle agents and try to assign tasks
|
|
71
|
+
let assignedCount = 0;
|
|
72
|
+
for (const agent of profiles) {
|
|
73
|
+
// Check if agent has capacity
|
|
74
|
+
const agentClaimed = claimedTasks.filter((t) => t.claimed_by === agent.id);
|
|
75
|
+
if (agentClaimed.length >= agent.maxConcurrent)
|
|
76
|
+
continue;
|
|
77
|
+
// Match available tasks to this agent
|
|
78
|
+
const matched = matchTasksToAgent(agent, readyTasks);
|
|
79
|
+
if (matched.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
// Claim the top-priority matched task
|
|
82
|
+
const task = await claimNextTask(agent.id, agent.skills);
|
|
83
|
+
if (task) {
|
|
84
|
+
assignedCount++;
|
|
85
|
+
console.log(` Assigned "${task.title}" -> ${agent.id}`);
|
|
86
|
+
// Remove from local readyTasks to avoid double-assign
|
|
87
|
+
const idx = readyTasks.findIndex((t) => t.id === task.id);
|
|
88
|
+
if (idx >= 0)
|
|
89
|
+
readyTasks.splice(idx, 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await logActivity({
|
|
93
|
+
actor: 'coordinator',
|
|
94
|
+
action: 'poll',
|
|
95
|
+
new_value: {
|
|
96
|
+
readyTasks: readyTasks.length,
|
|
97
|
+
activeAgents: activeAgentIds.size,
|
|
98
|
+
assigned: assignedCount,
|
|
99
|
+
ts: lastPollAt,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (assignedCount > 0) {
|
|
103
|
+
console.log(`Poll complete: assigned ${assignedCount} task(s)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// --- Status ---
|
|
107
|
+
export async function getCoordinatorStatus() {
|
|
108
|
+
const profiles = getAgentProfiles();
|
|
109
|
+
const activeAgents = await getActiveAgents();
|
|
110
|
+
const readyTasks = await listTasks({ status: 'ready' });
|
|
111
|
+
const claimedTasks = await listTasks({ status: 'claimed' });
|
|
112
|
+
const inProgressTasks = await listTasks({ status: 'in_progress' });
|
|
113
|
+
const blockedTasks = await listTasks({ status: 'blocked' });
|
|
114
|
+
const activeIds = new Set(activeAgents.map((a) => a.agent));
|
|
115
|
+
const idleAgents = profiles.filter((p) => !activeIds.has(p.id));
|
|
116
|
+
return {
|
|
117
|
+
activeAgents,
|
|
118
|
+
idleAgents,
|
|
119
|
+
tasksInProgress: claimedTasks.length + inProgressTasks.length,
|
|
120
|
+
tasksReady: readyTasks.length,
|
|
121
|
+
tasksBlocked: blockedTasks.length,
|
|
122
|
+
lastPollAt,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// --- Manual assignment ---
|
|
126
|
+
export async function assignTask(taskId, agentId) {
|
|
127
|
+
const task = await claimTask(taskId, agentId);
|
|
128
|
+
await logActivity({
|
|
129
|
+
actor: 'coordinator',
|
|
130
|
+
action: 'manual_assign',
|
|
131
|
+
task_id: taskId,
|
|
132
|
+
new_value: { agentId, title: task.title },
|
|
133
|
+
});
|
|
134
|
+
console.log(`Manually assigned "${task.title}" -> ${agentId}`);
|
|
135
|
+
return task;
|
|
136
|
+
}
|
|
137
|
+
// --- Rebalance ---
|
|
138
|
+
export async function rebalance() {
|
|
139
|
+
const claimedTasks = await listTasks({ status: 'claimed' });
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const staleThreshold = 60 * 60 * 1000; // 1 hour
|
|
142
|
+
const releasedTasks = [];
|
|
143
|
+
const reassignedTasks = [];
|
|
144
|
+
for (const task of claimedTasks) {
|
|
145
|
+
if (!task.claimed_at)
|
|
146
|
+
continue;
|
|
147
|
+
const claimedAge = now - new Date(task.claimed_at).getTime();
|
|
148
|
+
if (claimedAge < staleThreshold)
|
|
149
|
+
continue;
|
|
150
|
+
// Check for recent activity on this task
|
|
151
|
+
const activity = await listActivity({ task_id: task.id, limit: 5 });
|
|
152
|
+
const recentActivity = activity.some((a) => {
|
|
153
|
+
const age = now - new Date(a.created_at).getTime();
|
|
154
|
+
return age < staleThreshold && a.action !== 'poll';
|
|
155
|
+
});
|
|
156
|
+
if (recentActivity)
|
|
157
|
+
continue;
|
|
158
|
+
// Release stale task
|
|
159
|
+
const released = await updateTask(task.id, {
|
|
160
|
+
status: 'ready',
|
|
161
|
+
claimed_by: null,
|
|
162
|
+
claimed_at: null,
|
|
163
|
+
}, 'coordinator');
|
|
164
|
+
releasedTasks.push(released);
|
|
165
|
+
await logActivity({
|
|
166
|
+
actor: 'coordinator',
|
|
167
|
+
action: 'rebalance_release',
|
|
168
|
+
task_id: task.id,
|
|
169
|
+
new_value: {
|
|
170
|
+
previousAgent: task.claimed_by,
|
|
171
|
+
claimedAt: task.claimed_at,
|
|
172
|
+
reason: 'stale_claim',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
console.log(`Released stale task "${task.title}" (was claimed by ${task.claimed_by})`);
|
|
176
|
+
}
|
|
177
|
+
return { releasedTasks, reassignedTasks };
|
|
178
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { logActivity, listActivity } from '../board/index.js';
|
|
2
|
+
export async function sendHeartbeat(agentId, status) {
|
|
3
|
+
await logActivity({
|
|
4
|
+
actor: agentId,
|
|
5
|
+
action: 'heartbeat',
|
|
6
|
+
new_value: { status, ts: new Date().toISOString() },
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export async function getActiveAgents() {
|
|
10
|
+
const entries = await listActivity({ limit: 200 });
|
|
11
|
+
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
12
|
+
const latest = new Map();
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
if (e.action !== 'heartbeat')
|
|
15
|
+
continue;
|
|
16
|
+
if (new Date(e.created_at).getTime() < cutoff)
|
|
17
|
+
continue;
|
|
18
|
+
if (latest.has(e.actor))
|
|
19
|
+
continue;
|
|
20
|
+
const nv = e.new_value;
|
|
21
|
+
latest.set(e.actor, {
|
|
22
|
+
status: nv?.status ?? 'unknown',
|
|
23
|
+
lastSeen: e.created_at,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return Array.from(latest.entries()).map(([agent, info]) => ({
|
|
27
|
+
agent,
|
|
28
|
+
...info,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { sendHeartbeat, getActiveAgents } from './heartbeat.js';
|
|
2
|
+
export { claimNextTask, releaseTask } from './claim.js';
|
|
3
|
+
export { reportProgress, reportCompletion, reportBlocked } from './reporter.js';
|
|
4
|
+
export { getAgentProfiles, matchTasksToAgent, findBestAgent } from './skills.js';
|
|
5
|
+
export type { AgentProfile } from './skills.js';
|
|
6
|
+
export { runCoordinatorLoop, getCoordinatorStatus, assignTask, rebalance } from './coordinator.js';
|
|
7
|
+
export type { CoordinatorConfig, CoordinatorStatus, RebalanceResult } from './coordinator.js';
|
|
8
|
+
export { processAgentMessage } from './protocol.js';
|
|
9
|
+
export type { AgentMessage, AgentResponse } from './protocol.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { sendHeartbeat, getActiveAgents } from './heartbeat.js';
|
|
2
|
+
export { claimNextTask, releaseTask } from './claim.js';
|
|
3
|
+
export { reportProgress, reportCompletion, reportBlocked } from './reporter.js';
|
|
4
|
+
export { getAgentProfiles, matchTasksToAgent, findBestAgent } from './skills.js';
|
|
5
|
+
export { runCoordinatorLoop, getCoordinatorStatus, assignTask, rebalance } from './coordinator.js';
|
|
6
|
+
export { processAgentMessage } from './protocol.js';
|