u-foo 1.2.16 → 1.3.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.
@@ -14,6 +14,8 @@ const { getUfooPaths } = require("../ufoo/paths");
14
14
  const { scheduleProviderSessionProbe, loadProviderSessionCache } = require("./providerSessions");
15
15
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
16
16
  const { createDaemonCronController } = require("./cronOps");
17
+ const { createGroupOrchestrator } = require("./groupOrchestrator");
18
+ const { normalizeFormat, renderGroupDiagramFromTemplate, renderGroupDiagramFromRuntime } = require("../group/diagram");
17
19
  const { runAssistantTask } = require("../assistant/bridge");
18
20
  const { runPromptWithAssistant } = require("./promptLoop");
19
21
  const { handlePromptRequest } = require("./promptRequest");
@@ -22,6 +24,7 @@ const { recordAgentReport } = require("./reporting");
22
24
  let providerSessions = null;
23
25
  let probeHandles = new Map();
24
26
  let daemonCronController = null;
27
+ let daemonGroupOrchestrator = null;
25
28
 
26
29
  function sleep(ms) {
27
30
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -314,6 +317,7 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
314
317
  nickname: nickname || undefined,
315
318
  agent_id: existing,
316
319
  skipped: true,
320
+ cleaned: Boolean(cleaned),
317
321
  message: `Agent '${nickname}' already exists`,
318
322
  });
319
323
  continue;
@@ -354,7 +358,8 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
354
358
  ok: true,
355
359
  agent,
356
360
  count,
357
- nickname: nickname || undefined
361
+ nickname: nickname || undefined,
362
+ subscriber_ids: Array.isArray(launchResult.subscriberIds) ? launchResult.subscriberIds.slice() : [],
358
363
  });
359
364
  if (nickname) {
360
365
  // eslint-disable-next-line no-await-in-loop
@@ -680,6 +685,11 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
680
685
  },
681
686
  log,
682
687
  });
688
+ daemonGroupOrchestrator = createGroupOrchestrator({
689
+ projectRoot,
690
+ handleOps,
691
+ processManager,
692
+ });
683
693
 
684
694
  const buildRuntimeStatus = () =>
685
695
  buildStatus(projectRoot, {
@@ -992,6 +1002,313 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
992
1002
  type: IPC_RESPONSE_TYPES.ERROR,
993
1003
  error: err.message || "launch_agent failed",
994
1004
  })}
