@teamclaws/teamclaw 2026.3.21
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/README.md +37 -0
- package/api.ts +10 -0
- package/index.ts +246 -0
- package/openclaw.plugin.json +41 -0
- package/package.json +63 -0
- package/src/config.ts +297 -0
- package/src/controller/controller-service.ts +197 -0
- package/src/controller/controller-tools.ts +224 -0
- package/src/controller/http-server.ts +1946 -0
- package/src/controller/local-worker-manager.ts +531 -0
- package/src/controller/message-router.ts +62 -0
- package/src/controller/prompt-injector.ts +116 -0
- package/src/controller/task-router.ts +97 -0
- package/src/controller/websocket.ts +63 -0
- package/src/controller/worker-provisioning.ts +1286 -0
- package/src/discovery.ts +101 -0
- package/src/git-collaboration.ts +690 -0
- package/src/identity.ts +149 -0
- package/src/openclaw-workspace.ts +101 -0
- package/src/protocol.ts +88 -0
- package/src/roles.ts +275 -0
- package/src/state.ts +118 -0
- package/src/task-executor.ts +478 -0
- package/src/types.ts +469 -0
- package/src/ui/app.js +1400 -0
- package/src/ui/index.html +207 -0
- package/src/ui/style.css +1281 -0
- package/src/worker/http-handler.ts +136 -0
- package/src/worker/message-queue.ts +31 -0
- package/src/worker/prompt-injector.ts +72 -0
- package/src/worker/tools.ts +318 -0
- package/src/worker/worker-service.ts +194 -0
- package/src/workspace-browser.ts +312 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,1946 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
+
import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
|
|
6
|
+
import type {
|
|
7
|
+
ClarificationRequest,
|
|
8
|
+
GitRepoState,
|
|
9
|
+
PluginConfig,
|
|
10
|
+
RepoSyncInfo,
|
|
11
|
+
RoleId,
|
|
12
|
+
TaskExecution,
|
|
13
|
+
TaskExecutionEvent,
|
|
14
|
+
TaskExecutionEventInput,
|
|
15
|
+
TaskAssignmentPayload,
|
|
16
|
+
TaskExecutionSummary,
|
|
17
|
+
TaskInfo,
|
|
18
|
+
TaskPriority,
|
|
19
|
+
TaskStatus,
|
|
20
|
+
TeamMessage,
|
|
21
|
+
TeamState,
|
|
22
|
+
WorkerInfo,
|
|
23
|
+
} from "../types.js";
|
|
24
|
+
import {
|
|
25
|
+
parseJsonBody,
|
|
26
|
+
readRequestBody,
|
|
27
|
+
sendJson,
|
|
28
|
+
sendError,
|
|
29
|
+
generateId,
|
|
30
|
+
} from "../protocol.js";
|
|
31
|
+
import { listWorkspaceTree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
|
|
32
|
+
import { ROLES } from "../roles.js";
|
|
33
|
+
import { buildRepoSyncInfo, ensureControllerGitRepo, exportControllerGitBundle, importControllerGitBundle } from "../git-collaboration.js";
|
|
34
|
+
import type { LocalWorkerManager } from "./local-worker-manager.js";
|
|
35
|
+
import { TaskRouter } from "./task-router.js";
|
|
36
|
+
import { MessageRouter } from "./message-router.js";
|
|
37
|
+
import { TeamWebSocketServer } from "./websocket.js";
|
|
38
|
+
import type { WorkerProvisioningManager } from "./worker-provisioning.js";
|
|
39
|
+
|
|
40
|
+
export type ControllerHttpDeps = {
|
|
41
|
+
config: PluginConfig;
|
|
42
|
+
logger: PluginLogger;
|
|
43
|
+
runtime: OpenClawPluginApi["runtime"];
|
|
44
|
+
getTeamState: () => TeamState | null;
|
|
45
|
+
updateTeamState: (updater: (state: TeamState) => void) => TeamState;
|
|
46
|
+
taskRouter: TaskRouter;
|
|
47
|
+
messageRouter: MessageRouter;
|
|
48
|
+
wsServer: TeamWebSocketServer;
|
|
49
|
+
localWorkerManager?: LocalWorkerManager;
|
|
50
|
+
workerProvisioningManager?: WorkerProvisioningManager | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const MAX_TASK_EXECUTION_EVENTS = 250;
|
|
54
|
+
const MAX_RECENT_TASK_CONTEXT = 3;
|
|
55
|
+
const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
|
|
56
|
+
const CONTROLLER_INTAKE_TIMEOUT_CAP_MS = 180_000;
|
|
57
|
+
const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
|
|
58
|
+
|
|
59
|
+
function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
|
|
60
|
+
switch (taskStatus) {
|
|
61
|
+
case "completed":
|
|
62
|
+
return "completed";
|
|
63
|
+
case "failed":
|
|
64
|
+
return "failed";
|
|
65
|
+
case "in_progress":
|
|
66
|
+
case "review":
|
|
67
|
+
return "running";
|
|
68
|
+
case "pending":
|
|
69
|
+
case "assigned":
|
|
70
|
+
return current ?? "pending";
|
|
71
|
+
case "blocked":
|
|
72
|
+
return current ?? "running";
|
|
73
|
+
default:
|
|
74
|
+
return current ?? "pending";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ensureTaskExecution(task: TaskInfo): TaskExecution {
|
|
79
|
+
if (!task.execution) {
|
|
80
|
+
task.execution = {
|
|
81
|
+
status: mapTaskStatusToExecutionStatus(task.status),
|
|
82
|
+
startedAt: task.startedAt,
|
|
83
|
+
endedAt: task.completedAt,
|
|
84
|
+
lastUpdatedAt: task.updatedAt,
|
|
85
|
+
events: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!Array.isArray(task.execution.events)) {
|
|
90
|
+
task.execution.events = [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
task.execution.status = task.execution.status ?? mapTaskStatusToExecutionStatus(task.status);
|
|
94
|
+
task.execution.startedAt = task.execution.startedAt ?? task.startedAt;
|
|
95
|
+
task.execution.endedAt = task.execution.endedAt ?? task.completedAt;
|
|
96
|
+
task.execution.lastUpdatedAt = task.execution.lastUpdatedAt ?? task.updatedAt;
|
|
97
|
+
|
|
98
|
+
return task.execution;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function appendTaskExecutionEvent(task: TaskInfo, input: TaskExecutionEventInput): TaskExecutionEvent {
|
|
102
|
+
const now = input.createdAt ?? Date.now();
|
|
103
|
+
const execution = ensureTaskExecution(task);
|
|
104
|
+
|
|
105
|
+
if (input.runId) {
|
|
106
|
+
execution.runId = input.runId;
|
|
107
|
+
}
|
|
108
|
+
if (input.sessionKey) {
|
|
109
|
+
execution.sessionKey = input.sessionKey;
|
|
110
|
+
}
|
|
111
|
+
if (input.status) {
|
|
112
|
+
execution.status = input.status;
|
|
113
|
+
} else {
|
|
114
|
+
execution.status = mapTaskStatusToExecutionStatus(task.status, execution.status);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if ((input.status === "running" || input.phase === "run_started") && !execution.startedAt) {
|
|
118
|
+
execution.startedAt = now;
|
|
119
|
+
}
|
|
120
|
+
if ((input.status === "running" || input.phase === "run_started") && !task.startedAt) {
|
|
121
|
+
task.startedAt = now;
|
|
122
|
+
}
|
|
123
|
+
if ((input.status === "running" || input.phase === "run_started") && (task.status === "pending" || task.status === "assigned")) {
|
|
124
|
+
task.status = "in_progress";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (execution.status === "completed" || execution.status === "failed") {
|
|
128
|
+
execution.endedAt = execution.endedAt ?? now;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
execution.lastUpdatedAt = now;
|
|
132
|
+
task.updatedAt = now;
|
|
133
|
+
|
|
134
|
+
const event: TaskExecutionEvent = {
|
|
135
|
+
id: generateId(),
|
|
136
|
+
type: input.type,
|
|
137
|
+
createdAt: now,
|
|
138
|
+
message: input.message,
|
|
139
|
+
phase: input.phase,
|
|
140
|
+
source: input.source,
|
|
141
|
+
stream: input.stream,
|
|
142
|
+
role: input.role ?? task.assignedRole,
|
|
143
|
+
workerId: input.workerId ?? task.assignedWorkerId,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
execution.events.push(event);
|
|
147
|
+
if (execution.events.length > MAX_TASK_EXECUTION_EVENTS) {
|
|
148
|
+
execution.events = execution.events.slice(-MAX_TASK_EXECUTION_EVENTS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return event;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildTaskExecutionSummary(execution?: TaskExecution): TaskExecutionSummary | undefined {
|
|
155
|
+
if (!execution) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
status: execution.status,
|
|
161
|
+
runId: execution.runId,
|
|
162
|
+
startedAt: execution.startedAt,
|
|
163
|
+
endedAt: execution.endedAt,
|
|
164
|
+
lastUpdatedAt: execution.lastUpdatedAt,
|
|
165
|
+
eventCount: execution.events.length,
|
|
166
|
+
lastEvent: execution.events[execution.events.length - 1],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function serializeTask(task?: TaskInfo, includeExecutionEvents = false): Record<string, unknown> | undefined {
|
|
171
|
+
if (!task) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const payload: Record<string, unknown> = { ...task };
|
|
176
|
+
delete payload.controllerSessionKey;
|
|
177
|
+
if (!task.execution) {
|
|
178
|
+
return payload;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
payload.execution = includeExecutionEvents
|
|
182
|
+
? {
|
|
183
|
+
status: task.execution.status,
|
|
184
|
+
runId: task.execution.runId,
|
|
185
|
+
startedAt: task.execution.startedAt,
|
|
186
|
+
endedAt: task.execution.endedAt,
|
|
187
|
+
lastUpdatedAt: task.execution.lastUpdatedAt,
|
|
188
|
+
events: task.execution.events.map((event) => ({ ...event })),
|
|
189
|
+
}
|
|
190
|
+
: buildTaskExecutionSummary(task.execution);
|
|
191
|
+
|
|
192
|
+
return payload;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractLastAssistantText(messages: unknown[]): string {
|
|
196
|
+
const assistantMessages = messages.filter((message): message is { role?: unknown; content?: unknown } => {
|
|
197
|
+
if (!message || typeof message !== "object") {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return (message as { role?: unknown }).role === "assistant";
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
|
204
|
+
if (!lastAssistant) {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (typeof lastAssistant.content === "string") {
|
|
209
|
+
return lastAssistant.content;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(lastAssistant.content)) {
|
|
213
|
+
const textBlocks = lastAssistant.content
|
|
214
|
+
.filter((block): block is { type?: unknown; text?: unknown } => {
|
|
215
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text";
|
|
216
|
+
})
|
|
217
|
+
.map((block) => (typeof block.text === "string" ? block.text : ""))
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
if (textBlocks.length > 0) {
|
|
220
|
+
return textBlocks.join("\n");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return JSON.stringify(lastAssistant);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeControllerIntakeSessionKey(input: unknown): string {
|
|
228
|
+
const fallback = `${CONTROLLER_INTAKE_SESSION_PREFIX}default`;
|
|
229
|
+
if (typeof input !== "string") {
|
|
230
|
+
return fallback;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const trimmed = input.trim();
|
|
234
|
+
if (!trimmed || !/^[a-zA-Z0-9:_-]{1,120}$/.test(trimmed)) {
|
|
235
|
+
return fallback;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return trimmed.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
|
|
239
|
+
? trimmed
|
|
240
|
+
: `${CONTROLLER_INTAKE_SESSION_PREFIX}${trimmed}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function collectTaskIds(state: TeamState | null): Set<string> {
|
|
244
|
+
return new Set(Object.keys(state?.tasks ?? {}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function tagControllerCreatedTasks(
|
|
248
|
+
taskIdsBeforeRun: Set<string>,
|
|
249
|
+
sessionKey: string,
|
|
250
|
+
deps: ControllerHttpDeps,
|
|
251
|
+
): void {
|
|
252
|
+
deps.updateTeamState((state) => {
|
|
253
|
+
for (const task of Object.values(state.tasks)) {
|
|
254
|
+
if (taskIdsBeforeRun.has(task.id)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (task.createdBy !== "controller" || task.controllerSessionKey) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
task.controllerSessionKey = sessionKey;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
266
|
+
const parts = [
|
|
267
|
+
`A controller-created TeamClaw task has ${task.status === "failed" ? "failed" : "completed"}.`,
|
|
268
|
+
`Task ID: ${task.id}`,
|
|
269
|
+
`Title: ${task.title}`,
|
|
270
|
+
task.assignedRole ? `Role: ${task.assignedRole}` : "",
|
|
271
|
+
"",
|
|
272
|
+
"## Original Task",
|
|
273
|
+
task.description || "No task description was recorded.",
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
if (task.result) {
|
|
277
|
+
parts.push("", "## Task Result", task.result);
|
|
278
|
+
}
|
|
279
|
+
if (task.error) {
|
|
280
|
+
parts.push("", "## Task Error", task.error);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
parts.push(
|
|
284
|
+
"",
|
|
285
|
+
"## Controller Follow-up",
|
|
286
|
+
"Continue orchestrating this same requirement.",
|
|
287
|
+
"Review the current TeamClaw state before acting.",
|
|
288
|
+
"Create only the next execution-ready task(s) whose prerequisites are now satisfied.",
|
|
289
|
+
"Do not duplicate tasks that already exist, are active, or are already completed.",
|
|
290
|
+
"If no additional task should be created yet, reply briefly and stop.",
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return parts.filter(Boolean).join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
|
|
297
|
+
if (task.createdBy !== "controller" || !task.controllerSessionKey) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
await runControllerIntake(buildControllerFollowUpMessage(task), task.controllerSessionKey, deps);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function runControllerIntake(
|
|
304
|
+
message: string,
|
|
305
|
+
sessionKey: string,
|
|
306
|
+
deps: ControllerHttpDeps,
|
|
307
|
+
): Promise<{ sessionKey: string; runId: string; reply: string }> {
|
|
308
|
+
const taskIdsBeforeRun = collectTaskIds(deps.getTeamState());
|
|
309
|
+
const runResult = await deps.runtime.subagent.run({
|
|
310
|
+
sessionKey,
|
|
311
|
+
message,
|
|
312
|
+
idempotencyKey: `controller-intake-${generateId()}`,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const waitResult = await deps.runtime.subagent.waitForRun({
|
|
316
|
+
runId: runResult.runId,
|
|
317
|
+
timeoutMs: Math.min(deps.config.taskTimeoutMs, CONTROLLER_INTAKE_TIMEOUT_CAP_MS),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (waitResult.status === "timeout") {
|
|
321
|
+
tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
322
|
+
throw new Error("Controller intake timed out");
|
|
323
|
+
}
|
|
324
|
+
if (waitResult.status !== "ok") {
|
|
325
|
+
tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
326
|
+
throw new Error(waitResult.error || "Controller intake failed");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
330
|
+
|
|
331
|
+
const sessionMessages = await deps.runtime.subagent.getSessionMessages({
|
|
332
|
+
sessionKey,
|
|
333
|
+
limit: 100,
|
|
334
|
+
});
|
|
335
|
+
const reply = extractLastAssistantText(sessionMessages.messages);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
sessionKey,
|
|
339
|
+
runId: runResult.runId,
|
|
340
|
+
reply: reply || "Controller completed the intake run but did not return any text.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function summarizeTaskForAssignment(task: TaskInfo): string {
|
|
345
|
+
const lastExecutionMessage = task.execution?.events[task.execution.events.length - 1]?.message;
|
|
346
|
+
const raw = task.result || task.progress || lastExecutionMessage || task.description || "";
|
|
347
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
348
|
+
if (!normalized) {
|
|
349
|
+
return "No upstream summary available.";
|
|
350
|
+
}
|
|
351
|
+
if (normalized.length <= MAX_TASK_CONTEXT_SUMMARY_CHARS) {
|
|
352
|
+
return normalized;
|
|
353
|
+
}
|
|
354
|
+
return `${normalized.slice(0, MAX_TASK_CONTEXT_SUMMARY_CHARS).trimEnd()}…`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function buildRecentCompletedTaskContext(task: TaskInfo, state: TeamState | null): string {
|
|
358
|
+
if (!state) {
|
|
359
|
+
return "";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const recentCompletedTasks = Object.values(state.tasks)
|
|
363
|
+
.filter((candidate) => candidate.id !== task.id && candidate.status === "completed")
|
|
364
|
+
.filter((candidate) => (candidate.completedAt ?? candidate.updatedAt) <= task.createdAt)
|
|
365
|
+
.sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt))
|
|
366
|
+
.slice(0, MAX_RECENT_TASK_CONTEXT)
|
|
367
|
+
.reverse();
|
|
368
|
+
|
|
369
|
+
if (recentCompletedTasks.length === 0) {
|
|
370
|
+
return "";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return [
|
|
374
|
+
"## Recent Completed Team Deliverables",
|
|
375
|
+
"Use these upstream outputs before requesting clarification.",
|
|
376
|
+
"If a summary references a filename or task ID, search the shared workspace for it first.",
|
|
377
|
+
"Do not try to inspect another worker's OpenClaw session or session key directly; those sessions are isolated per worker.",
|
|
378
|
+
...recentCompletedTasks.map((candidate) => {
|
|
379
|
+
const roleLabel = candidate.assignedRole
|
|
380
|
+
? (ROLES.find((role) => role.id === candidate.assignedRole)?.label ?? candidate.assignedRole)
|
|
381
|
+
: "Unassigned";
|
|
382
|
+
return `- [${candidate.id}] ${candidate.title} (${roleLabel}): ${summarizeTaskForAssignment(candidate)}`;
|
|
383
|
+
}),
|
|
384
|
+
].join("\n");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string {
|
|
388
|
+
const parts = [task.description];
|
|
389
|
+
const recentContext = buildRecentCompletedTaskContext(task, state);
|
|
390
|
+
if (recentContext) {
|
|
391
|
+
parts.push("", recentContext);
|
|
392
|
+
}
|
|
393
|
+
if (repoInfo?.enabled) {
|
|
394
|
+
parts.push("", buildRepoTaskContext(repoInfo));
|
|
395
|
+
}
|
|
396
|
+
return parts.join("\n");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildRepoTaskContext(repoInfo: RepoSyncInfo): string {
|
|
400
|
+
const lines = [
|
|
401
|
+
"## TeamClaw Git Collaboration",
|
|
402
|
+
"- TeamClaw manages a git-backed project workspace for this task.",
|
|
403
|
+
`- Sync mode: ${repoInfo.mode}.`,
|
|
404
|
+
`- Default branch: ${repoInfo.defaultBranch}.`,
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
if (repoInfo.headCommit) {
|
|
408
|
+
const headSummary = repoInfo.headSummary ? ` "${repoInfo.headSummary}"` : "";
|
|
409
|
+
lines.push(`- Current HEAD: ${repoInfo.headCommit}${headSummary}.`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
lines.push("- TeamClaw syncs the workspace checkout before task execution when needed.");
|
|
413
|
+
lines.push("- Treat the current workspace as the canonical repo checkout; do not delete `.git` or replace the repo with ad-hoc archives.");
|
|
414
|
+
return lines.join("\n");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function refreshControllerRepoState(deps: ControllerHttpDeps): Promise<GitRepoState | null> {
|
|
418
|
+
if (!deps.config.gitEnabled) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const repo = await ensureControllerGitRepo(deps.config, deps.logger);
|
|
424
|
+
if (repo) {
|
|
425
|
+
deps.updateTeamState((s) => {
|
|
426
|
+
s.repo = repo;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return repo;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
432
|
+
deps.logger.warn(`Controller: failed to refresh git repo state: ${message}`);
|
|
433
|
+
deps.updateTeamState((s) => {
|
|
434
|
+
if (s.repo?.enabled) {
|
|
435
|
+
s.repo = {
|
|
436
|
+
...s.repo,
|
|
437
|
+
error: message,
|
|
438
|
+
lastPreparedAt: Date.now(),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
return deps.getTeamState()?.repo ?? null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function scheduleProvisioningReconcile(deps: ControllerHttpDeps, reason: string): void {
|
|
447
|
+
void deps.workerProvisioningManager?.requestReconcile(reason);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function broadcastTaskExecutionEvent(
|
|
451
|
+
taskId: string,
|
|
452
|
+
task: TaskInfo,
|
|
453
|
+
event: TaskExecutionEvent,
|
|
454
|
+
deps: ControllerHttpDeps,
|
|
455
|
+
): void {
|
|
456
|
+
deps.wsServer.broadcastUpdate({
|
|
457
|
+
type: "task:execution",
|
|
458
|
+
data: {
|
|
459
|
+
taskId,
|
|
460
|
+
event,
|
|
461
|
+
execution: buildTaskExecutionSummary(task.execution),
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function recordTaskExecutionEvent(
|
|
467
|
+
taskId: string,
|
|
468
|
+
input: TaskExecutionEventInput,
|
|
469
|
+
deps: ControllerHttpDeps,
|
|
470
|
+
): { task?: TaskInfo; event?: TaskExecutionEvent; statusChanged: boolean } {
|
|
471
|
+
const { updateTeamState, wsServer } = deps;
|
|
472
|
+
let statusChanged = false;
|
|
473
|
+
let event: TaskExecutionEvent | undefined;
|
|
474
|
+
|
|
475
|
+
const state = updateTeamState((s) => {
|
|
476
|
+
const task = s.tasks[taskId];
|
|
477
|
+
if (!task) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const previousStatus = task.status;
|
|
482
|
+
event = appendTaskExecutionEvent(task, input);
|
|
483
|
+
statusChanged = previousStatus !== task.status;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const updatedTask = state.tasks[taskId];
|
|
487
|
+
if (updatedTask && event) {
|
|
488
|
+
broadcastTaskExecutionEvent(taskId, updatedTask, event, deps);
|
|
489
|
+
if (statusChanged) {
|
|
490
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return { task: updatedTask, event, statusChanged };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function canAcceptWorkerUpdate(task: TaskInfo | undefined, workerId: string): boolean {
|
|
498
|
+
if (!task || task.assignedWorkerId !== workerId || task.completedAt) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return task.status === "assigned" ||
|
|
503
|
+
task.status === "in_progress" ||
|
|
504
|
+
task.status === "review" ||
|
|
505
|
+
task.status === "completed" ||
|
|
506
|
+
task.status === "failed";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function cancelTaskExecution(
|
|
510
|
+
taskId: string,
|
|
511
|
+
workerId: string | undefined,
|
|
512
|
+
reason: string,
|
|
513
|
+
deps: ControllerHttpDeps,
|
|
514
|
+
): Promise<void> {
|
|
515
|
+
if (!workerId) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const worker = deps.getTeamState()?.workers[workerId];
|
|
520
|
+
if (!worker) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let cancelled = false;
|
|
525
|
+
if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
|
|
526
|
+
cancelled = await deps.localWorkerManager.cancelTaskExecution(workerId, taskId);
|
|
527
|
+
} else {
|
|
528
|
+
try {
|
|
529
|
+
const res = await fetch(`${worker.url}/api/v1/tasks/${taskId}/cancel`, {
|
|
530
|
+
method: "POST",
|
|
531
|
+
});
|
|
532
|
+
cancelled = res.ok;
|
|
533
|
+
if (!res.ok) {
|
|
534
|
+
deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
|
|
535
|
+
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!cancelled) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
recordTaskExecutionEvent(taskId, {
|
|
546
|
+
type: "lifecycle",
|
|
547
|
+
phase: "execution_cancelled",
|
|
548
|
+
source: "controller",
|
|
549
|
+
message: `Cancelled active execution before ${reason}.`,
|
|
550
|
+
workerId,
|
|
551
|
+
}, deps);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function serveStaticFile(res: ServerResponse, filePath: string, contentType: string): void {
|
|
555
|
+
try {
|
|
556
|
+
const content = fs.readFileSync(filePath);
|
|
557
|
+
res.writeHead(200, {
|
|
558
|
+
"Content-Type": contentType,
|
|
559
|
+
"Access-Control-Allow-Origin": "*",
|
|
560
|
+
});
|
|
561
|
+
res.end(content);
|
|
562
|
+
} catch {
|
|
563
|
+
sendError(res, 404, "File not found");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function workspaceRequestErrorStatus(err: unknown): number {
|
|
568
|
+
if (err && typeof err === "object" && "code" in err && (err as { code?: unknown }).code === "ENOENT") {
|
|
569
|
+
return 404;
|
|
570
|
+
}
|
|
571
|
+
return 400;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function workspaceRequestErrorMessage(err: unknown): string {
|
|
575
|
+
return err instanceof Error ? err.message : "Workspace request failed";
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function applyTaskResult(
|
|
579
|
+
taskId: string,
|
|
580
|
+
result: string,
|
|
581
|
+
error: string | undefined,
|
|
582
|
+
deps: ControllerHttpDeps,
|
|
583
|
+
): TaskInfo | undefined {
|
|
584
|
+
const { logger, updateTeamState, wsServer } = deps;
|
|
585
|
+
let completionEvent: TaskExecutionEvent | undefined;
|
|
586
|
+
|
|
587
|
+
const state = updateTeamState((s) => {
|
|
588
|
+
const task = s.tasks[taskId];
|
|
589
|
+
if (!task) return;
|
|
590
|
+
|
|
591
|
+
task.status = error ? "failed" : "completed";
|
|
592
|
+
task.result = result;
|
|
593
|
+
task.error = error;
|
|
594
|
+
task.completedAt = Date.now();
|
|
595
|
+
task.updatedAt = Date.now();
|
|
596
|
+
completionEvent = appendTaskExecutionEvent(task, {
|
|
597
|
+
type: error ? "error" : "lifecycle",
|
|
598
|
+
phase: error ? "result_failed" : "result_completed",
|
|
599
|
+
source: "controller",
|
|
600
|
+
status: error ? "failed" : "completed",
|
|
601
|
+
message: error ? `Task failed: ${error}` : "Task completed successfully.",
|
|
602
|
+
workerId: task.assignedWorkerId,
|
|
603
|
+
role: task.assignedRole,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (task.assignedWorkerId && s.workers[task.assignedWorkerId]) {
|
|
607
|
+
s.workers[task.assignedWorkerId].status = "idle";
|
|
608
|
+
s.workers[task.assignedWorkerId].currentTaskId = undefined;
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const updatedTask = state.tasks[taskId];
|
|
613
|
+
if (updatedTask) {
|
|
614
|
+
if (completionEvent) {
|
|
615
|
+
broadcastTaskExecutionEvent(taskId, updatedTask, completionEvent, deps);
|
|
616
|
+
}
|
|
617
|
+
wsServer.broadcastUpdate({ type: "task:completed", data: serializeTask(updatedTask) });
|
|
618
|
+
logger.info(`Controller: task ${taskId} ${error ? "failed" : "completed"}`);
|
|
619
|
+
if (updatedTask.assignedWorkerId) {
|
|
620
|
+
void autoAssignPendingTasks(deps, updatedTask.assignedWorkerId).catch((err) => {
|
|
621
|
+
logger.warn(
|
|
622
|
+
`Controller: failed to auto-assign pending tasks after result for ${taskId}: ${String(err)}`,
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
scheduleProvisioningReconcile(deps, `task-result:${taskId}`);
|
|
627
|
+
if (!error && updatedTask.createdBy === "controller" && updatedTask.controllerSessionKey) {
|
|
628
|
+
void continueControllerWorkflow(updatedTask, deps).catch((err) => {
|
|
629
|
+
logger.warn(
|
|
630
|
+
`Controller: failed to continue intake workflow after ${taskId}: ${String(err)}`,
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return updatedTask;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function revertTaskAssignment(taskId: string, workerId: string, deps: ControllerHttpDeps): TaskInfo | undefined {
|
|
640
|
+
const { updateTeamState, wsServer } = deps;
|
|
641
|
+
let revertEvent: TaskExecutionEvent | undefined;
|
|
642
|
+
|
|
643
|
+
const state = updateTeamState((s) => {
|
|
644
|
+
const task = s.tasks[taskId];
|
|
645
|
+
if (!task) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (task.assignedWorkerId === workerId) {
|
|
650
|
+
task.status = "pending";
|
|
651
|
+
task.assignedWorkerId = undefined;
|
|
652
|
+
task.updatedAt = Date.now();
|
|
653
|
+
revertEvent = appendTaskExecutionEvent(task, {
|
|
654
|
+
type: "error",
|
|
655
|
+
phase: "assignment_reverted",
|
|
656
|
+
source: "controller",
|
|
657
|
+
message: `Assignment to ${workerId} was reverted; task returned to pending.`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const worker = s.workers[workerId];
|
|
662
|
+
if (worker?.currentTaskId === taskId) {
|
|
663
|
+
worker.status = "idle";
|
|
664
|
+
worker.currentTaskId = undefined;
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const updatedTask = state.tasks[taskId];
|
|
669
|
+
if (updatedTask) {
|
|
670
|
+
if (revertEvent) {
|
|
671
|
+
broadcastTaskExecutionEvent(taskId, updatedTask, revertEvent, deps);
|
|
672
|
+
}
|
|
673
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
674
|
+
void autoAssignPendingTasks(deps).catch(() => {
|
|
675
|
+
// Best-effort retry path; assignment failure is already surfaced via task state.
|
|
676
|
+
});
|
|
677
|
+
scheduleProvisioningReconcile(deps, `assignment-reverted:${taskId}`);
|
|
678
|
+
}
|
|
679
|
+
return updatedTask;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function deliverMessageToWorker(
|
|
683
|
+
worker: WorkerInfo,
|
|
684
|
+
message: TeamMessage,
|
|
685
|
+
deps: ControllerHttpDeps,
|
|
686
|
+
): Promise<void> {
|
|
687
|
+
const { localWorkerManager } = deps;
|
|
688
|
+
|
|
689
|
+
if (localWorkerManager?.isLocalWorkerId(worker.id)) {
|
|
690
|
+
const queued = await localWorkerManager.queueMessage(worker.id, message);
|
|
691
|
+
if (queued) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
deps.logger.warn(`Controller: local message path unavailable for ${worker.id}, falling back to worker URL`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const res = await fetch(`${worker.url}/api/v1/messages`, {
|
|
699
|
+
method: "POST",
|
|
700
|
+
headers: { "Content-Type": "application/json" },
|
|
701
|
+
body: JSON.stringify(message),
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
if (!res.ok) {
|
|
705
|
+
throw new Error(`worker ${worker.id} responded with ${res.status}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function routeDirectMessage(
|
|
710
|
+
message: TeamMessage,
|
|
711
|
+
deps: ControllerHttpDeps,
|
|
712
|
+
): Promise<boolean> {
|
|
713
|
+
const { getTeamState, logger, messageRouter } = deps;
|
|
714
|
+
const state = getTeamState();
|
|
715
|
+
if (!state) {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const routed = messageRouter.routeDirectMessage(message, state.workers);
|
|
720
|
+
if (!routed) {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
await deliverMessageToWorker(routed.worker, routed.message, deps);
|
|
726
|
+
} catch (err) {
|
|
727
|
+
logger.warn(`Controller: failed to deliver message to ${routed.worker.id}: ${String(err)}`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function dispatchTaskToWorker(
|
|
734
|
+
taskId: string,
|
|
735
|
+
worker: WorkerInfo,
|
|
736
|
+
deps: ControllerHttpDeps,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
const { getTeamState, localWorkerManager } = deps;
|
|
739
|
+
const state = getTeamState();
|
|
740
|
+
const task = state?.tasks[taskId];
|
|
741
|
+
if (!task) {
|
|
742
|
+
throw new Error(`task ${taskId} not found`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const sharedWorkspace = localWorkerManager?.isLocalWorkerId(worker.id) ?? false;
|
|
746
|
+
const repoState = await refreshControllerRepoState(deps);
|
|
747
|
+
const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace);
|
|
748
|
+
const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo);
|
|
749
|
+
const assignment: TaskAssignmentPayload = {
|
|
750
|
+
taskId: task.id,
|
|
751
|
+
title: task.title,
|
|
752
|
+
description,
|
|
753
|
+
priority: task.priority,
|
|
754
|
+
repo: repoInfo,
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
if (localWorkerManager?.isLocalWorkerId(worker.id)) {
|
|
758
|
+
const accepted = await localWorkerManager.dispatchTask(worker.id, assignment);
|
|
759
|
+
if (accepted) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
deps.logger.warn(`Controller: local dispatch path unavailable for ${worker.id}, falling back to worker URL`);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const res = await fetch(`${worker.url}/api/v1/tasks/assign`, {
|
|
767
|
+
method: "POST",
|
|
768
|
+
headers: { "Content-Type": "application/json" },
|
|
769
|
+
body: JSON.stringify(assignment),
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
if (!res.ok) {
|
|
773
|
+
throw new Error(`worker ${worker.id} responded with ${res.status}`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function assignTaskToWorker(
|
|
778
|
+
taskId: string,
|
|
779
|
+
worker: WorkerInfo,
|
|
780
|
+
deps: ControllerHttpDeps,
|
|
781
|
+
options?: {
|
|
782
|
+
assignedRole?: RoleId;
|
|
783
|
+
},
|
|
784
|
+
): Promise<TaskInfo | undefined> {
|
|
785
|
+
const { logger, updateTeamState } = deps;
|
|
786
|
+
let assignmentApplied = false;
|
|
787
|
+
|
|
788
|
+
updateTeamState((s) => {
|
|
789
|
+
const task = s.tasks[taskId];
|
|
790
|
+
const targetWorker = s.workers[worker.id];
|
|
791
|
+
if (!task || !targetWorker) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (targetWorker.status !== "idle") {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const canAssignCurrentTask =
|
|
798
|
+
task.status === "pending" ||
|
|
799
|
+
(task.status === "assigned" && task.assignedWorkerId === worker.id);
|
|
800
|
+
if (!canAssignCurrentTask) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
task.status = "assigned";
|
|
805
|
+
task.assignedWorkerId = worker.id;
|
|
806
|
+
if (options?.assignedRole) {
|
|
807
|
+
task.assignedRole = options.assignedRole;
|
|
808
|
+
}
|
|
809
|
+
task.updatedAt = Date.now();
|
|
810
|
+
|
|
811
|
+
targetWorker.status = "busy";
|
|
812
|
+
targetWorker.currentTaskId = taskId;
|
|
813
|
+
assignmentApplied = true;
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
if (!assignmentApplied) {
|
|
817
|
+
return deps.getTeamState()?.tasks[taskId];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
await dispatchTaskToWorker(taskId, worker, deps);
|
|
822
|
+
} catch (err) {
|
|
823
|
+
logger.warn(`Controller: failed to dispatch task ${taskId} to ${worker.id}: ${String(err)}`);
|
|
824
|
+
recordTaskExecutionEvent(taskId, {
|
|
825
|
+
type: "error",
|
|
826
|
+
phase: "dispatch_failed",
|
|
827
|
+
source: "controller",
|
|
828
|
+
message: `Failed to dispatch task to ${worker.id}: ${String(err)}`,
|
|
829
|
+
workerId: worker.id,
|
|
830
|
+
role: options?.assignedRole ?? worker.role,
|
|
831
|
+
}, deps);
|
|
832
|
+
return revertTaskAssignment(taskId, worker.id, deps);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
recordTaskExecutionEvent(taskId, {
|
|
836
|
+
type: "lifecycle",
|
|
837
|
+
phase: "assigned",
|
|
838
|
+
source: "controller",
|
|
839
|
+
message: `Assigned to ${worker.label || worker.id}.`,
|
|
840
|
+
workerId: worker.id,
|
|
841
|
+
role: options?.assignedRole ?? worker.role,
|
|
842
|
+
}, deps);
|
|
843
|
+
|
|
844
|
+
return deps.getTeamState()?.tasks[taskId];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function autoAssignPendingTasks(
|
|
848
|
+
deps: ControllerHttpDeps,
|
|
849
|
+
preferredWorkerId?: string,
|
|
850
|
+
): Promise<TaskInfo[]> {
|
|
851
|
+
const { getTeamState, taskRouter, wsServer, logger } = deps;
|
|
852
|
+
const attemptedPairs = new Set<string>();
|
|
853
|
+
const assignedTasks: TaskInfo[] = [];
|
|
854
|
+
|
|
855
|
+
while (true) {
|
|
856
|
+
const state = getTeamState();
|
|
857
|
+
if (!state) {
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const nextAssignment = taskRouter
|
|
862
|
+
.autoAssignPendingTasks(state.tasks, state.workers)
|
|
863
|
+
.filter(({ worker }) => !preferredWorkerId || worker.id === preferredWorkerId)
|
|
864
|
+
.find(({ task, worker }) => !attemptedPairs.has(`${task.id}:${worker.id}`));
|
|
865
|
+
|
|
866
|
+
if (!nextAssignment) {
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const pairKey = `${nextAssignment.task.id}:${nextAssignment.worker.id}`;
|
|
871
|
+
attemptedPairs.add(pairKey);
|
|
872
|
+
|
|
873
|
+
const updatedTask = await assignTaskToWorker(nextAssignment.task.id, nextAssignment.worker, deps, {
|
|
874
|
+
assignedRole: nextAssignment.task.assignedRole,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
if (updatedTask?.status === "assigned" && updatedTask.assignedWorkerId === nextAssignment.worker.id) {
|
|
878
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
879
|
+
logger.info(
|
|
880
|
+
`Controller: auto-assigned pending task ${updatedTask.id} to ${nextAssignment.worker.id}`,
|
|
881
|
+
);
|
|
882
|
+
assignedTasks.push(updatedTask);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
scheduleProvisioningReconcile(deps, preferredWorkerId
|
|
887
|
+
? `auto-assign:${preferredWorkerId}`
|
|
888
|
+
: "auto-assign");
|
|
889
|
+
|
|
890
|
+
return assignedTasks;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function createControllerHttpServer(deps: ControllerHttpDeps): http.Server {
|
|
894
|
+
const { logger, wsServer } = deps;
|
|
895
|
+
|
|
896
|
+
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
897
|
+
// CORS preflight
|
|
898
|
+
if (req.method === "OPTIONS") {
|
|
899
|
+
res.writeHead(200, {
|
|
900
|
+
"Access-Control-Allow-Origin": "*",
|
|
901
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
902
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
903
|
+
});
|
|
904
|
+
res.end();
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
909
|
+
const pathname = url.pathname;
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
await handleRequest(req, res, pathname, deps);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
logger.error(`Controller HTTP error: ${err instanceof Error ? err.message : String(err)}`);
|
|
915
|
+
sendError(res, 500, "Internal server error");
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// Attach WebSocket
|
|
920
|
+
wsServer.attach(server);
|
|
921
|
+
|
|
922
|
+
return server;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function handleRequest(
|
|
926
|
+
req: IncomingMessage,
|
|
927
|
+
res: ServerResponse,
|
|
928
|
+
pathname: string,
|
|
929
|
+
deps: ControllerHttpDeps,
|
|
930
|
+
): Promise<void> {
|
|
931
|
+
const { config, logger, getTeamState, updateTeamState, taskRouter, messageRouter, wsServer } = deps;
|
|
932
|
+
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
933
|
+
|
|
934
|
+
// ==================== Web UI ====================
|
|
935
|
+
if (req.method === "GET" && (pathname === "/ui" || pathname === "/ui/")) {
|
|
936
|
+
const uiPath = path.join(import.meta.dirname, "..", "ui");
|
|
937
|
+
serveStaticFile(res, path.join(uiPath, "index.html"), "text/html; charset=utf-8");
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (req.method === "GET" && pathname.startsWith("/ui/")) {
|
|
942
|
+
const uiPath = path.join(import.meta.dirname, "..", "ui");
|
|
943
|
+
const file = pathname.slice(4); // remove "/ui/"
|
|
944
|
+
if (file.endsWith(".css")) {
|
|
945
|
+
serveStaticFile(res, path.join(uiPath, file), "text/css; charset=utf-8");
|
|
946
|
+
} else if (file.endsWith(".js")) {
|
|
947
|
+
serveStaticFile(res, path.join(uiPath, file), "application/javascript; charset=utf-8");
|
|
948
|
+
} else {
|
|
949
|
+
serveStaticFile(res, path.join(uiPath, file), "application/octet-stream");
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ==================== Workspace Browser ====================
|
|
955
|
+
|
|
956
|
+
if (req.method === "GET" && pathname === "/api/v1/workspace/tree") {
|
|
957
|
+
try {
|
|
958
|
+
sendJson(res, 200, await listWorkspaceTree());
|
|
959
|
+
} catch (err) {
|
|
960
|
+
sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
|
|
961
|
+
}
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (req.method === "GET" && pathname === "/api/v1/workspace/file") {
|
|
966
|
+
const relativePath = requestUrl.searchParams.get("path") ?? "";
|
|
967
|
+
if (!relativePath) {
|
|
968
|
+
sendError(res, 400, "path is required");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
try {
|
|
973
|
+
sendJson(res, 200, { file: await readWorkspaceFile(relativePath) });
|
|
974
|
+
} catch (err) {
|
|
975
|
+
sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
|
|
976
|
+
}
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (req.method === "GET" && pathname.startsWith("/api/v1/workspace/raw/")) {
|
|
981
|
+
const rawPathname = pathname.slice("/api/v1/workspace/raw/".length);
|
|
982
|
+
if (!rawPathname) {
|
|
983
|
+
sendError(res, 400, "path is required");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
try {
|
|
988
|
+
const relativePath = decodeURIComponent(rawPathname);
|
|
989
|
+
const file = await readWorkspaceRawFile(relativePath);
|
|
990
|
+
res.writeHead(200, {
|
|
991
|
+
"Content-Type": file.contentType,
|
|
992
|
+
"Cache-Control": "no-store",
|
|
993
|
+
"Access-Control-Allow-Origin": "*",
|
|
994
|
+
"X-Content-Type-Options": "nosniff",
|
|
995
|
+
});
|
|
996
|
+
res.end(file.content);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
|
|
999
|
+
}
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ==================== Worker Management ====================
|
|
1004
|
+
|
|
1005
|
+
// POST /api/v1/workers/register
|
|
1006
|
+
if (req.method === "POST" && pathname === "/api/v1/workers/register") {
|
|
1007
|
+
const body = await parseJsonBody(req);
|
|
1008
|
+
const workerId = typeof body.workerId === "string" ? body.workerId : "";
|
|
1009
|
+
const role = typeof body.role === "string" ? body.role as RoleId : "";
|
|
1010
|
+
const label = typeof body.label === "string" ? body.label : role;
|
|
1011
|
+
const workerUrl = typeof body.url === "string" ? body.url : "";
|
|
1012
|
+
const capabilities = Array.isArray(body.capabilities) ? body.capabilities as string[] : [];
|
|
1013
|
+
const launchToken = typeof body.launchToken === "string" ? body.launchToken : undefined;
|
|
1014
|
+
|
|
1015
|
+
if (!workerId || !role || !workerUrl) {
|
|
1016
|
+
sendError(res, 400, "workerId, role, and url are required");
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const registrationValidation = deps.workerProvisioningManager?.validateRegistration(workerId, role, launchToken);
|
|
1021
|
+
if (registrationValidation && !registrationValidation.ok) {
|
|
1022
|
+
sendError(res, 403, registrationValidation.reason ?? "Worker registration rejected");
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const state = updateTeamState((s) => {
|
|
1027
|
+
s.workers[workerId] = {
|
|
1028
|
+
id: workerId,
|
|
1029
|
+
role,
|
|
1030
|
+
label,
|
|
1031
|
+
status: "idle",
|
|
1032
|
+
transport: "http",
|
|
1033
|
+
url: workerUrl,
|
|
1034
|
+
lastHeartbeat: Date.now(),
|
|
1035
|
+
capabilities,
|
|
1036
|
+
registeredAt: Date.now(),
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
deps.workerProvisioningManager?.onWorkerRegistered(workerId);
|
|
1040
|
+
|
|
1041
|
+
wsServer.broadcastUpdate({ type: "worker:online", data: state.workers[workerId] });
|
|
1042
|
+
logger.info(`Controller: worker registered - ${label} (${workerId}) at ${workerUrl}`);
|
|
1043
|
+
sendJson(res, 201, { status: "registered", worker: state.workers[workerId] });
|
|
1044
|
+
void autoAssignPendingTasks(deps, workerId).catch((err) => {
|
|
1045
|
+
logger.warn(`Controller: failed to auto-assign after worker registration (${workerId}): ${String(err)}`);
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// DELETE /api/v1/workers/:id
|
|
1051
|
+
if (req.method === "DELETE" && pathname.match(/^\/api\/v1\/workers\/[^/]+$/)) {
|
|
1052
|
+
const workerId = pathname.split("/").pop()!;
|
|
1053
|
+
if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
|
|
1054
|
+
sendError(res, 400, "Local workers are managed by controller config");
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (deps.workerProvisioningManager?.hasManagedWorker(workerId)) {
|
|
1059
|
+
await deps.workerProvisioningManager.onWorkerRemoved(workerId, "worker delete requested");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const affectedTaskIds: string[] = [];
|
|
1063
|
+
updateTeamState((s) => {
|
|
1064
|
+
const worker = s.workers[workerId];
|
|
1065
|
+
if (worker) {
|
|
1066
|
+
worker.status = "offline";
|
|
1067
|
+
worker.currentTaskId = undefined;
|
|
1068
|
+
delete s.workers[workerId];
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
for (const task of Object.values(s.tasks)) {
|
|
1072
|
+
if (
|
|
1073
|
+
task.assignedWorkerId === workerId &&
|
|
1074
|
+
task.status !== "completed" &&
|
|
1075
|
+
task.status !== "failed" &&
|
|
1076
|
+
task.status !== "blocked"
|
|
1077
|
+
) {
|
|
1078
|
+
task.status = "pending";
|
|
1079
|
+
task.assignedWorkerId = undefined;
|
|
1080
|
+
task.updatedAt = Date.now();
|
|
1081
|
+
affectedTaskIds.push(task.id);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
await autoAssignPendingTasks(deps);
|
|
1087
|
+
for (const taskId of affectedTaskIds) {
|
|
1088
|
+
const task = getTeamState()?.tasks[taskId];
|
|
1089
|
+
if (task) {
|
|
1090
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(task) });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
wsServer.broadcastUpdate({ type: "worker:offline", data: { workerId } });
|
|
1094
|
+
logger.info(`Controller: worker removed - ${workerId}`);
|
|
1095
|
+
sendJson(res, 200, { status: "removed" });
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// GET /api/v1/workers
|
|
1100
|
+
if (req.method === "GET" && pathname === "/api/v1/workers") {
|
|
1101
|
+
const state = getTeamState();
|
|
1102
|
+
const workers = state ? Object.values(state.workers) : [];
|
|
1103
|
+
sendJson(res, 200, { workers });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// POST /api/v1/workers/:id/heartbeat
|
|
1108
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/workers\/[^/]+\/heartbeat$/)) {
|
|
1109
|
+
const workerId = pathname.split("/")[4]!;
|
|
1110
|
+
const body = await parseJsonBody(req);
|
|
1111
|
+
const status = typeof body.status === "string" ? body.status as WorkerInfo["status"] : "idle";
|
|
1112
|
+
const currentTaskId = typeof body.currentTaskId === "string" ? body.currentTaskId : undefined;
|
|
1113
|
+
|
|
1114
|
+
updateTeamState((s) => {
|
|
1115
|
+
if (s.workers[workerId]) {
|
|
1116
|
+
s.workers[workerId].lastHeartbeat = Date.now();
|
|
1117
|
+
s.workers[workerId].status = status;
|
|
1118
|
+
s.workers[workerId].currentTaskId = currentTaskId;
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
deps.workerProvisioningManager?.onWorkerHeartbeat(workerId, status);
|
|
1122
|
+
|
|
1123
|
+
if (status === "idle") {
|
|
1124
|
+
await autoAssignPendingTasks(deps, workerId);
|
|
1125
|
+
} else {
|
|
1126
|
+
scheduleProvisioningReconcile(deps, `heartbeat:${workerId}:${status}`);
|
|
1127
|
+
}
|
|
1128
|
+
sendJson(res, 200, { status: "ok" });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ==================== Task Management ====================
|
|
1133
|
+
|
|
1134
|
+
// POST /api/v1/tasks
|
|
1135
|
+
if (req.method === "POST" && pathname === "/api/v1/tasks") {
|
|
1136
|
+
const body = await parseJsonBody(req);
|
|
1137
|
+
const title = typeof body.title === "string" ? body.title : "";
|
|
1138
|
+
const description = typeof body.description === "string" ? body.description : "";
|
|
1139
|
+
const priority = typeof body.priority === "string" ? body.priority as TaskPriority : "medium";
|
|
1140
|
+
const assignedRole = typeof body.assignedRole === "string" ? body.assignedRole as RoleId : undefined;
|
|
1141
|
+
const createdBy = typeof body.createdBy === "string" ? body.createdBy : "boss";
|
|
1142
|
+
|
|
1143
|
+
if (!title) {
|
|
1144
|
+
sendError(res, 400, "title is required");
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const taskId = generateId();
|
|
1149
|
+
const now = Date.now();
|
|
1150
|
+
const repoState = await refreshControllerRepoState(deps);
|
|
1151
|
+
|
|
1152
|
+
const task: TaskInfo = {
|
|
1153
|
+
id: taskId,
|
|
1154
|
+
title,
|
|
1155
|
+
description,
|
|
1156
|
+
status: "pending",
|
|
1157
|
+
priority,
|
|
1158
|
+
assignedRole,
|
|
1159
|
+
createdBy,
|
|
1160
|
+
createdAt: now,
|
|
1161
|
+
updatedAt: now,
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
updateTeamState((s) => {
|
|
1165
|
+
s.tasks[taskId] = task;
|
|
1166
|
+
});
|
|
1167
|
+
recordTaskExecutionEvent(taskId, {
|
|
1168
|
+
type: "lifecycle",
|
|
1169
|
+
phase: "created",
|
|
1170
|
+
source: "controller",
|
|
1171
|
+
status: "pending",
|
|
1172
|
+
message: `Task created by ${createdBy}.`,
|
|
1173
|
+
role: assignedRole,
|
|
1174
|
+
}, deps);
|
|
1175
|
+
if (repoState?.enabled) {
|
|
1176
|
+
recordTaskExecutionEvent(taskId, {
|
|
1177
|
+
type: "lifecycle",
|
|
1178
|
+
phase: "repo_ready",
|
|
1179
|
+
source: "controller",
|
|
1180
|
+
status: "pending",
|
|
1181
|
+
message: repoState.remoteReady && repoState.remoteUrl
|
|
1182
|
+
? `Git collaboration ready on ${repoState.defaultBranch} with remote ${repoState.remoteUrl}.`
|
|
1183
|
+
: `Git collaboration ready on ${repoState.defaultBranch} using controller-managed bundle sync.`,
|
|
1184
|
+
role: assignedRole,
|
|
1185
|
+
}, deps);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
await autoAssignPendingTasks(deps);
|
|
1189
|
+
|
|
1190
|
+
const updatedTask = getTeamState()?.tasks[taskId];
|
|
1191
|
+
wsServer.broadcastUpdate({ type: "task:created", data: serializeTask(updatedTask) });
|
|
1192
|
+
sendJson(res, 201, { task: serializeTask(updatedTask) });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// GET /api/v1/tasks
|
|
1197
|
+
if (req.method === "GET" && pathname === "/api/v1/tasks") {
|
|
1198
|
+
const state = getTeamState();
|
|
1199
|
+
const tasks = state ? Object.values(state.tasks).map((task) => serializeTask(task)) : [];
|
|
1200
|
+
sendJson(res, 200, { tasks });
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// GET /api/v1/tasks/:id
|
|
1205
|
+
if (req.method === "GET" && pathname.match(/^\/api\/v1\/tasks\/[^/]+$/)) {
|
|
1206
|
+
const taskId = pathname.split("/").pop()!;
|
|
1207
|
+
const state = getTeamState();
|
|
1208
|
+
const task = state?.tasks[taskId];
|
|
1209
|
+
if (!task) {
|
|
1210
|
+
sendError(res, 404, "Task not found");
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
sendJson(res, 200, { task: serializeTask(task) });
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// GET /api/v1/tasks/:id/execution
|
|
1218
|
+
if (req.method === "GET" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/execution$/)) {
|
|
1219
|
+
const taskId = pathname.split("/")[4]!;
|
|
1220
|
+
const state = getTeamState();
|
|
1221
|
+
const task = state?.tasks[taskId];
|
|
1222
|
+
if (!task) {
|
|
1223
|
+
sendError(res, 404, "Task not found");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const clarifications = state
|
|
1228
|
+
? Object.values(state.clarifications)
|
|
1229
|
+
.filter((item) => item.taskId === taskId)
|
|
1230
|
+
.sort((left, right) => left.createdAt - right.createdAt)
|
|
1231
|
+
: [];
|
|
1232
|
+
const messages = state
|
|
1233
|
+
? state.messages
|
|
1234
|
+
.filter((message) => message.taskId === taskId)
|
|
1235
|
+
.sort((left, right) => left.createdAt - right.createdAt)
|
|
1236
|
+
: [];
|
|
1237
|
+
|
|
1238
|
+
sendJson(res, 200, {
|
|
1239
|
+
task: serializeTask(task, true),
|
|
1240
|
+
messages,
|
|
1241
|
+
clarifications,
|
|
1242
|
+
});
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// PATCH /api/v1/tasks/:id
|
|
1247
|
+
if (req.method === "PATCH" && pathname.match(/^\/api\/v1\/tasks\/[^/]+$/)) {
|
|
1248
|
+
const taskId = pathname.split("/").pop()!;
|
|
1249
|
+
const body = await parseJsonBody(req);
|
|
1250
|
+
let statusEvent: TaskExecutionEvent | undefined;
|
|
1251
|
+
let progressEvent: TaskExecutionEvent | undefined;
|
|
1252
|
+
|
|
1253
|
+
const state = updateTeamState((s) => {
|
|
1254
|
+
const task = s.tasks[taskId];
|
|
1255
|
+
if (!task) return;
|
|
1256
|
+
const previousStatus = task.status;
|
|
1257
|
+
const previousProgress = task.progress;
|
|
1258
|
+
if (typeof body.status === "string") task.status = body.status as TaskStatus;
|
|
1259
|
+
if (typeof body.progress === "string") task.progress = body.progress as string;
|
|
1260
|
+
if (typeof body.priority === "string") task.priority = body.priority as TaskPriority;
|
|
1261
|
+
if (typeof body.assignedRole === "string") task.assignedRole = body.assignedRole as RoleId;
|
|
1262
|
+
task.updatedAt = Date.now();
|
|
1263
|
+
|
|
1264
|
+
if (typeof body.status === "string" && body.status !== previousStatus) {
|
|
1265
|
+
statusEvent = appendTaskExecutionEvent(task, {
|
|
1266
|
+
type: "lifecycle",
|
|
1267
|
+
phase: `status_${task.status}`,
|
|
1268
|
+
source: "controller",
|
|
1269
|
+
status: mapTaskStatusToExecutionStatus(task.status, task.execution?.status),
|
|
1270
|
+
message: `Task status updated to ${task.status}.`,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
if (typeof body.progress === "string" && body.progress !== previousProgress) {
|
|
1274
|
+
progressEvent = appendTaskExecutionEvent(task, {
|
|
1275
|
+
type: "progress",
|
|
1276
|
+
phase: "progress_reported",
|
|
1277
|
+
source: "worker",
|
|
1278
|
+
status: task.status === "in_progress" || task.status === "review" ? "running" : undefined,
|
|
1279
|
+
message: body.progress as string,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
const updatedTask = state.tasks[taskId];
|
|
1285
|
+
if (updatedTask) {
|
|
1286
|
+
if (statusEvent) {
|
|
1287
|
+
broadcastTaskExecutionEvent(taskId, updatedTask, statusEvent, deps);
|
|
1288
|
+
}
|
|
1289
|
+
if (progressEvent) {
|
|
1290
|
+
broadcastTaskExecutionEvent(taskId, updatedTask, progressEvent, deps);
|
|
1291
|
+
}
|
|
1292
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
1293
|
+
}
|
|
1294
|
+
sendJson(res, 200, { task: serializeTask(updatedTask) });
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// POST /api/v1/tasks/:id/assign
|
|
1299
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/assign$/)) {
|
|
1300
|
+
const taskId = pathname.split("/")[4]!;
|
|
1301
|
+
const body = await parseJsonBody(req);
|
|
1302
|
+
const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
|
|
1303
|
+
const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined;
|
|
1304
|
+
|
|
1305
|
+
const state = getTeamState();
|
|
1306
|
+
if (!state?.tasks[taskId]) {
|
|
1307
|
+
sendError(res, 404, "Task not found");
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
let targetWorker: WorkerInfo | null = null;
|
|
1312
|
+
if (workerId && state.workers[workerId]) {
|
|
1313
|
+
targetWorker = state.workers[workerId]!;
|
|
1314
|
+
} else {
|
|
1315
|
+
const taskForRouting = targetRole
|
|
1316
|
+
? { ...state.tasks[taskId], assignedRole: targetRole }
|
|
1317
|
+
: state.tasks[taskId];
|
|
1318
|
+
targetWorker = taskRouter.routeTask(taskForRouting, state.workers);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (!targetWorker) {
|
|
1322
|
+
sendError(res, 404, "No available worker for this task");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const updatedTask = await assignTaskToWorker(taskId, targetWorker, deps, {
|
|
1327
|
+
assignedRole: targetRole,
|
|
1328
|
+
});
|
|
1329
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
1330
|
+
sendJson(res, 200, { task: serializeTask(updatedTask), worker: targetWorker });
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// POST /api/v1/tasks/:id/handoff
|
|
1335
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/handoff$/)) {
|
|
1336
|
+
const taskId = pathname.split("/")[4]!;
|
|
1337
|
+
const body = await parseJsonBody(req);
|
|
1338
|
+
const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined;
|
|
1339
|
+
|
|
1340
|
+
const state = getTeamState();
|
|
1341
|
+
if (!state?.tasks[taskId]) {
|
|
1342
|
+
sendError(res, 404, "Task not found");
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const previousWorkerId = state.tasks[taskId].assignedWorkerId;
|
|
1347
|
+
|
|
1348
|
+
updateTeamState((s) => {
|
|
1349
|
+
s.tasks[taskId].status = "pending";
|
|
1350
|
+
s.tasks[taskId].assignedWorkerId = undefined;
|
|
1351
|
+
s.tasks[taskId].assignedRole = targetRole ?? s.tasks[taskId].assignedRole;
|
|
1352
|
+
s.tasks[taskId].updatedAt = Date.now();
|
|
1353
|
+
|
|
1354
|
+
// Free old worker
|
|
1355
|
+
if (previousWorkerId && s.workers[previousWorkerId]) {
|
|
1356
|
+
s.workers[previousWorkerId].status = "idle";
|
|
1357
|
+
s.workers[previousWorkerId].currentTaskId = undefined;
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
await cancelTaskExecution(taskId, previousWorkerId, "handoff", deps);
|
|
1362
|
+
|
|
1363
|
+
// Try auto-assign to new role
|
|
1364
|
+
const newState = getTeamState()!;
|
|
1365
|
+
const worker = taskRouter.routeTask(newState.tasks[taskId], newState.workers);
|
|
1366
|
+
if (worker) {
|
|
1367
|
+
await assignTaskToWorker(taskId, worker, deps, { assignedRole: targetRole });
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const updatedTask = getTeamState()?.tasks[taskId];
|
|
1371
|
+
recordTaskExecutionEvent(taskId, {
|
|
1372
|
+
type: "lifecycle",
|
|
1373
|
+
phase: "handoff",
|
|
1374
|
+
source: "controller",
|
|
1375
|
+
message: targetRole
|
|
1376
|
+
? `Task handed off and re-routed to role ${targetRole}.`
|
|
1377
|
+
: "Task handed off for re-routing.",
|
|
1378
|
+
role: targetRole,
|
|
1379
|
+
}, deps);
|
|
1380
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
1381
|
+
sendJson(res, 200, { task: serializeTask(updatedTask) });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// POST /api/v1/tasks/:id/result
|
|
1386
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/result$/)) {
|
|
1387
|
+
const taskId = pathname.split("/")[4]!;
|
|
1388
|
+
const body = await parseJsonBody(req);
|
|
1389
|
+
const result = typeof body.result === "string" ? body.result : "";
|
|
1390
|
+
const error = typeof body.error === "string" ? body.error : undefined;
|
|
1391
|
+
const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
|
|
1392
|
+
const currentTask = getTeamState()?.tasks[taskId];
|
|
1393
|
+
if (!currentTask) {
|
|
1394
|
+
sendError(res, 404, "Task not found");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) {
|
|
1398
|
+
logger.info(`Controller: ignoring stale task result for ${taskId} from ${workerId}`);
|
|
1399
|
+
sendJson(res, 202, { status: "ignored", reason: "stale-worker-result" });
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const previousWorkerId = getTeamState()?.tasks[taskId]?.assignedWorkerId;
|
|
1403
|
+
|
|
1404
|
+
const updatedTask = applyTaskResult(taskId, result, error, deps);
|
|
1405
|
+
if (!workerId || workerId !== previousWorkerId) {
|
|
1406
|
+
await cancelTaskExecution(taskId, previousWorkerId, "manual result submission", deps);
|
|
1407
|
+
}
|
|
1408
|
+
sendJson(res, 200, { task: serializeTask(updatedTask) });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// POST /api/v1/tasks/:id/execution
|
|
1413
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/execution$/)) {
|
|
1414
|
+
const taskId = pathname.split("/")[4]!;
|
|
1415
|
+
const body = await parseJsonBody(req);
|
|
1416
|
+
const type = typeof body.type === "string" ? body.type : "";
|
|
1417
|
+
const message = typeof body.message === "string" ? body.message : "";
|
|
1418
|
+
const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
|
|
1419
|
+
const currentTask = getTeamState()?.tasks[taskId];
|
|
1420
|
+
|
|
1421
|
+
if (!type || !message) {
|
|
1422
|
+
sendError(res, 400, "type and message are required");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (!currentTask) {
|
|
1427
|
+
sendError(res, 404, "Task not found");
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) {
|
|
1432
|
+
logger.info(`Controller: ignoring stale execution event for ${taskId} from ${workerId}`);
|
|
1433
|
+
sendJson(res, 202, { status: "ignored", reason: "stale-worker-event" });
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const recorded = recordTaskExecutionEvent(taskId, {
|
|
1438
|
+
type: type as TaskExecutionEventInput["type"],
|
|
1439
|
+
message,
|
|
1440
|
+
createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined,
|
|
1441
|
+
phase: typeof body.phase === "string" ? body.phase : undefined,
|
|
1442
|
+
source: typeof body.source === "string" ? body.source as TaskExecutionEventInput["source"] : undefined,
|
|
1443
|
+
stream: typeof body.stream === "string" ? body.stream : undefined,
|
|
1444
|
+
role: typeof body.role === "string" ? body.role as RoleId : undefined,
|
|
1445
|
+
workerId,
|
|
1446
|
+
runId: typeof body.runId === "string" ? body.runId : undefined,
|
|
1447
|
+
sessionKey: typeof body.sessionKey === "string" ? body.sessionKey : undefined,
|
|
1448
|
+
status: typeof body.status === "string" ? body.status as TaskExecutionEventInput["status"] : undefined,
|
|
1449
|
+
}, deps);
|
|
1450
|
+
|
|
1451
|
+
if (!recorded.task || !recorded.event) {
|
|
1452
|
+
sendError(res, 404, "Task not found");
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
sendJson(res, 201, {
|
|
1457
|
+
task: serializeTask(recorded.task),
|
|
1458
|
+
execution: buildTaskExecutionSummary(recorded.task.execution),
|
|
1459
|
+
event: recorded.event,
|
|
1460
|
+
});
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// ==================== Message Routing ====================
|
|
1465
|
+
|
|
1466
|
+
// POST /api/v1/controller/intake
|
|
1467
|
+
if (req.method === "POST" && pathname === "/api/v1/controller/intake") {
|
|
1468
|
+
const body = await parseJsonBody(req);
|
|
1469
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
1470
|
+
if (!message) {
|
|
1471
|
+
sendError(res, 400, "message is required");
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const sessionKey = normalizeControllerIntakeSessionKey(body.sessionKey);
|
|
1476
|
+
|
|
1477
|
+
try {
|
|
1478
|
+
const result = await runControllerIntake(message, sessionKey, deps);
|
|
1479
|
+
sendJson(res, 200, result);
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1482
|
+
logger.warn(`Controller: intake failed for ${sessionKey}: ${errorMessage}`);
|
|
1483
|
+
sendError(res, errorMessage.includes("timed out") ? 504 : 500, errorMessage);
|
|
1484
|
+
}
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// POST /api/v1/messages/direct
|
|
1489
|
+
if (req.method === "POST" && pathname === "/api/v1/messages/direct") {
|
|
1490
|
+
const body = await parseJsonBody(req);
|
|
1491
|
+
const message: TeamMessage = {
|
|
1492
|
+
id: generateId(),
|
|
1493
|
+
from: typeof body.from === "string" ? body.from : "",
|
|
1494
|
+
fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
|
|
1495
|
+
toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
|
|
1496
|
+
type: "direct",
|
|
1497
|
+
content: typeof body.content === "string" ? body.content : "",
|
|
1498
|
+
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
1499
|
+
createdAt: Date.now(),
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
updateTeamState((s) => { s.messages.push(message); });
|
|
1503
|
+
|
|
1504
|
+
const routed = await routeDirectMessage(message, deps);
|
|
1505
|
+
|
|
1506
|
+
wsServer.broadcastUpdate({ type: "message:new", data: message });
|
|
1507
|
+
sendJson(res, 201, { status: routed ? "delivered" : "no-target", message });
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// POST /api/v1/messages/broadcast
|
|
1512
|
+
if (req.method === "POST" && pathname === "/api/v1/messages/broadcast") {
|
|
1513
|
+
const body = await parseJsonBody(req);
|
|
1514
|
+
const message: TeamMessage = {
|
|
1515
|
+
id: generateId(),
|
|
1516
|
+
from: typeof body.from === "string" ? body.from : "",
|
|
1517
|
+
fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
|
|
1518
|
+
type: "broadcast",
|
|
1519
|
+
content: typeof body.content === "string" ? body.content : "",
|
|
1520
|
+
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
1521
|
+
createdAt: Date.now(),
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
updateTeamState((s) => { s.messages.push(message); });
|
|
1525
|
+
|
|
1526
|
+
const state = getTeamState()!;
|
|
1527
|
+
const routed = messageRouter.routeBroadcast(message, state.workers);
|
|
1528
|
+
for (const { worker, message: routedMsg } of routed) {
|
|
1529
|
+
try {
|
|
1530
|
+
await deliverMessageToWorker(worker, routedMsg, deps);
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
logger.warn(`Controller: failed to broadcast to ${worker.id}: ${String(err)}`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
wsServer.broadcastUpdate({ type: "message:new", data: message });
|
|
1537
|
+
sendJson(res, 201, { status: "broadcast", recipients: routed.length });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// POST /api/v1/messages/review-request
|
|
1542
|
+
if (req.method === "POST" && pathname === "/api/v1/messages/review-request") {
|
|
1543
|
+
const body = await parseJsonBody(req);
|
|
1544
|
+
const message: TeamMessage = {
|
|
1545
|
+
id: generateId(),
|
|
1546
|
+
from: typeof body.from === "string" ? body.from : "",
|
|
1547
|
+
fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
|
|
1548
|
+
toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
|
|
1549
|
+
type: "review-request",
|
|
1550
|
+
content: typeof body.content === "string" ? body.content : "",
|
|
1551
|
+
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
1552
|
+
createdAt: Date.now(),
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
updateTeamState((s) => { s.messages.push(message); });
|
|
1556
|
+
|
|
1557
|
+
const state = getTeamState()!;
|
|
1558
|
+
const routed = messageRouter.routeReviewRequest(message, state.workers);
|
|
1559
|
+
if (routed) {
|
|
1560
|
+
try {
|
|
1561
|
+
await deliverMessageToWorker(routed.worker, routed.message, deps);
|
|
1562
|
+
} catch (err) {
|
|
1563
|
+
logger.warn(`Controller: failed to deliver review request: ${String(err)}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
wsServer.broadcastUpdate({ type: "message:new", data: message });
|
|
1568
|
+
sendJson(res, 201, { status: routed ? "delivered" : "no-target", message });
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// GET /api/v1/messages
|
|
1573
|
+
if (req.method === "GET" && pathname === "/api/v1/messages") {
|
|
1574
|
+
const state = getTeamState();
|
|
1575
|
+
const messages = state?.messages ?? [];
|
|
1576
|
+
const limit = parseInt(requestUrl.searchParams.get("limit") ?? "50", 10);
|
|
1577
|
+
const offset = parseInt(requestUrl.searchParams.get("offset") ?? "0", 10);
|
|
1578
|
+
sendJson(res, 200, {
|
|
1579
|
+
messages: messages.slice(offset, offset + limit),
|
|
1580
|
+
total: messages.length,
|
|
1581
|
+
});
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// ==================== Clarification Requests ====================
|
|
1586
|
+
|
|
1587
|
+
// POST /api/v1/clarifications
|
|
1588
|
+
if (req.method === "POST" && pathname === "/api/v1/clarifications") {
|
|
1589
|
+
const body = await parseJsonBody(req);
|
|
1590
|
+
const taskId = typeof body.taskId === "string" ? body.taskId : "";
|
|
1591
|
+
const requestedBy = typeof body.requestedBy === "string" ? body.requestedBy : "";
|
|
1592
|
+
const requestedByWorkerId = typeof body.requestedByWorkerId === "string" ? body.requestedByWorkerId : undefined;
|
|
1593
|
+
const requestedByRole = typeof body.requestedByRole === "string" ? body.requestedByRole as RoleId : undefined;
|
|
1594
|
+
const question = typeof body.question === "string" ? body.question.trim() : "";
|
|
1595
|
+
const blockingReason = typeof body.blockingReason === "string" ? body.blockingReason.trim() : "";
|
|
1596
|
+
const context = typeof body.context === "string" && body.context.trim() ? body.context.trim() : undefined;
|
|
1597
|
+
|
|
1598
|
+
if (!taskId || !question || !blockingReason) {
|
|
1599
|
+
sendError(res, 400, "taskId, question, and blockingReason are required");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const currentState = getTeamState();
|
|
1604
|
+
const currentTask = currentState?.tasks[taskId];
|
|
1605
|
+
if (!currentTask) {
|
|
1606
|
+
sendError(res, 404, "Task not found");
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
if (currentTask.clarificationRequestId) {
|
|
1611
|
+
const existing = currentState?.clarifications[currentTask.clarificationRequestId];
|
|
1612
|
+
if (existing?.status === "pending") {
|
|
1613
|
+
sendJson(res, 200, { clarification: existing, task: currentTask, status: "already-pending" });
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (currentTask.status === "completed" || currentTask.status === "failed") {
|
|
1619
|
+
sendError(res, 409, "Cannot request clarification for a completed task");
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const previousWorkerId = currentTask.assignedWorkerId;
|
|
1624
|
+
|
|
1625
|
+
const clarificationId = generateId();
|
|
1626
|
+
const now = Date.now();
|
|
1627
|
+
const clarification: ClarificationRequest = {
|
|
1628
|
+
id: clarificationId,
|
|
1629
|
+
taskId,
|
|
1630
|
+
requestedBy,
|
|
1631
|
+
requestedByWorkerId,
|
|
1632
|
+
requestedByRole,
|
|
1633
|
+
question,
|
|
1634
|
+
blockingReason,
|
|
1635
|
+
context,
|
|
1636
|
+
status: "pending",
|
|
1637
|
+
createdAt: now,
|
|
1638
|
+
updatedAt: now,
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
const state = updateTeamState((s) => {
|
|
1642
|
+
s.clarifications[clarificationId] = clarification;
|
|
1643
|
+
const task = s.tasks[taskId];
|
|
1644
|
+
if (!task) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const assignedWorkerId = task.assignedWorkerId;
|
|
1649
|
+
task.status = "blocked";
|
|
1650
|
+
task.progress = `Awaiting clarification: ${question}`;
|
|
1651
|
+
task.clarificationRequestId = clarificationId;
|
|
1652
|
+
task.assignedWorkerId = undefined;
|
|
1653
|
+
task.updatedAt = now;
|
|
1654
|
+
|
|
1655
|
+
if (assignedWorkerId && s.workers[assignedWorkerId]) {
|
|
1656
|
+
s.workers[assignedWorkerId].status = "idle";
|
|
1657
|
+
s.workers[assignedWorkerId].currentTaskId = undefined;
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
await cancelTaskExecution(taskId, previousWorkerId, "clarification request", deps);
|
|
1662
|
+
|
|
1663
|
+
const updatedTask = state.tasks[taskId];
|
|
1664
|
+
wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
|
|
1665
|
+
if (updatedTask) {
|
|
1666
|
+
recordTaskExecutionEvent(taskId, {
|
|
1667
|
+
type: "lifecycle",
|
|
1668
|
+
phase: "clarification_requested",
|
|
1669
|
+
source: "controller",
|
|
1670
|
+
message: `Clarification requested: ${question}`,
|
|
1671
|
+
role: clarification.requestedByRole,
|
|
1672
|
+
workerId: clarification.requestedByWorkerId,
|
|
1673
|
+
}, deps);
|
|
1674
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
1675
|
+
}
|
|
1676
|
+
sendJson(res, 201, { clarification, task: serializeTask(updatedTask) });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// GET /api/v1/clarifications
|
|
1681
|
+
if (req.method === "GET" && pathname === "/api/v1/clarifications") {
|
|
1682
|
+
const state = getTeamState();
|
|
1683
|
+
const clarifications = state
|
|
1684
|
+
? Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt)
|
|
1685
|
+
: [];
|
|
1686
|
+
sendJson(res, 200, {
|
|
1687
|
+
clarifications,
|
|
1688
|
+
pendingCount: clarifications.filter((item) => item.status === "pending").length,
|
|
1689
|
+
});
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// POST /api/v1/clarifications/:id/answer
|
|
1694
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/clarifications\/[^/]+\/answer$/)) {
|
|
1695
|
+
const clarificationId = pathname.split("/")[4]!;
|
|
1696
|
+
const body = await parseJsonBody(req);
|
|
1697
|
+
const answer = typeof body.answer === "string" ? body.answer.trim() : "";
|
|
1698
|
+
const answeredBy = typeof body.answeredBy === "string" && body.answeredBy.trim()
|
|
1699
|
+
? body.answeredBy.trim()
|
|
1700
|
+
: "human";
|
|
1701
|
+
|
|
1702
|
+
if (!answer) {
|
|
1703
|
+
sendError(res, 400, "answer is required");
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
const currentState = getTeamState();
|
|
1708
|
+
const currentClarification = currentState?.clarifications[clarificationId];
|
|
1709
|
+
if (!currentClarification) {
|
|
1710
|
+
sendError(res, 404, "Clarification request not found");
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (currentClarification.status === "answered") {
|
|
1715
|
+
sendError(res, 409, "Clarification request already answered");
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const now = Date.now();
|
|
1720
|
+
const state = updateTeamState((s) => {
|
|
1721
|
+
const clarification = s.clarifications[clarificationId];
|
|
1722
|
+
if (!clarification) {
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
clarification.status = "answered";
|
|
1727
|
+
clarification.answer = answer;
|
|
1728
|
+
clarification.answeredBy = answeredBy;
|
|
1729
|
+
clarification.answeredAt = now;
|
|
1730
|
+
clarification.updatedAt = now;
|
|
1731
|
+
|
|
1732
|
+
const task = s.tasks[clarification.taskId];
|
|
1733
|
+
if (!task) {
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
task.status = "pending";
|
|
1738
|
+
task.progress = `Clarification answered by ${answeredBy}: ${answer}`;
|
|
1739
|
+
task.clarificationRequestId = undefined;
|
|
1740
|
+
task.updatedAt = now;
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
const clarification = state.clarifications[clarificationId];
|
|
1744
|
+
const task = clarification ? state.tasks[clarification.taskId] : undefined;
|
|
1745
|
+
|
|
1746
|
+
let responseMessage: TeamMessage | undefined;
|
|
1747
|
+
if (clarification?.requestedByRole && task) {
|
|
1748
|
+
responseMessage = {
|
|
1749
|
+
id: generateId(),
|
|
1750
|
+
from: answeredBy,
|
|
1751
|
+
toRole: clarification.requestedByRole,
|
|
1752
|
+
type: "direct",
|
|
1753
|
+
content: `Clarification answer for task ${task.id}: ${answer}`,
|
|
1754
|
+
taskId: task.id,
|
|
1755
|
+
createdAt: now,
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
updateTeamState((s) => {
|
|
1759
|
+
s.messages.push(responseMessage!);
|
|
1760
|
+
});
|
|
1761
|
+
await routeDirectMessage(responseMessage, deps);
|
|
1762
|
+
wsServer.broadcastUpdate({ type: "message:new", data: responseMessage });
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
let resumedTask = task;
|
|
1766
|
+
let resumedWorker: WorkerInfo | null = null;
|
|
1767
|
+
if (task) {
|
|
1768
|
+
const latestState = getTeamState()!;
|
|
1769
|
+
if (clarification?.requestedByWorkerId && latestState.workers[clarification.requestedByWorkerId]?.status === "idle") {
|
|
1770
|
+
resumedWorker = latestState.workers[clarification.requestedByWorkerId]!;
|
|
1771
|
+
} else {
|
|
1772
|
+
resumedWorker = taskRouter.routeTask(task, latestState.workers);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (resumedWorker) {
|
|
1776
|
+
resumedTask = await assignTaskToWorker(task.id, resumedWorker, deps, {
|
|
1777
|
+
assignedRole: task.assignedRole,
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification });
|
|
1783
|
+
if (resumedTask) {
|
|
1784
|
+
recordTaskExecutionEvent(resumedTask.id, {
|
|
1785
|
+
type: "lifecycle",
|
|
1786
|
+
phase: "clarification_answered",
|
|
1787
|
+
source: "controller",
|
|
1788
|
+
message: `Clarification answered by ${answeredBy}: ${answer}`,
|
|
1789
|
+
role: clarification?.requestedByRole,
|
|
1790
|
+
workerId: clarification?.requestedByWorkerId,
|
|
1791
|
+
}, deps);
|
|
1792
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(resumedTask) });
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
sendJson(res, 200, {
|
|
1796
|
+
clarification,
|
|
1797
|
+
task: serializeTask(resumedTask),
|
|
1798
|
+
resumedWorker,
|
|
1799
|
+
message: responseMessage,
|
|
1800
|
+
});
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// ==================== Git Collaboration ====================
|
|
1805
|
+
|
|
1806
|
+
// GET /api/v1/repo
|
|
1807
|
+
if (req.method === "GET" && pathname === "/api/v1/repo") {
|
|
1808
|
+
const repo = await refreshControllerRepoState(deps);
|
|
1809
|
+
if (!repo?.enabled) {
|
|
1810
|
+
sendJson(res, 200, { enabled: false });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
sendJson(res, 200, { repo });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// GET /api/v1/repo/bundle
|
|
1819
|
+
if (req.method === "GET" && pathname === "/api/v1/repo/bundle") {
|
|
1820
|
+
try {
|
|
1821
|
+
const exported = await exportControllerGitBundle(config, logger);
|
|
1822
|
+
res.writeHead(200, {
|
|
1823
|
+
"Content-Type": "application/octet-stream",
|
|
1824
|
+
"Content-Length": exported.data.byteLength,
|
|
1825
|
+
"Content-Disposition": `attachment; filename="${exported.filename}"`,
|
|
1826
|
+
"Access-Control-Allow-Origin": "*",
|
|
1827
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
1828
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
1829
|
+
});
|
|
1830
|
+
res.end(exported.data);
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
sendError(res, 503, err instanceof Error ? err.message : String(err));
|
|
1833
|
+
}
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// POST /api/v1/repo/import
|
|
1838
|
+
if (req.method === "POST" && pathname === "/api/v1/repo/import") {
|
|
1839
|
+
const body = await readRequestBody(req);
|
|
1840
|
+
if (!body.length) {
|
|
1841
|
+
sendError(res, 400, "bundle body is required");
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const taskId = typeof requestUrl.searchParams.get("taskId") === "string" && requestUrl.searchParams.get("taskId")
|
|
1846
|
+
? requestUrl.searchParams.get("taskId")!
|
|
1847
|
+
: undefined;
|
|
1848
|
+
const workerId = typeof requestUrl.searchParams.get("workerId") === "string" && requestUrl.searchParams.get("workerId")
|
|
1849
|
+
? requestUrl.searchParams.get("workerId")!
|
|
1850
|
+
: undefined;
|
|
1851
|
+
const role = typeof requestUrl.searchParams.get("role") === "string" && requestUrl.searchParams.get("role")
|
|
1852
|
+
? requestUrl.searchParams.get("role") as RoleId
|
|
1853
|
+
: undefined;
|
|
1854
|
+
|
|
1855
|
+
try {
|
|
1856
|
+
const imported = await importControllerGitBundle(config, logger, body, { taskId, workerId });
|
|
1857
|
+
updateTeamState((s) => {
|
|
1858
|
+
s.repo = imported.repo;
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
if (taskId) {
|
|
1862
|
+
recordTaskExecutionEvent(taskId, {
|
|
1863
|
+
type: imported.merged || imported.alreadyUpToDate ? "lifecycle" : "error",
|
|
1864
|
+
phase: imported.merged
|
|
1865
|
+
? "repo_imported"
|
|
1866
|
+
: imported.alreadyUpToDate
|
|
1867
|
+
? "repo_import_skipped"
|
|
1868
|
+
: "repo_import_failed",
|
|
1869
|
+
source: "controller",
|
|
1870
|
+
message: imported.message,
|
|
1871
|
+
workerId,
|
|
1872
|
+
role,
|
|
1873
|
+
}, deps);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
sendJson(res, imported.merged || imported.alreadyUpToDate ? 200 : 409, {
|
|
1877
|
+
repo: imported.repo,
|
|
1878
|
+
merged: imported.merged,
|
|
1879
|
+
fastForwarded: imported.fastForwarded,
|
|
1880
|
+
alreadyUpToDate: imported.alreadyUpToDate,
|
|
1881
|
+
message: imported.message,
|
|
1882
|
+
});
|
|
1883
|
+
} catch (err) {
|
|
1884
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1885
|
+
if (taskId) {
|
|
1886
|
+
recordTaskExecutionEvent(taskId, {
|
|
1887
|
+
type: "error",
|
|
1888
|
+
phase: "repo_import_failed",
|
|
1889
|
+
source: "controller",
|
|
1890
|
+
message,
|
|
1891
|
+
workerId,
|
|
1892
|
+
role,
|
|
1893
|
+
}, deps);
|
|
1894
|
+
}
|
|
1895
|
+
sendError(res, 500, message);
|
|
1896
|
+
}
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// ==================== Team Info ====================
|
|
1901
|
+
|
|
1902
|
+
// GET /api/v1/team/status
|
|
1903
|
+
if (req.method === "GET" && pathname === "/api/v1/team/status") {
|
|
1904
|
+
const state = getTeamState();
|
|
1905
|
+
if (!state) {
|
|
1906
|
+
sendJson(res, 200, {
|
|
1907
|
+
teamName: config.teamName,
|
|
1908
|
+
workers: [],
|
|
1909
|
+
tasks: [],
|
|
1910
|
+
messages: [],
|
|
1911
|
+
clarifications: [],
|
|
1912
|
+
repo: null,
|
|
1913
|
+
pendingClarificationCount: 0,
|
|
1914
|
+
});
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
const clarifications = Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt);
|
|
1919
|
+
sendJson(res, 200, {
|
|
1920
|
+
teamName: state.teamName,
|
|
1921
|
+
workers: Object.values(state.workers),
|
|
1922
|
+
tasks: Object.values(state.tasks).map((task) => serializeTask(task)),
|
|
1923
|
+
messages: state.messages,
|
|
1924
|
+
clarifications,
|
|
1925
|
+
repo: state.repo ?? null,
|
|
1926
|
+
taskCount: Object.keys(state.tasks).length,
|
|
1927
|
+
workerCount: Object.keys(state.workers).length,
|
|
1928
|
+
pendingClarificationCount: clarifications.filter((item) => item.status === "pending").length,
|
|
1929
|
+
});
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// GET /api/v1/roles
|
|
1934
|
+
if (req.method === "GET" && pathname === "/api/v1/roles") {
|
|
1935
|
+
sendJson(res, 200, { roles: ROLES });
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// GET /api/v1/health
|
|
1940
|
+
if (req.method === "GET" && pathname === "/api/v1/health") {
|
|
1941
|
+
sendJson(res, 200, { status: "ok", mode: "controller", timestamp: Date.now() });
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
sendError(res, 404, "Not found");
|
|
1946
|
+
}
|