u-foo 1.2.16 → 1.4.0
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/modules/online/README.md +18 -0
- package/package.json +2 -1
- package/src/agent/cliRunner.js +1 -1
- package/src/agent/launcher.js +23 -4
- package/src/agent/ptyRunner.js +39 -16
- package/src/agent/ufooAgent.js +2 -1
- package/src/assistant/agent.js +2 -1
- package/src/assistant/bridge.js +9 -3
- package/src/assistant/constants.js +15 -0
- package/src/assistant/engine.js +7 -2
- package/src/assistant/ufooEngineCli.js +9 -3
- package/src/chat/commandExecutor.js +188 -13
- package/src/chat/commands.js +11 -0
- package/src/chat/daemonMessageRouter.js +107 -0
- package/src/cli/groupCoreCommands.js +246 -0
- package/src/cli/onlineCoreCommands.js +8 -0
- package/src/cli.js +325 -2
- package/src/daemon/groupOrchestrator.js +557 -0
- package/src/daemon/index.js +319 -1
- package/src/daemon/status.js +48 -0
- package/src/group/diagram.js +222 -0
- package/src/group/templates.js +280 -0
- package/src/group/validateTemplate.js +234 -0
- package/src/online/server.js +320 -28
- package/src/shared/eventContract.js +5 -0
- package/src/ufoo/paths.js +2 -0
- package/templates/groups/dev-basic.json +78 -0
- package/templates/groups/research-quick.json +49 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { getUfooPaths } = require("../ufoo/paths");
|
|
6
|
+
const { resolveTemplateReference } = require("../group/templates");
|
|
7
|
+
const { validateTemplate } = require("../group/validateTemplate");
|
|
8
|
+
|
|
9
|
+
function asTrimmedString(value) {
|
|
10
|
+
if (typeof value !== "string") return "";
|
|
11
|
+
return value.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SAFE_GROUP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
15
|
+
|
|
16
|
+
function formatResolveErrors(errors = []) {
|
|
17
|
+
if (!Array.isArray(errors) || errors.length === 0) return "";
|
|
18
|
+
return errors
|
|
19
|
+
.map((item) => `${item.filePath}: ${item.error}`)
|
|
20
|
+
.join("; ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveTemplateTarget(projectRoot, target, options = {}) {
|
|
24
|
+
const resolved = resolveTemplateReference(projectRoot, target, {
|
|
25
|
+
allowPath: options.allowPath !== false,
|
|
26
|
+
cwd: options.cwd || projectRoot,
|
|
27
|
+
...(options.templatesOptions || {}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (resolved.entry) {
|
|
31
|
+
return { ok: true, resolved, error: "" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const details = formatResolveErrors(resolved.errors || []);
|
|
35
|
+
const error = details
|
|
36
|
+
? `failed to load template "${target}": ${details}`
|
|
37
|
+
: `template not found: ${target}`;
|
|
38
|
+
return { ok: false, resolved, error };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeInstanceId(value = "") {
|
|
42
|
+
const text = asTrimmedString(value);
|
|
43
|
+
if (!text) return "";
|
|
44
|
+
if (!SAFE_GROUP_ID_RE.test(text)) return "";
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeGroupId(value = "") {
|
|
49
|
+
return normalizeInstanceId(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildLaunchPlan(templateDoc = {}) {
|
|
53
|
+
const agents = Array.isArray(templateDoc.agents) ? templateDoc.agents : [];
|
|
54
|
+
const remaining = new Map();
|
|
55
|
+
const launched = new Set();
|
|
56
|
+
const ordered = [];
|
|
57
|
+
|
|
58
|
+
for (const agent of agents) {
|
|
59
|
+
const nickname = asTrimmedString(agent && agent.nickname);
|
|
60
|
+
if (!nickname) continue;
|
|
61
|
+
const dependsOn = Array.isArray(agent.depends_on)
|
|
62
|
+
? agent.depends_on.map((item) => asTrimmedString(item)).filter(Boolean)
|
|
63
|
+
: [];
|
|
64
|
+
const startupOrder = Number.isInteger(agent.startup_order) ? agent.startup_order : 0;
|
|
65
|
+
remaining.set(nickname, {
|
|
66
|
+
id: asTrimmedString(agent.id),
|
|
67
|
+
nickname,
|
|
68
|
+
type: asTrimmedString(agent.type),
|
|
69
|
+
startup_order: startupOrder,
|
|
70
|
+
depends_on: dependsOn,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let guard = 0;
|
|
75
|
+
while (remaining.size > 0 && guard < 10000) {
|
|
76
|
+
guard += 1;
|
|
77
|
+
const ready = [];
|
|
78
|
+
for (const item of remaining.values()) {
|
|
79
|
+
const allDepsReady = item.depends_on.every((dep) => launched.has(dep));
|
|
80
|
+
if (allDepsReady) ready.push(item);
|
|
81
|
+
}
|
|
82
|
+
if (ready.length === 0) {
|
|
83
|
+
throw new Error("unable to compile launch plan: unresolved dependency graph");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ready.sort((a, b) => {
|
|
87
|
+
if (a.startup_order !== b.startup_order) return a.startup_order - b.startup_order;
|
|
88
|
+
return a.nickname.localeCompare(b.nickname, "en", { sensitivity: "base" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
for (const item of ready) {
|
|
92
|
+
remaining.delete(item.nickname);
|
|
93
|
+
launched.add(item.nickname);
|
|
94
|
+
ordered.push(item);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return ordered;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ensureGroupsDir(projectRoot) {
|
|
102
|
+
const dir = getUfooPaths(projectRoot).groupsDir;
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
return dir;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function groupFilePath(projectRoot, groupId) {
|
|
108
|
+
const raw = asTrimmedString(groupId);
|
|
109
|
+
const normalized = normalizeGroupId(raw);
|
|
110
|
+
if (!normalized || normalized !== raw) {
|
|
111
|
+
throw new Error("invalid group_id");
|
|
112
|
+
}
|
|
113
|
+
return path.join(ensureGroupsDir(projectRoot), `${normalized}.json`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeGroupState(projectRoot, runtime) {
|
|
117
|
+
const filePath = groupFilePath(projectRoot, runtime.group_id);
|
|
118
|
+
fs.writeFileSync(filePath, `${JSON.stringify(runtime, null, 2)}\n`, "utf8");
|
|
119
|
+
return filePath;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readGroupState(projectRoot, groupId) {
|
|
123
|
+
let filePath = "";
|
|
124
|
+
try {
|
|
125
|
+
filePath = groupFilePath(projectRoot, groupId);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (!fs.existsSync(filePath)) return null;
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function listGroupStates(projectRoot) {
|
|
138
|
+
const dir = ensureGroupsDir(projectRoot);
|
|
139
|
+
const entries = fs
|
|
140
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
141
|
+
.filter((item) => item.isFile() && item.name.endsWith(".json"))
|
|
142
|
+
.map((item) => path.join(dir, item.name))
|
|
143
|
+
.sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
|
|
144
|
+
|
|
145
|
+
const groups = [];
|
|
146
|
+
for (const filePath of entries) {
|
|
147
|
+
try {
|
|
148
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
149
|
+
groups.push(raw);
|
|
150
|
+
} catch {
|
|
151
|
+
// ignore malformed runtime state
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return groups;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readBusAgents(projectRoot) {
|
|
158
|
+
const filePath = getUfooPaths(projectRoot).agentsFile;
|
|
159
|
+
try {
|
|
160
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
161
|
+
return data && data.agents ? data.agents : {};
|
|
162
|
+
} catch {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveActiveSubscriberByNickname(projectRoot, nickname) {
|
|
168
|
+
if (!nickname) return "";
|
|
169
|
+
const agents = readBusAgents(projectRoot);
|
|
170
|
+
const entries = Object.entries(agents);
|
|
171
|
+
const match = entries.find(([, meta]) => meta && meta.nickname === nickname && meta.status === "active");
|
|
172
|
+
return match ? match[0] : "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function summarizeGroup(runtime = {}) {
|
|
176
|
+
const members = Array.isArray(runtime.members) ? runtime.members : [];
|
|
177
|
+
const active = members.filter((item) => item.status === "active" || item.status === "reused").length;
|
|
178
|
+
return {
|
|
179
|
+
group_id: runtime.group_id || "",
|
|
180
|
+
status: runtime.status || "",
|
|
181
|
+
template_alias: runtime.template_alias || "",
|
|
182
|
+
template_version: runtime.template_version || null,
|
|
183
|
+
updated_at: runtime.updated_at || "",
|
|
184
|
+
members_total: members.length,
|
|
185
|
+
members_active: active,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function nowIso() {
|
|
190
|
+
return new Date().toISOString();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildDefaultRuntime({
|
|
194
|
+
groupId,
|
|
195
|
+
instance,
|
|
196
|
+
templateEntry,
|
|
197
|
+
plan,
|
|
198
|
+
}) {
|
|
199
|
+
const templateInfo = templateEntry && templateEntry.data && templateEntry.data.template
|
|
200
|
+
? templateEntry.data.template
|
|
201
|
+
: {};
|
|
202
|
+
const createdAt = nowIso();
|
|
203
|
+
return {
|
|
204
|
+
group_id: groupId,
|
|
205
|
+
instance: instance || "",
|
|
206
|
+
status: "starting",
|
|
207
|
+
template_alias: templateEntry.alias || asTrimmedString(templateInfo.alias),
|
|
208
|
+
template_id: asTrimmedString(templateInfo.id),
|
|
209
|
+
template_name: asTrimmedString(templateInfo.name),
|
|
210
|
+
template_version: Number.isInteger(templateEntry.schemaVersion) ? templateEntry.schemaVersion : null,
|
|
211
|
+
template_source: templateEntry.source || "",
|
|
212
|
+
template_file: templateEntry.filePath || "",
|
|
213
|
+
created_at: createdAt,
|
|
214
|
+
started_at: createdAt,
|
|
215
|
+
updated_at: createdAt,
|
|
216
|
+
errors: [],
|
|
217
|
+
members: plan.map((item, idx) => ({
|
|
218
|
+
index: idx,
|
|
219
|
+
template_agent_id: item.id || "",
|
|
220
|
+
nickname: item.nickname,
|
|
221
|
+
type: item.type,
|
|
222
|
+
startup_order: item.startup_order,
|
|
223
|
+
depends_on: item.depends_on.slice(),
|
|
224
|
+
status: "pending",
|
|
225
|
+
managed: true,
|
|
226
|
+
subscriber_id: "",
|
|
227
|
+
launched_at: "",
|
|
228
|
+
stopped_at: "",
|
|
229
|
+
launch: {},
|
|
230
|
+
stop: {},
|
|
231
|
+
})),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function pickLaunchSubscriber(projectRoot, launchResult, nickname) {
|
|
236
|
+
if (launchResult && Array.isArray(launchResult.subscriber_ids) && launchResult.subscriber_ids.length > 0) {
|
|
237
|
+
return String(launchResult.subscriber_ids[0] || "").trim();
|
|
238
|
+
}
|
|
239
|
+
if (launchResult && typeof launchResult.agent_id === "string" && launchResult.agent_id.includes(":")) {
|
|
240
|
+
return launchResult.agent_id;
|
|
241
|
+
}
|
|
242
|
+
return resolveActiveSubscriberByNickname(projectRoot, nickname);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createGroupOrchestrator(options = {}) {
|
|
246
|
+
const {
|
|
247
|
+
projectRoot,
|
|
248
|
+
handleOps,
|
|
249
|
+
processManager = null,
|
|
250
|
+
templatesOptions = {},
|
|
251
|
+
} = options;
|
|
252
|
+
|
|
253
|
+
if (!projectRoot) {
|
|
254
|
+
throw new Error("createGroupOrchestrator requires projectRoot");
|
|
255
|
+
}
|
|
256
|
+
if (typeof handleOps !== "function") {
|
|
257
|
+
throw new Error("createGroupOrchestrator requires handleOps");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function generateGroupId(alias, instance) {
|
|
261
|
+
const normalizedInstance = normalizeInstanceId(instance);
|
|
262
|
+
if (normalizedInstance) return normalizedInstance;
|
|
263
|
+
|
|
264
|
+
const prefix = normalizeInstanceId(alias) || "group";
|
|
265
|
+
for (let i = 0; i < 5; i += 1) {
|
|
266
|
+
const candidate = `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
267
|
+
if (!fs.existsSync(groupFilePath(projectRoot, candidate))) {
|
|
268
|
+
return candidate;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return `${prefix}-${Date.now().toString(36)}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function validateTemplateTarget(target, resolveOptions = {}) {
|
|
275
|
+
const rawTarget = asTrimmedString(target);
|
|
276
|
+
if (!rawTarget) {
|
|
277
|
+
return { ok: false, error: "template target is required", errors: [], entry: null };
|
|
278
|
+
}
|
|
279
|
+
const resolvedState = resolveTemplateTarget(projectRoot, rawTarget, {
|
|
280
|
+
templatesOptions,
|
|
281
|
+
allowPath: resolveOptions.allowPath !== false,
|
|
282
|
+
});
|
|
283
|
+
if (!resolvedState.ok) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: resolvedState.error,
|
|
287
|
+
errors: resolvedState.resolved.errors || [],
|
|
288
|
+
entry: null,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const result = validateTemplate(resolvedState.resolved.entry.data);
|
|
292
|
+
if (!result.ok) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
error: "template validation failed",
|
|
296
|
+
errors: result.errors,
|
|
297
|
+
entry: resolvedState.resolved.entry,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
error: "",
|
|
303
|
+
errors: [],
|
|
304
|
+
entry: resolvedState.resolved.entry,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function runGroup(params = {}) {
|
|
309
|
+
const alias = asTrimmedString(params.alias);
|
|
310
|
+
const instance = asTrimmedString(params.instance);
|
|
311
|
+
const dryRun = params.dry_run === true || params.dryRun === true;
|
|
312
|
+
|
|
313
|
+
if (!alias) {
|
|
314
|
+
return { ok: false, error: "group run requires alias", status: "failed" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const validated = validateTemplateTarget(alias, { allowPath: false });
|
|
318
|
+
if (!validated.ok || !validated.entry) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
error: validated.error || "template validation failed",
|
|
322
|
+
validationErrors: validated.errors || [],
|
|
323
|
+
status: "failed",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const plan = buildLaunchPlan(validated.entry.data);
|
|
328
|
+
const groupId = generateGroupId(validated.entry.alias || alias, instance);
|
|
329
|
+
|
|
330
|
+
if (instance && !normalizeInstanceId(instance)) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
error: "instance must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/",
|
|
334
|
+
status: "failed",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (fs.existsSync(groupFilePath(projectRoot, groupId))) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error: `group id already exists: ${groupId}`,
|
|
342
|
+
status: "failed",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (dryRun) {
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
dry_run: true,
|
|
350
|
+
status: "dry_run",
|
|
351
|
+
group_id: groupId,
|
|
352
|
+
template_alias: validated.entry.alias,
|
|
353
|
+
members: plan.map((item) => ({
|
|
354
|
+
nickname: item.nickname,
|
|
355
|
+
type: item.type,
|
|
356
|
+
startup_order: item.startup_order,
|
|
357
|
+
depends_on: item.depends_on.slice(),
|
|
358
|
+
})),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const runtime = buildDefaultRuntime({
|
|
363
|
+
groupId,
|
|
364
|
+
instance,
|
|
365
|
+
templateEntry: validated.entry,
|
|
366
|
+
plan,
|
|
367
|
+
});
|
|
368
|
+
writeGroupState(projectRoot, runtime);
|
|
369
|
+
|
|
370
|
+
const rollbackTargets = [];
|
|
371
|
+
|
|
372
|
+
for (let i = 0; i < plan.length; i += 1) {
|
|
373
|
+
const item = plan[i];
|
|
374
|
+
const member = runtime.members[i];
|
|
375
|
+
const op = {
|
|
376
|
+
action: "launch",
|
|
377
|
+
agent: item.type,
|
|
378
|
+
count: 1,
|
|
379
|
+
nickname: item.nickname,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// eslint-disable-next-line no-await-in-loop
|
|
383
|
+
const opsResults = await handleOps(projectRoot, [op], processManager);
|
|
384
|
+
const launchResult = Array.isArray(opsResults)
|
|
385
|
+
? opsResults.find((entry) => entry && entry.action === "launch")
|
|
386
|
+
: null;
|
|
387
|
+
|
|
388
|
+
if (!launchResult || launchResult.ok === false) {
|
|
389
|
+
member.status = "failed";
|
|
390
|
+
member.launch = launchResult || {};
|
|
391
|
+
runtime.status = "failed";
|
|
392
|
+
runtime.updated_at = nowIso();
|
|
393
|
+
runtime.errors.push({
|
|
394
|
+
stage: "launch",
|
|
395
|
+
nickname: item.nickname,
|
|
396
|
+
error: launchResult && launchResult.error
|
|
397
|
+
? launchResult.error
|
|
398
|
+
: `launch failed for ${item.nickname}`,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
for (let j = rollbackTargets.length - 1; j >= 0; j -= 1) {
|
|
402
|
+
const target = rollbackTargets[j];
|
|
403
|
+
// eslint-disable-next-line no-await-in-loop
|
|
404
|
+
const closeResults = await handleOps(projectRoot, [{ action: "close", agent_id: target.target }], processManager);
|
|
405
|
+
const closeResult = Array.isArray(closeResults)
|
|
406
|
+
? closeResults.find((entry) => entry && entry.action === "close")
|
|
407
|
+
: null;
|
|
408
|
+
const targetMember = runtime.members[target.memberIndex];
|
|
409
|
+
if (closeResult && closeResult.ok !== false) {
|
|
410
|
+
targetMember.status = "rolled_back";
|
|
411
|
+
targetMember.stop = { ok: true, reason: "rollback", at: nowIso() };
|
|
412
|
+
} else {
|
|
413
|
+
targetMember.status = "rollback_failed";
|
|
414
|
+
targetMember.stop = { ok: false, reason: "rollback", at: nowIso(), error: closeResult?.error || "close failed" };
|
|
415
|
+
runtime.errors.push({
|
|
416
|
+
stage: "rollback",
|
|
417
|
+
nickname: targetMember.nickname,
|
|
418
|
+
error: closeResult?.error || "close failed",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
writeGroupState(projectRoot, runtime);
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
status: runtime.status,
|
|
427
|
+
group_id: runtime.group_id,
|
|
428
|
+
error: runtime.errors[runtime.errors.length - 1]?.error || "group launch failed",
|
|
429
|
+
group: runtime,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const reused = Boolean(launchResult.skipped);
|
|
434
|
+
const subscriberId = pickLaunchSubscriber(projectRoot, launchResult, item.nickname);
|
|
435
|
+
member.status = reused ? "reused" : "active";
|
|
436
|
+
member.managed = !reused;
|
|
437
|
+
member.subscriber_id = subscriberId || "";
|
|
438
|
+
member.launched_at = nowIso();
|
|
439
|
+
member.launch = launchResult;
|
|
440
|
+
runtime.updated_at = nowIso();
|
|
441
|
+
|
|
442
|
+
if (!reused) {
|
|
443
|
+
rollbackTargets.push({
|
|
444
|
+
memberIndex: i,
|
|
445
|
+
target: subscriberId || item.nickname,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
writeGroupState(projectRoot, runtime);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
runtime.status = "active";
|
|
453
|
+
runtime.updated_at = nowIso();
|
|
454
|
+
writeGroupState(projectRoot, runtime);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
status: runtime.status,
|
|
459
|
+
group_id: runtime.group_id,
|
|
460
|
+
group: runtime,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function stopGroup(params = {}) {
|
|
465
|
+
const requestedGroupId = asTrimmedString(params.groupId || params.group_id || "");
|
|
466
|
+
if (!requestedGroupId) {
|
|
467
|
+
return { ok: false, error: "stop_group requires group_id", status: "failed" };
|
|
468
|
+
}
|
|
469
|
+
const groupId = normalizeGroupId(requestedGroupId);
|
|
470
|
+
if (!groupId || groupId !== requestedGroupId) {
|
|
471
|
+
return { ok: false, error: "invalid group_id", status: "failed" };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const runtime = readGroupState(projectRoot, groupId);
|
|
475
|
+
if (!runtime) {
|
|
476
|
+
return { ok: false, error: `group not found: ${groupId}`, status: "failed" };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const members = Array.isArray(runtime.members) ? runtime.members : [];
|
|
480
|
+
const activeMembers = [];
|
|
481
|
+
for (let i = members.length - 1; i >= 0; i -= 1) {
|
|
482
|
+
const member = members[i];
|
|
483
|
+
if (!member || member.managed === false) continue;
|
|
484
|
+
if (member.status !== "active") continue;
|
|
485
|
+
const target = asTrimmedString(member.subscriber_id) || asTrimmedString(member.nickname);
|
|
486
|
+
if (!target) continue;
|
|
487
|
+
activeMembers.push({ index: i, target });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const errors = [];
|
|
491
|
+
for (const item of activeMembers) {
|
|
492
|
+
// eslint-disable-next-line no-await-in-loop
|
|
493
|
+
const closeResults = await handleOps(projectRoot, [{ action: "close", agent_id: item.target }], processManager);
|
|
494
|
+
const closeResult = Array.isArray(closeResults)
|
|
495
|
+
? closeResults.find((entry) => entry && entry.action === "close")
|
|
496
|
+
: null;
|
|
497
|
+
const member = runtime.members[item.index];
|
|
498
|
+
if (closeResult && closeResult.ok !== false) {
|
|
499
|
+
member.status = "stopped";
|
|
500
|
+
member.stop = { ok: true, at: nowIso() };
|
|
501
|
+
} else {
|
|
502
|
+
member.status = "stop_failed";
|
|
503
|
+
member.stop = { ok: false, at: nowIso(), error: closeResult?.error || "close failed" };
|
|
504
|
+
errors.push({ nickname: member.nickname, error: closeResult?.error || "close failed" });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
runtime.status = "stopped";
|
|
509
|
+
runtime.updated_at = nowIso();
|
|
510
|
+
runtime.errors = Array.isArray(runtime.errors) ? runtime.errors : [];
|
|
511
|
+
for (const err of errors) {
|
|
512
|
+
runtime.errors.push({ stage: "stop", ...err });
|
|
513
|
+
}
|
|
514
|
+
writeGroupState(projectRoot, runtime);
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
ok: true,
|
|
518
|
+
status: runtime.status,
|
|
519
|
+
group_id: runtime.group_id,
|
|
520
|
+
errors,
|
|
521
|
+
group: runtime,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function getStatus(params = {}) {
|
|
526
|
+
const requestedGroupId = asTrimmedString(params.groupId || params.group_id || "");
|
|
527
|
+
if (requestedGroupId) {
|
|
528
|
+
const groupId = normalizeGroupId(requestedGroupId);
|
|
529
|
+
if (!groupId || groupId !== requestedGroupId) {
|
|
530
|
+
return { ok: false, error: "invalid group_id" };
|
|
531
|
+
}
|
|
532
|
+
const group = readGroupState(projectRoot, groupId);
|
|
533
|
+
if (!group) {
|
|
534
|
+
return { ok: false, error: `group not found: ${groupId}` };
|
|
535
|
+
}
|
|
536
|
+
return { ok: true, group };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const groups = listGroupStates(projectRoot).map((runtime) => summarizeGroup(runtime));
|
|
540
|
+
groups.sort((a, b) => String(b.updated_at || "").localeCompare(String(a.updated_at || "")));
|
|
541
|
+
return { ok: true, count: groups.length, groups };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
validateTemplateTarget,
|
|
546
|
+
runGroup,
|
|
547
|
+
stopGroup,
|
|
548
|
+
getStatus,
|
|
549
|
+
summarizeGroup,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
module.exports = {
|
|
554
|
+
createGroupOrchestrator,
|
|
555
|
+
buildLaunchPlan,
|
|
556
|
+
normalizeGroupId,
|
|
557
|
+
};
|