1005
+ `,
1006
+ );
1007
+ }
1008
+ return;
1009
+ }
1010
+ if (req.type === IPC_REQUEST_TYPES.LAUNCH_GROUP) {
1011
+ if (!daemonGroupOrchestrator) {
1012
+ socket.write(
1013
+ `${JSON.stringify({
1014
+ type: IPC_RESPONSE_TYPES.ERROR,
1015
+ error: "group orchestrator unavailable",
1016
+ })}
1017
+ `,
1018
+ );
1019
+ return;
1020
+ }
1021
+ const alias = req.alias || req.template || "";
1022
+ const instance = req.instance || req.group_id || "";
1023
+ const dryRun = req.dry_run === true || req.dryRun === true;
1024
+ try {
1025
+ const result = await daemonGroupOrchestrator.runGroup({
1026
+ alias,
1027
+ instance,
1028
+ dry_run: dryRun,
1029
+ });
1030
+ const ok = result && result.ok !== false;
1031
+ let reply = "";
1032
+ if (!ok) {
1033
+ reply = `Group run failed: ${result?.error || "unknown error"}`;
1034
+ } else if (result.dry_run) {
1035
+ reply = `Group dry-run ${result.group_id}: ${Array.isArray(result.members) ? result.members.length : 0} member(s)`;
1036
+ } else {
1037
+ reply = `Group started ${result.group_id}`;
1038
+ }
1039
+ socket.write(
1040
+ `${JSON.stringify({
1041
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1042
+ data: {
1043
+ reply,
1044
+ group: result,
1045
+ },
1046
+ })}
1047
+ `,
1048
+ );
1049
+ if (!dryRun) {
1050
+ cleanupInactiveSubscribers();
1051
+ ipcServer.sendToSockets({
1052
+ type: IPC_RESPONSE_TYPES.STATUS,
1053
+ data: buildRuntimeStatus(),
1054
+ });
1055
+ }
1056
+ } catch (err) {
1057
+ socket.write(
1058
+ `${JSON.stringify({
1059
+ type: IPC_RESPONSE_TYPES.ERROR,
1060
+ error: err.message || "launch_group failed",
1061
+ })}
1062
+ `,
1063
+ );
1064
+ }
1065
+ return;
1066
+ }
1067
+ if (req.type === IPC_REQUEST_TYPES.STOP_GROUP) {
1068
+ if (!daemonGroupOrchestrator) {
1069
+ socket.write(
1070
+ `${JSON.stringify({
1071
+ type: IPC_RESPONSE_TYPES.ERROR,
1072
+ error: "group orchestrator unavailable",
1073
+ })}
1074
+ `,
1075
+ );
1076
+ return;
1077
+ }
1078
+ const groupId = req.group_id || req.groupId || req.instance || "";
1079
+ try {
1080
+ const result = await daemonGroupOrchestrator.stopGroup({ group_id: groupId });
1081
+ const ok = result && result.ok !== false;
1082
+ const reply = ok
1083
+ ? `Stopped group ${result.group_id}`
1084
+ : `Group stop failed: ${result?.error || "unknown error"}`;
1085
+ socket.write(
1086
+ `${JSON.stringify({
1087
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1088
+ data: {
1089
+ reply,
1090
+ group: result,
1091
+ },
1092
+ })}
1093
+ `,
1094
+ );
1095
+ cleanupInactiveSubscribers();
1096
+ ipcServer.sendToSockets({
1097
+ type: IPC_RESPONSE_TYPES.STATUS,
1098
+ data: buildRuntimeStatus(),
1099
+ });
1100
+ } catch (err) {
1101
+ socket.write(
1102
+ `${JSON.stringify({
1103
+ type: IPC_RESPONSE_TYPES.ERROR,
1104
+ error: err.message || "stop_group failed",
1105
+ })}
1106
+ `,
1107
+ );
1108
+ }
1109
+ return;
1110
+ }
1111
+ if (req.type === IPC_REQUEST_TYPES.GROUP_STATUS) {
1112
+ if (!daemonGroupOrchestrator) {
1113
+ socket.write(
1114
+ `${JSON.stringify({
1115
+ type: IPC_RESPONSE_TYPES.ERROR,
1116
+ error: "group orchestrator unavailable",
1117
+ })}
1118
+ `,
1119
+ );
1120
+ return;
1121
+ }
1122
+ const groupId = req.group_id || req.groupId || req.instance || "";
1123
+ try {
1124
+ const result = daemonGroupOrchestrator.getStatus({ group_id: groupId });
1125
+ const ok = result && result.ok !== false;
1126
+ const reply = ok
1127
+ ? (groupId
1128
+ ? `Group ${groupId}: ${result.group?.status || "unknown"}`
1129
+ : `Group instances: ${result.count || 0}`)
1130
+ : `Group status failed: ${result?.error || "unknown error"}`;
1131
+ socket.write(
1132
+ `${JSON.stringify({
1133
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1134
+ data: {
1135
+ reply,
1136
+ group: result,
1137
+ },
1138
+ })}
1139
+ `,
1140
+ );
1141
+ } catch (err) {
1142
+ socket.write(
1143
+ `${JSON.stringify({
1144
+ type: IPC_RESPONSE_TYPES.ERROR,
1145
+ error: err.message || "group_status failed",
1146
+ })}
1147
+ `,
1148
+ );
1149
+ }
1150
+ return;
1151
+ }
1152
+ if (req.type === IPC_REQUEST_TYPES.GROUP_TEMPLATE_VALIDATE) {
1153
+ if (!daemonGroupOrchestrator) {
1154
+ socket.write(
1155
+ `${JSON.stringify({
1156
+ type: IPC_RESPONSE_TYPES.ERROR,
1157
+ error: "group orchestrator unavailable",
1158
+ })}
1159
+ `,
1160
+ );
1161
+ return;
1162
+ }
1163
+ const target = req.alias || req.path || req.target || "";
1164
+ try {
1165
+ const result = daemonGroupOrchestrator.validateTemplateTarget(target);
1166
+ const reply = result.ok
1167
+ ? `Template valid: ${result.entry?.alias || target}`
1168
+ : `Template invalid: ${result.error || "unknown error"}`;
1169
+ socket.write(
1170
+ `${JSON.stringify({
1171
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1172
+ data: {
1173
+ reply,
1174
+ group: {
1175
+ ok: result.ok,
1176
+ target,
1177
+ alias: result.entry?.alias || "",
1178
+ source: result.entry?.source || "",
1179
+ filePath: result.entry?.filePath || "",
1180
+ errors: result.errors || [],
1181
+ },
1182
+ },
1183
+ })}
1184
+ `,
1185
+ );
1186
+ } catch (err) {
1187
+ socket.write(
1188
+ `${JSON.stringify({
1189
+ type: IPC_RESPONSE_TYPES.ERROR,
1190
+ error: err.message || "group_template_validate failed",
1191
+ })}
1192
+ `,
1193
+ );
1194
+ }
1195
+ return;
1196
+ }
1197
+ if (req.type === IPC_REQUEST_TYPES.GROUP_DIAGRAM) {
1198
+ if (!daemonGroupOrchestrator) {
1199
+ socket.write(
1200
+ `${JSON.stringify({
1201
+ type: IPC_RESPONSE_TYPES.ERROR,
1202
+ error: "group orchestrator unavailable",
1203
+ })}
1204
+ `,
1205
+ );
1206
+ return;
1207
+ }
1208
+ const target = req.group_id || req.groupId || req.instance || req.alias || req.target || "";
1209
+ if (!target) {
1210
+ socket.write(
1211
+ `${JSON.stringify({
1212
+ type: IPC_RESPONSE_TYPES.ERROR,
1213
+ error: "group diagram requires alias|group_id",
1214
+ })}
1215
+ `,
1216
+ );
1217
+ return;
1218
+ }
1219
+ const format = normalizeFormat(req.format || (req.mermaid ? "mermaid" : "ascii"));
1220
+ try {
1221
+ const runtimeState = daemonGroupOrchestrator.getStatus({ group_id: target });
1222
+ if (runtimeState && runtimeState.ok === false && runtimeState.error === "invalid group_id") {
1223
+ socket.write(
1224
+ `${JSON.stringify({
1225
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1226
+ data: {
1227
+ reply: "Group diagram failed: invalid group_id",
1228
+ group: {
1229
+ ok: false,
1230
+ mode: "runtime",
1231
+ target,
1232
+ format,
1233
+ error: "invalid group_id",
1234
+ },
1235
+ },
1236
+ })}
1237
+ `,
1238
+ );
1239
+ return;
1240
+ }
1241
+ if (runtimeState && runtimeState.ok && runtimeState.group) {
1242
+ const diagram = renderGroupDiagramFromRuntime(runtimeState.group, { format });
1243
+ socket.write(
1244
+ `${JSON.stringify({
1245
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1246
+ data: {
1247
+ reply: `Group diagram (${format}) for runtime ${target}`,
1248
+ group: {
1249
+ ok: true,
1250
+ mode: "runtime",
1251
+ target,
1252
+ format,
1253
+ diagram,
1254
+ group_id: runtimeState.group.group_id || target,
1255
+ status: runtimeState.group.status || "",
1256
+ },
1257
+ },
1258
+ })}
1259
+ `,
1260
+ );
1261
+ return;
1262
+ }
1263
+
1264
+ const templateState = daemonGroupOrchestrator.validateTemplateTarget(target, { allowPath: false });
1265
+ if (!templateState || !templateState.ok || !templateState.entry) {
1266
+ socket.write(
1267
+ `${JSON.stringify({
1268
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1269
+ data: {
1270
+ reply: `Group diagram failed: ${templateState?.error || "template not found"}`,
1271
+ group: {
1272
+ ok: false,
1273
+ mode: "template",
1274
+ target,
1275
+ format,
1276
+ error: templateState?.error || "template not found",
1277
+ errors: templateState?.errors || [],
1278
+ },
1279
+ },
1280
+ })}
1281
+ `,
1282
+ );
1283
+ return;
1284
+ }
1285
+
1286
+ const diagram = renderGroupDiagramFromTemplate(templateState.entry.data, { format });
1287
+ socket.write(
1288
+ `${JSON.stringify({
1289
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1290
+ data: {
1291
+ reply: `Group diagram (${format}) for template ${templateState.entry.alias || target}`,
1292
+ group: {
1293
+ ok: true,
1294
+ mode: "template",
1295
+ target,
1296
+ format,
1297
+ diagram,
1298
+ alias: templateState.entry.alias || "",
1299
+ source: templateState.entry.source || "",
1300
+ filePath: templateState.entry.filePath || "",
1301
+ },
1302
+ },
1303
+ })}
1304
+ `,
1305
+ );
1306
+ } catch (err) {
1307
+ socket.write(
1308
+ `${JSON.stringify({
1309
+ type: IPC_RESPONSE_TYPES.ERROR,
1310
+ error: err.message || "group_diagram failed",
1311
+ })}
995
1312
  `,
996
1313
  );
997
1314
  }
@@ -1309,6 +1626,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1309
1626
  daemonCronController.stopAll();
1310
1627
  daemonCronController = null;
1311
1628
  }
1629
+ daemonGroupOrchestrator = null;
1312
1630
 
1313
1631
  // 清理所有子进程
1314
1632
  processManager.cleanup();
@@ -79,11 +79,58 @@ function normalizeCronTasks(raw = []) {
79
79
  }));
80
80
  }
81
81
 
82
+ function readGroups(projectRoot) {
83
+ const groupsDir = getUfooPaths(projectRoot).groupsDir;
84
+ if (!groupsDir || !fs.existsSync(groupsDir)) {
85
+ return { count: 0, active: 0, failed: 0, stopped: 0, items: [] };
86
+ }
87
+
88
+ const items = [];
89
+ try {
90
+ const files = fs.readdirSync(groupsDir)
91
+ .filter((name) => name.endsWith(".json"))
92
+ .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
93
+ for (const file of files) {
94
+ const filePath = path.join(groupsDir, file);
95
+ try {
96
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
97
+ const members = Array.isArray(raw.members) ? raw.members : [];
98
+ const membersActive = members.filter((item) => item && (item.status === "active" || item.status === "reused")).length;
99
+ items.push({
100
+ group_id: String(raw.group_id || path.basename(file, ".json")),
101
+ status: String(raw.status || "unknown"),
102
+ template_alias: String(raw.template_alias || ""),
103
+ updated_at: String(raw.updated_at || ""),
104
+ members_total: members.length,
105
+ members_active: membersActive,
106
+ });
107
+ } catch {
108
+ // ignore malformed group state
109
+ }
110
+ }
111
+ } catch {
112
+ return { count: 0, active: 0, failed: 0, stopped: 0, items: [] };
113
+ }
114
+
115
+ items.sort((a, b) => String(b.updated_at || "").localeCompare(String(a.updated_at || "")));
116
+ const active = items.filter((item) => item.status === "active").length;
117
+ const failed = items.filter((item) => item.status === "failed").length;
118
+ const stopped = items.filter((item) => item.status === "stopped").length;
119
+ return {
120
+ count: items.length,
121
+ active,
122
+ failed,
123
+ stopped,
124
+ items,
125
+ };
126
+ }
127
+
82
128
  function buildStatus(projectRoot, options = {}) {
83
129
  const bus = readBus(projectRoot);
84
130
  const decisions = readDecisions(projectRoot);
85
131
  const unread = readUnread(projectRoot);
86
132
  const reports = readReportSummary(projectRoot);
133
+ const groups = readGroups(projectRoot);
87
134
  const subscribers = bus ? Object.keys(bus.agents || {}) : [];
88
135
  const cronTasks = normalizeCronTasks(options.cronTasks || []);
89
136
 
@@ -115,6 +162,7 @@ function buildStatus(projectRoot, options = {}) {
115
162
  count: cronTasks.length,
116
163
  tasks: cronTasks,
117
164
  },
165
+ groups,
118
166
  };
119
167
  }
120
168
 
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+
3
+ function asTrimmedString(value) {
4
+ if (typeof value !== "string") return "";
5
+ return value.trim();
6
+ }
7
+
8
+ function normalizeFormat(format = "") {
9
+ const value = asTrimmedString(format).toLowerCase();
10
+ return value === "mermaid" ? "mermaid" : "ascii";
11
+ }
12
+
13
+ function toMemberNickname(member = {}, fallback = "") {
14
+ return asTrimmedString(member.nickname) || asTrimmedString(member.id) || fallback;
15
+ }
16
+
17
+ function collectTemplateMembers(templateDoc = {}) {
18
+ const agents = Array.isArray(templateDoc.agents) ? templateDoc.agents : [];
19
+ return agents
20
+ .map((agent, index) => ({
21
+ nickname: toMemberNickname(agent, `agent_${index + 1}`),
22
+ id: asTrimmedString(agent.id),
23
+ type: asTrimmedString(agent.type),
24
+ startup_order: Number.isInteger(agent.startup_order) ? agent.startup_order : null,
25
+ depends_on: Array.isArray(agent.depends_on)
26
+ ? agent.depends_on.map((dep) => asTrimmedString(dep)).filter(Boolean)
27
+ : [],
28
+ status: asTrimmedString(agent.status),
29
+ subscriber_id: asTrimmedString(agent.subscriber_id),
30
+ }))
31
+ .filter((item) => item.nickname);
32
+ }
33
+
34
+ function collectRuntimeMembers(runtime = {}) {
35
+ const members = Array.isArray(runtime.members) ? runtime.members : [];
36
+ return members
37
+ .map((member, index) => ({
38
+ nickname: toMemberNickname(member, `agent_${index + 1}`),
39
+ id: asTrimmedString(member.template_agent_id) || asTrimmedString(member.id),
40
+ type: asTrimmedString(member.type),
41
+ startup_order: Number.isInteger(member.startup_order) ? member.startup_order : null,
42
+ depends_on: Array.isArray(member.depends_on)
43
+ ? member.depends_on.map((dep) => asTrimmedString(dep)).filter(Boolean)
44
+ : [],
45
+ status: asTrimmedString(member.status),
46
+ subscriber_id: asTrimmedString(member.subscriber_id),
47
+ }))
48
+ .filter((item) => item.nickname);
49
+ }
50
+
51
+ function collectTemplateEdges(templateDoc = {}, members = []) {
52
+ const known = new Set(members.map((member) => member.nickname));
53
+ const edges = [];
54
+ const seen = new Set();
55
+
56
+ function addEdge(from, to, kind) {
57
+ const source = asTrimmedString(from);
58
+ const target = asTrimmedString(to);
59
+ if (!source || !target) return;
60
+ if (!known.has(source) || !known.has(target)) return;
61
+ const edgeKind = asTrimmedString(kind);
62
+ const key = `${source}->${target}:${edgeKind}`;
63
+ if (seen.has(key)) return;
64
+ seen.add(key);
65
+ edges.push({ from: source, to: target, kind: edgeKind });
66
+ }
67
+
68
+ const rawEdges = Array.isArray(templateDoc.edges) ? templateDoc.edges : [];
69
+ rawEdges.forEach((edge) => {
70
+ if (!edge || typeof edge !== "object") return;
71
+ addEdge(edge.from, edge.to, edge.kind);
72
+ });
73
+
74
+ members.forEach((member) => {
75
+ member.depends_on.forEach((dep) => addEdge(dep, member.nickname, "depends_on"));
76
+ });
77
+
78
+ return edges;
79
+ }
80
+
81
+ function collectRuntimeEdges(members = []) {
82
+ const known = new Set(members.map((member) => member.nickname));
83
+ const edges = [];
84
+ const seen = new Set();
85
+ members.forEach((member) => {
86
+ member.depends_on.forEach((dep) => {
87
+ if (!known.has(dep)) return;
88
+ const key = `${dep}->${member.nickname}`;
89
+ if (seen.has(key)) return;
90
+ seen.add(key);
91
+ edges.push({ from: dep, to: member.nickname, kind: "depends_on" });
92
+ });
93
+ });
94
+ return edges;
95
+ }
96
+
97
+ function formatMemberLine(member = {}) {
98
+ const type = member.type || "unknown";
99
+ const order = Number.isInteger(member.startup_order) ? member.startup_order : "-";
100
+ const deps = Array.isArray(member.depends_on) && member.depends_on.length > 0
101
+ ? member.depends_on.join(", ")
102
+ : "-";
103
+ const status = member.status ? ` status=${member.status}` : "";
104
+ const subscriber = member.subscriber_id ? ` sub=${member.subscriber_id}` : "";
105
+ return `- ${member.nickname} [${type}] order=${order} deps=${deps}${status}${subscriber}`;
106
+ }
107
+
108
+ function renderAsciiDiagram(metadata = {}, members = [], edges = []) {
109
+ const mode = metadata.mode || "template";
110
+ const name = metadata.name || "unknown";
111
+ const lines = [`Group Diagram (${mode}: ${name})`];
112
+ if (metadata.status) {
113
+ lines.push(`Status: ${metadata.status}`);
114
+ }
115
+ lines.push(`Members (${members.length}):`);
116
+ members.forEach((member) => {
117
+ lines.push(formatMemberLine(member));
118
+ });
119
+ if (edges.length === 0) {
120
+ lines.push("Edges: none");
121
+ } else {
122
+ lines.push(`Edges (${edges.length}):`);
123
+ edges.forEach((edge) => {
124
+ const suffix = edge.kind ? ` (${edge.kind})` : "";
125
+ lines.push(`- ${edge.from} -> ${edge.to}${suffix}`);
126
+ });
127
+ }
128
+ return lines.join("\n");
129
+ }
130
+
131
+ function normalizeMermaidId(value = "", fallback = "node") {
132
+ const normalized = String(value || "")
133
+ .replace(/[^A-Za-z0-9_]/g, "_")
134
+ .replace(/^[^A-Za-z_]+/, "");
135
+ return normalized || fallback;
136
+ }
137
+
138
+ function escapeMermaidLabel(value = "") {
139
+ return String(value || "")
140
+ .replace(/"/g, "\\\"")
141
+ .replace(/\n/g, "\\n");
142
+ }
143
+
144
+ function renderMermaidDiagram(members = [], edges = []) {
145
+ const lines = ["flowchart LR"];
146
+ const nodeIdMap = new Map();
147
+ const taken = new Set();
148
+
149
+ members.forEach((member, index) => {
150
+ const base = normalizeMermaidId(member.nickname, `node_${index + 1}`);
151
+ let id = base;
152
+ let suffix = 1;
153
+ while (taken.has(id)) {
154
+ id = `${base}_${suffix}`;
155
+ suffix += 1;
156
+ }
157
+ taken.add(id);
158
+ nodeIdMap.set(member.nickname, id);
159
+ const labelParts = [member.nickname];
160
+ if (member.type) labelParts.push(member.type);
161
+ if (member.status) labelParts.push(member.status);
162
+ lines.push(` ${id}["${escapeMermaidLabel(labelParts.join("\n"))}"]`);
163
+ });
164
+
165
+ edges.forEach((edge) => {
166
+ const fromId = nodeIdMap.get(edge.from);
167
+ const toId = nodeIdMap.get(edge.to);
168
+ if (!fromId || !toId) return;
169
+ const edgeLabel = edge.kind ? `|${escapeMermaidLabel(edge.kind)}|` : "";
170
+ lines.push(` ${fromId} -->${edgeLabel} ${toId}`);
171
+ });
172
+
173
+ return lines.join("\n");
174
+ }
175
+
176
+ function resolveTemplateName(templateDoc = {}, fallback = "") {
177
+ const templateMeta = templateDoc && templateDoc.template && typeof templateDoc.template === "object"
178
+ ? templateDoc.template
179
+ : {};
180
+ return asTrimmedString(templateMeta.alias) || asTrimmedString(templateMeta.id) || asTrimmedString(templateMeta.name) || fallback;
181
+ }
182
+
183
+ function renderGroupDiagramFromTemplate(templateDoc = {}, options = {}) {
184
+ const format = normalizeFormat(options.format);
185
+ const members = collectTemplateMembers(templateDoc);
186
+ const edges = collectTemplateEdges(templateDoc, members);
187
+ if (format === "mermaid") {
188
+ return renderMermaidDiagram(members, edges);
189
+ }
190
+ return renderAsciiDiagram(
191
+ {
192
+ mode: "template",
193
+ name: resolveTemplateName(templateDoc, "template"),
194
+ },
195
+ members,
196
+ edges
197
+ );
198
+ }
199
+
200
+ function renderGroupDiagramFromRuntime(runtime = {}, options = {}) {
201
+ const format = normalizeFormat(options.format);
202
+ const members = collectRuntimeMembers(runtime);
203
+ const edges = collectRuntimeEdges(members);
204
+ if (format === "mermaid") {
205
+ return renderMermaidDiagram(members, edges);
206
+ }
207
+ return renderAsciiDiagram(
208
+ {
209
+ mode: "runtime",
210
+ name: asTrimmedString(runtime.group_id) || "runtime",
211
+ status: asTrimmedString(runtime.status),
212
+ },
213
+ members,
214
+ edges
215
+ );
216
+ }
217
+
218
+ module.exports = {
219
+ normalizeFormat,
220
+ renderGroupDiagramFromTemplate,
221
+ renderGroupDiagramFromRuntime,
222
+ };