u-foo 1.7.4 → 1.8.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/README.md +9 -1
- package/README.zh-CN.md +9 -1
- package/bin/ufoo.js +4 -2
- package/package.json +1 -1
- package/src/agent/cliRunner.js +3 -2
- package/src/agent/ucodeBootstrap.js +5 -3
- package/src/agent/ufooAgent.js +185 -6
- package/src/assistant/constants.js +1 -1
- package/src/assistant/engine.js +1 -6
- package/src/chat/commandExecutor.js +116 -19
- package/src/chat/commands.js +8 -1
- package/src/chat/completionController.js +40 -0
- package/src/chat/cronScheduler.js +37 -6
- package/src/chat/daemonMessageRouter.js +23 -3
- package/src/chat/dashboardKeyController.js +48 -59
- package/src/chat/dashboardView.js +31 -39
- package/src/chat/index.js +154 -77
- package/src/chat/inputListenerController.js +14 -0
- package/src/chat/inputSubmitHandler.js +9 -5
- package/src/chat/settingsController.js +0 -28
- package/src/chat/transientAgentState.js +64 -0
- package/src/cli/groupCoreCommands.js +21 -12
- package/src/cli.js +23 -1
- package/src/daemon/cronOps.js +48 -11
- package/src/daemon/groupOrchestrator.js +581 -97
- package/src/daemon/index.js +420 -5
- package/src/daemon/ops.js +25 -7
- package/src/daemon/promptLoop.js +16 -0
- package/src/daemon/promptRequest.js +126 -2
- package/src/daemon/reporting.js +18 -0
- package/src/daemon/soloBootstrap.js +435 -0
- package/src/daemon/status.js +7 -1
- package/src/globalMode.js +33 -0
- package/src/group/bootstrap.js +157 -0
- package/src/group/promptProfiles.js +646 -0
- package/src/group/templateValidation.js +99 -0
- package/src/group/validateTemplate.js +36 -5
- package/src/init/index.js +13 -7
- package/src/report/store.js +6 -0
- package/src/shared/eventContract.js +1 -0
- package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
- package/templates/groups/product-discovery.json +79 -0
- package/templates/groups/ui-polish.json +87 -0
- package/templates/groups/verify-ship.json +79 -0
- package/templates/groups/research-quick.json +0 -49
|
@@ -5,6 +5,29 @@ const {
|
|
|
5
5
|
listControllerInboxEntries,
|
|
6
6
|
consumeControllerInboxEntries,
|
|
7
7
|
} = require("../report/store");
|
|
8
|
+
const { isGlobalControllerProjectRoot } = require("../globalMode");
|
|
9
|
+
|
|
10
|
+
function normalizeProjectRoute(route) {
|
|
11
|
+
if (!route || typeof route !== "object") return null;
|
|
12
|
+
const projectRoot = String(route.project_root || route.projectRoot || "").trim();
|
|
13
|
+
if (!projectRoot) return null;
|
|
14
|
+
return {
|
|
15
|
+
project_root: projectRoot,
|
|
16
|
+
project_name: String(route.project_name || route.projectName || "").trim(),
|
|
17
|
+
prompt: String(route.prompt || route.message || "").trim(),
|
|
18
|
+
reason: String(route.reason || "").trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildRoutedProjectPayload(basePayload = {}, route = {}) {
|
|
23
|
+
const payload = basePayload && typeof basePayload === "object" ? { ...basePayload } : {};
|
|
24
|
+
payload.routed_project = {
|
|
25
|
+
project_root: String(route.project_root || ""),
|
|
26
|
+
project_name: String(route.project_name || ""),
|
|
27
|
+
reason: String(route.reason || ""),
|
|
28
|
+
};
|
|
29
|
+
return payload;
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
function buildPromptWithPrivateReports(prompt = "", reports = [], requestMeta = {}) {
|
|
10
33
|
const meta = requestMeta && typeof requestMeta === "object" ? requestMeta : {};
|
|
@@ -34,6 +57,9 @@ function buildPromptWithPrivateReports(prompt = "", reports = [], requestMeta =
|
|
|
34
57
|
lines.push(JSON.stringify(reports, null, 2));
|
|
35
58
|
lines.push("");
|
|
36
59
|
lines.push("Use these runtime reports when deciding reply/dispatch/ops.");
|
|
60
|
+
lines.push("Treat them as control-plane observability, not automatic downstream delivery instructions.");
|
|
61
|
+
lines.push("If report.meta.handoff.status is \"delivered\" and report.meta.needs_dispatch is not true, do not dispatch that handoff again.");
|
|
62
|
+
lines.push("Only treat a report as a controller dispatch request when report.meta.needs_dispatch is true.");
|
|
37
63
|
return lines.join("\n");
|
|
38
64
|
}
|
|
39
65
|
|
|
@@ -52,12 +78,61 @@ async function handlePromptRequest(options = {}) {
|
|
|
52
78
|
handleOps,
|
|
53
79
|
markPending = () => {},
|
|
54
80
|
reportTaskStatus = () => {},
|
|
81
|
+
forwardProjectPrompt = null,
|
|
55
82
|
log = () => {},
|
|
56
83
|
} = options;
|
|
57
84
|
|
|
58
85
|
log(`prompt ${String(req.text || "").slice(0, 200)}`);
|
|
86
|
+
const requestMeta = req.request_meta && typeof req.request_meta === "object" ? req.request_meta : {};
|
|
87
|
+
const isGlobalController = isGlobalControllerProjectRoot(projectRoot);
|
|
88
|
+
const forcedProjectRoot = String(requestMeta.force_project_root || "").trim();
|
|
89
|
+
|
|
90
|
+
if (isGlobalController && forcedProjectRoot) {
|
|
91
|
+
try {
|
|
92
|
+
const routed = await Promise.resolve(forwardProjectPrompt({
|
|
93
|
+
targetProjectRoot: forcedProjectRoot,
|
|
94
|
+
targetProjectName: String(requestMeta.force_project_name || "").trim(),
|
|
95
|
+
prompt: req.text || "",
|
|
96
|
+
routeReason: "forced_project_root",
|
|
97
|
+
requestMeta,
|
|
98
|
+
}));
|
|
99
|
+
if (!routed || routed.ok !== true) {
|
|
100
|
+
const error = routed && routed.error ? routed.error : "project forward failed";
|
|
101
|
+
socket.write(
|
|
102
|
+
`${JSON.stringify({
|
|
103
|
+
type: IPC_RESPONSE_TYPES.ERROR,
|
|
104
|
+
error,
|
|
105
|
+
})}\n`,
|
|
106
|
+
);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const payload = buildRoutedProjectPayload(routed.payload || {}, {
|
|
110
|
+
project_root: routed.project_root || forcedProjectRoot,
|
|
111
|
+
project_name: routed.project_name || String(requestMeta.force_project_name || "").trim(),
|
|
112
|
+
reason: "forced_project_root",
|
|
113
|
+
});
|
|
114
|
+
socket.write(
|
|
115
|
+
`${JSON.stringify({
|
|
116
|
+
type: IPC_RESPONSE_TYPES.RESPONSE,
|
|
117
|
+
data: payload,
|
|
118
|
+
opsResults: routed.opsResults || [],
|
|
119
|
+
})}\n`,
|
|
120
|
+
);
|
|
121
|
+
return true;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
socket.write(
|
|
124
|
+
`${JSON.stringify({
|
|
125
|
+
type: IPC_RESPONSE_TYPES.ERROR,
|
|
126
|
+
error: err.message || String(err),
|
|
127
|
+
})}\n`,
|
|
128
|
+
);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
59
133
|
const privateReports = listControllerInboxEntries(projectRoot, "ufoo-agent", { num: 100 });
|
|
60
|
-
const promptText = buildPromptWithPrivateReports(req.text || "", privateReports,
|
|
134
|
+
const promptText = buildPromptWithPrivateReports(req.text || "", privateReports, requestMeta);
|
|
135
|
+
const useGlobalProjectRouter = isGlobalController;
|
|
61
136
|
|
|
62
137
|
try {
|
|
63
138
|
const handled = await runPromptWithAssistant({
|
|
@@ -74,6 +149,8 @@ async function handlePromptRequest(options = {}) {
|
|
|
74
149
|
reportTaskStatus,
|
|
75
150
|
maxAssistantLoops: 2,
|
|
76
151
|
log,
|
|
152
|
+
ufooAgentOptions: useGlobalProjectRouter ? { routingMode: "global-router" } : {},
|
|
153
|
+
finalizeLocally: !useGlobalProjectRouter,
|
|
77
154
|
});
|
|
78
155
|
|
|
79
156
|
if (!handled.ok) {
|
|
@@ -87,9 +164,56 @@ async function handlePromptRequest(options = {}) {
|
|
|
87
164
|
return false;
|
|
88
165
|
}
|
|
89
166
|
|
|
167
|
+
if (useGlobalProjectRouter) {
|
|
168
|
+
const route = normalizeProjectRoute(handled.payload && handled.payload.project_route);
|
|
169
|
+
if (route) {
|
|
170
|
+
const routed = await Promise.resolve(forwardProjectPrompt({
|
|
171
|
+
targetProjectRoot: route.project_root,
|
|
172
|
+
targetProjectName: route.project_name,
|
|
173
|
+
prompt: route.prompt || req.text || "",
|
|
174
|
+
routeReason: route.reason,
|
|
175
|
+
requestMeta,
|
|
176
|
+
}));
|
|
177
|
+
if (!routed || routed.ok !== true) {
|
|
178
|
+
log(`project-forward-fail ${route.project_root} ${(routed && routed.error) || "project forward failed"}`);
|
|
179
|
+
socket.write(
|
|
180
|
+
`${JSON.stringify({
|
|
181
|
+
type: IPC_RESPONSE_TYPES.ERROR,
|
|
182
|
+
error: (routed && routed.error) || "project forward failed",
|
|
183
|
+
})}\n`,
|
|
184
|
+
);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
consumeControllerInboxEntries(projectRoot, "ufoo-agent", privateReports);
|
|
189
|
+
const routedPayload = buildRoutedProjectPayload(routed.payload || {}, {
|
|
190
|
+
project_root: routed.project_root || route.project_root,
|
|
191
|
+
project_name: routed.project_name || route.project_name,
|
|
192
|
+
reason: route.reason,
|
|
193
|
+
});
|
|
194
|
+
log(`project-forward-ok ${route.project_root}`);
|
|
195
|
+
socket.write(
|
|
196
|
+
`${JSON.stringify({
|
|
197
|
+
type: IPC_RESPONSE_TYPES.RESPONSE,
|
|
198
|
+
data: routedPayload,
|
|
199
|
+
opsResults: routed.opsResults || [],
|
|
200
|
+
})}\n`,
|
|
201
|
+
);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
90
206
|
consumeControllerInboxEntries(projectRoot, "ufoo-agent", privateReports);
|
|
91
207
|
|
|
92
|
-
const payload = handled.payload
|
|
208
|
+
const payload = handled.payload && typeof handled.payload === "object"
|
|
209
|
+
? { ...handled.payload }
|
|
210
|
+
: {};
|
|
211
|
+
if (useGlobalProjectRouter) {
|
|
212
|
+
delete payload.project_route;
|
|
213
|
+
delete payload.disambiguate;
|
|
214
|
+
payload.dispatch = [];
|
|
215
|
+
payload.ops = [];
|
|
216
|
+
}
|
|
93
217
|
const opsResults = handled.opsResults || [];
|
|
94
218
|
log(`ok reply=${Boolean(payload.reply)} dispatch=${(payload.dispatch || []).length} ops=${(payload.ops || []).length}`);
|
|
95
219
|
socket.write(
|
package/src/daemon/reporting.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const EventBus = require("../bus");
|
|
2
3
|
const { BUS_STATUS_PHASES } = require("../shared/eventContract");
|
|
3
4
|
const {
|
|
4
5
|
REPORT_PHASES,
|
|
@@ -62,6 +63,21 @@ function publishToPrivateController(projectRoot, entry) {
|
|
|
62
63
|
appendControllerInboxEntry(projectRoot, entry.controller_id, entry);
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
async function wakePrivateController(projectRoot, entry, log = () => {}) {
|
|
67
|
+
if (!entry || entry.scope !== "private" || !entry.controller_id) return false;
|
|
68
|
+
try {
|
|
69
|
+
const eventBus = new EventBus(projectRoot);
|
|
70
|
+
await eventBus.wake(entry.controller_id, {
|
|
71
|
+
reason: `agent-report:${entry.phase}`,
|
|
72
|
+
shake: false,
|
|
73
|
+
});
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log(`report wake skipped controller=${entry.controller_id} error=${err.message || String(err)}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
async function recordAgentReport({
|
|
66
82
|
projectRoot,
|
|
67
83
|
report,
|
|
@@ -72,6 +88,7 @@ async function recordAgentReport({
|
|
|
72
88
|
appendReport(projectRoot, entry);
|
|
73
89
|
const state = updateReportState(projectRoot, entry);
|
|
74
90
|
publishToPrivateController(projectRoot, entry);
|
|
91
|
+
await wakePrivateController(projectRoot, entry, log);
|
|
75
92
|
const displayName = resolveAgentDisplayName(projectRoot, entry.agent_id);
|
|
76
93
|
if (entry.scope !== "private") {
|
|
77
94
|
onStatus(buildReportStatus(entry, displayName));
|
|
@@ -87,4 +104,5 @@ module.exports = {
|
|
|
87
104
|
formatStatusText,
|
|
88
105
|
buildReportStatus,
|
|
89
106
|
publishToPrivateController,
|
|
107
|
+
wakePrivateController,
|
|
90
108
|
};
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const EventBus = require("../bus");
|
|
5
|
+
const { prepareUcodeBootstrap } = require("../agent/ucodeBootstrap");
|
|
6
|
+
const { isMetaActive } = require("../bus/utils");
|
|
7
|
+
const { getUfooPaths } = require("../ufoo/paths");
|
|
8
|
+
const { loadAgentsData, saveAgentsData } = require("../ufoo/agentsStore");
|
|
9
|
+
const {
|
|
10
|
+
loadPromptProfileRegistry,
|
|
11
|
+
resolvePromptProfileReference,
|
|
12
|
+
} = require("../group/promptProfiles");
|
|
13
|
+
const {
|
|
14
|
+
buildSoloPromptMetadata,
|
|
15
|
+
composeSoloBootstrapPrompt,
|
|
16
|
+
} = require("../group/bootstrap");
|
|
17
|
+
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asTrimmedString(value) {
|
|
23
|
+
return typeof value === "string" ? value.trim() : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveSoloPromptProfile(projectRoot, reference = "", options = {}) {
|
|
27
|
+
const requested = String(reference || "").trim();
|
|
28
|
+
if (!requested) {
|
|
29
|
+
return { ok: true, requested_profile: "", profile: null, registry: null };
|
|
30
|
+
}
|
|
31
|
+
const registry = loadPromptProfileRegistry(projectRoot, options);
|
|
32
|
+
if (Array.isArray(registry.errors) && registry.errors.length > 0) {
|
|
33
|
+
const first = registry.errors[0];
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: first && first.message ? first.message : "prompt profile registry failed to load",
|
|
37
|
+
registry,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const profile = resolvePromptProfileReference(registry, requested);
|
|
41
|
+
if (!profile) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `unknown prompt profile: ${requested}`,
|
|
45
|
+
registry,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
requested_profile: requested,
|
|
51
|
+
profile,
|
|
52
|
+
registry,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSoloBootstrap({
|
|
57
|
+
nickname = "",
|
|
58
|
+
agentType = "",
|
|
59
|
+
requestedProfile = "",
|
|
60
|
+
profile = null,
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!profile) {
|
|
63
|
+
return { ok: true, required: false, promptText: "", metadata: {}, profile: null };
|
|
64
|
+
}
|
|
65
|
+
const metadata = buildSoloPromptMetadata({
|
|
66
|
+
nickname,
|
|
67
|
+
agentType,
|
|
68
|
+
requestedProfile,
|
|
69
|
+
resolvedProfile: profile.id,
|
|
70
|
+
displayName: profile.display_name,
|
|
71
|
+
shortName: profile.short_name,
|
|
72
|
+
summary: profile.summary,
|
|
73
|
+
source: profile.source,
|
|
74
|
+
});
|
|
75
|
+
const promptText = composeSoloBootstrapPrompt({
|
|
76
|
+
profilePrompt: profile.prompt,
|
|
77
|
+
metadata,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
required: true,
|
|
82
|
+
promptText,
|
|
83
|
+
metadata,
|
|
84
|
+
profile,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseTimestampMs(value) {
|
|
89
|
+
const ms = Date.parse(String(value || ""));
|
|
90
|
+
return Number.isFinite(ms) ? ms : Number.NaN;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getAgentRuntimeMeta(projectRoot, subscriberId) {
|
|
94
|
+
try {
|
|
95
|
+
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
96
|
+
const bus = loadAgentsData(busPath);
|
|
97
|
+
const meta = bus && bus.agents ? bus.agents[subscriberId] : null;
|
|
98
|
+
return meta && typeof meta === "object" ? meta : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function waitForSoloBootstrapReady(projectRoot, subscriberId, options = {}) {
|
|
105
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
|
|
106
|
+
const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 250;
|
|
107
|
+
const protectionMs = Number.isFinite(options.protectionMs) ? options.protectionMs : 3000;
|
|
108
|
+
const workingGraceMs = Number.isFinite(options.workingGraceMs) ? options.workingGraceMs : 10000;
|
|
109
|
+
const startedAt = Date.now();
|
|
110
|
+
let lastState = "";
|
|
111
|
+
|
|
112
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
113
|
+
const meta = getAgentRuntimeMeta(projectRoot, subscriberId);
|
|
114
|
+
const status = asTrimmedString(meta && meta.status).toLowerCase();
|
|
115
|
+
const state = asTrimmedString(meta && meta.activity_state).toLowerCase();
|
|
116
|
+
lastState = state || lastState;
|
|
117
|
+
if (status && status !== "active") {
|
|
118
|
+
return { ok: false, error: `agent became ${status} before bootstrap` };
|
|
119
|
+
}
|
|
120
|
+
const elapsed = Date.now() - startedAt;
|
|
121
|
+
if (state === "ready" || state === "idle" || state === "waiting_input" || state === "blocked") {
|
|
122
|
+
return { ok: true, activity_state: state };
|
|
123
|
+
}
|
|
124
|
+
if (state === "working") {
|
|
125
|
+
if (elapsed < protectionMs) {
|
|
126
|
+
await sleep(retryDelayMs);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const activitySinceMs = parseTimestampMs(meta && meta.activity_since);
|
|
130
|
+
const workingMs = Number.isFinite(activitySinceMs)
|
|
131
|
+
? Math.max(0, Date.now() - activitySinceMs)
|
|
132
|
+
: elapsed;
|
|
133
|
+
if (workingMs >= workingGraceMs) {
|
|
134
|
+
return { ok: true, activity_state: state, degraded: true };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!state || state === "starting" || state === "running" || state === "working") {
|
|
138
|
+
await sleep(retryDelayMs);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
await sleep(retryDelayMs);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: lastState
|
|
147
|
+
? `agent not ready for bootstrap (last activity_state=${lastState})`
|
|
148
|
+
: "agent not ready for bootstrap",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function injectSoloBootstrapPrompt(projectRoot, subscriberId, promptText, options = {}) {
|
|
153
|
+
const ready = await waitForSoloBootstrapReady(projectRoot, subscriberId, options);
|
|
154
|
+
if (!ready.ok) return ready;
|
|
155
|
+
|
|
156
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
|
|
157
|
+
const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 250;
|
|
158
|
+
const deadline = Date.now() + timeoutMs;
|
|
159
|
+
const bus = new EventBus(projectRoot);
|
|
160
|
+
let lastError = null;
|
|
161
|
+
while (Date.now() < deadline) {
|
|
162
|
+
try {
|
|
163
|
+
await bus.inject(subscriberId, promptText);
|
|
164
|
+
return { ok: true };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
lastError = err;
|
|
167
|
+
await sleep(retryDelayMs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: lastError && lastError.message ? lastError.message : `bootstrap inject failed for ${subscriberId}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function prepareSoloUcodeBootstrap(projectRoot, nickname, promptText) {
|
|
177
|
+
const safeNickname = String(nickname || "agent").replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
178
|
+
const targetFile = path.join(getUfooPaths(projectRoot).agentDir, "ucode", `${safeNickname}-bootstrap.md`);
|
|
179
|
+
return prepareUcodeBootstrap({
|
|
180
|
+
projectRoot,
|
|
181
|
+
targetFile,
|
|
182
|
+
promptText,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function loadBusMeta(projectRoot) {
|
|
187
|
+
return loadAgentsData(getUfooPaths(projectRoot).agentsFile);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isLiveAgentMeta(meta) {
|
|
191
|
+
return Boolean(meta) && isMetaActive(meta);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveExistingAgent(projectRoot, target = "") {
|
|
195
|
+
const key = String(target || "").trim();
|
|
196
|
+
if (!key) return null;
|
|
197
|
+
const bus = loadBusMeta(projectRoot);
|
|
198
|
+
const agents = bus && bus.agents ? bus.agents : {};
|
|
199
|
+
if (isLiveAgentMeta(agents[key])) {
|
|
200
|
+
return { subscriberId: key, meta: agents[key] };
|
|
201
|
+
}
|
|
202
|
+
for (const [subscriberId, meta] of Object.entries(agents)) {
|
|
203
|
+
if (meta && meta.nickname === key && isLiveAgentMeta(meta)) {
|
|
204
|
+
return { subscriberId, meta };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function findOwningGroup(projectRoot, subscriberId = "") {
|
|
211
|
+
const targetSubscriber = asTrimmedString(subscriberId);
|
|
212
|
+
if (!targetSubscriber) return null;
|
|
213
|
+
const liveMeta = getAgentRuntimeMeta(projectRoot, targetSubscriber);
|
|
214
|
+
if (!isLiveAgentMeta(liveMeta)) return null;
|
|
215
|
+
if (asTrimmedString(liveMeta.role_owner).toLowerCase() === "solo") return null;
|
|
216
|
+
const liveNickname = asTrimmedString(liveMeta.nickname);
|
|
217
|
+
const groupsDir = getUfooPaths(projectRoot).groupsDir;
|
|
218
|
+
if (!groupsDir) return null;
|
|
219
|
+
let files = [];
|
|
220
|
+
try {
|
|
221
|
+
files = require("fs").readdirSync(groupsDir).filter((name) => name.endsWith(".json"));
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
for (const fileName of files) {
|
|
226
|
+
try {
|
|
227
|
+
const filePath = path.join(groupsDir, fileName);
|
|
228
|
+
const runtime = JSON.parse(require("fs").readFileSync(filePath, "utf8"));
|
|
229
|
+
if (!runtime || runtime.status !== "active" || !Array.isArray(runtime.members)) continue;
|
|
230
|
+
const found = runtime.members.find((member) =>
|
|
231
|
+
member
|
|
232
|
+
&& asTrimmedString(member.subscriber_id) === targetSubscriber
|
|
233
|
+
&& (member.status === "active" || member.status === "reused")
|
|
234
|
+
&& asTrimmedString(member.nickname)
|
|
235
|
+
&& (!liveNickname || asTrimmedString(member.nickname) === liveNickname)
|
|
236
|
+
);
|
|
237
|
+
if (found) {
|
|
238
|
+
return {
|
|
239
|
+
group_id: String(runtime.group_id || "").trim(),
|
|
240
|
+
template_alias: String(runtime.template_alias || "").trim(),
|
|
241
|
+
nickname: String(found.nickname || "").trim(),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// ignore malformed runtime
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function pickLaunchSubscriber(launchResult = {}, fallbackTarget = "") {
|
|
252
|
+
if (launchResult && Array.isArray(launchResult.subscriber_ids) && launchResult.subscriber_ids.length > 0) {
|
|
253
|
+
return asTrimmedString(launchResult.subscriber_ids[0]);
|
|
254
|
+
}
|
|
255
|
+
if (launchResult && launchResult.agent_id) {
|
|
256
|
+
return asTrimmedString(launchResult.agent_id);
|
|
257
|
+
}
|
|
258
|
+
return asTrimmedString(fallbackTarget);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function shouldRollbackRoleAssignmentLaunch(launchResult = {}) {
|
|
262
|
+
return Boolean(launchResult && launchResult.ok !== false && launchResult.skipped !== true);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function rollbackLaunchAfterRoleAssignmentFailure(
|
|
266
|
+
projectRoot,
|
|
267
|
+
launchResult,
|
|
268
|
+
fallbackTarget,
|
|
269
|
+
handleOps,
|
|
270
|
+
processManager = null
|
|
271
|
+
) {
|
|
272
|
+
if (!shouldRollbackRoleAssignmentLaunch(launchResult)) {
|
|
273
|
+
return { ok: true, skipped: true, rolled_back: false, target: "" };
|
|
274
|
+
}
|
|
275
|
+
if (typeof handleOps !== "function") {
|
|
276
|
+
return { ok: false, rolled_back: false, target: "", error: "handleOps is required for rollback" };
|
|
277
|
+
}
|
|
278
|
+
const target = pickLaunchSubscriber(launchResult, fallbackTarget);
|
|
279
|
+
if (!target) {
|
|
280
|
+
return { ok: false, rolled_back: false, target: "", error: "missing subscriber_id for rollback" };
|
|
281
|
+
}
|
|
282
|
+
const opsResults = await handleOps(projectRoot, [{ action: "close", agent_id: target }], processManager);
|
|
283
|
+
const closeResult = Array.isArray(opsResults)
|
|
284
|
+
? opsResults.find((entry) => entry && entry.action === "close")
|
|
285
|
+
: null;
|
|
286
|
+
if (closeResult && closeResult.ok !== false) {
|
|
287
|
+
return { ok: true, skipped: false, rolled_back: true, target };
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
skipped: false,
|
|
292
|
+
rolled_back: false,
|
|
293
|
+
target,
|
|
294
|
+
error: closeResult && closeResult.error ? closeResult.error : "close failed",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function persistSoloRoleMetadata(projectRoot, subscriberId, payload = {}) {
|
|
299
|
+
const filePath = getUfooPaths(projectRoot).agentsFile;
|
|
300
|
+
const bus = loadAgentsData(filePath);
|
|
301
|
+
const meta = bus.agents && bus.agents[subscriberId];
|
|
302
|
+
if (!meta) return false;
|
|
303
|
+
bus.agents[subscriberId] = {
|
|
304
|
+
...meta,
|
|
305
|
+
bootstrap_kind: "solo",
|
|
306
|
+
role_owner: "solo",
|
|
307
|
+
requested_profile: String(payload.requested_profile || "").trim(),
|
|
308
|
+
resolved_profile: String(payload.resolved_profile || "").trim(),
|
|
309
|
+
bootstrap_fingerprint: String(payload.bootstrap_fingerprint || "").trim(),
|
|
310
|
+
bootstrapped_subscriber_id: String(payload.bootstrapped_subscriber_id || subscriberId).trim(),
|
|
311
|
+
role_assigned_at: new Date().toISOString(),
|
|
312
|
+
};
|
|
313
|
+
saveAgentsData(filePath, bus);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function buildSoloBootstrapFingerprint({ subscriberId = "", requestedProfile = "", resolvedProfile = "", promptText = "" } = {}) {
|
|
318
|
+
return require("crypto")
|
|
319
|
+
.createHash("sha256")
|
|
320
|
+
.update(JSON.stringify({
|
|
321
|
+
subscriber_id: String(subscriberId || ""),
|
|
322
|
+
requested_profile: String(requestedProfile || ""),
|
|
323
|
+
resolved_profile: String(resolvedProfile || ""),
|
|
324
|
+
prompt: String(promptText || ""),
|
|
325
|
+
}))
|
|
326
|
+
.digest("hex");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isSameSoloAssignment(meta = {}, subscriberId = "", requestedProfile = "", resolvedProfile = "", promptText = "") {
|
|
330
|
+
const currentOwner = String(meta.role_owner || "").trim();
|
|
331
|
+
const currentKind = String(meta.bootstrap_kind || "").trim();
|
|
332
|
+
const currentFingerprint = String(meta.bootstrap_fingerprint || "").trim();
|
|
333
|
+
const nextFingerprint = buildSoloBootstrapFingerprint({
|
|
334
|
+
subscriberId,
|
|
335
|
+
requestedProfile,
|
|
336
|
+
resolvedProfile,
|
|
337
|
+
promptText,
|
|
338
|
+
});
|
|
339
|
+
return currentOwner === "solo"
|
|
340
|
+
&& currentKind === "solo"
|
|
341
|
+
&& currentFingerprint
|
|
342
|
+
&& currentFingerprint === nextFingerprint;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function assignSoloRoleToExistingAgent(projectRoot, target, profileReference, options = {}) {
|
|
346
|
+
const resolvedTarget = resolveExistingAgent(projectRoot, target);
|
|
347
|
+
if (!resolvedTarget) {
|
|
348
|
+
return { ok: false, error: `agent not found: ${target}` };
|
|
349
|
+
}
|
|
350
|
+
const ownership = findOwningGroup(projectRoot, resolvedTarget.subscriberId);
|
|
351
|
+
if (ownership) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
error: `agent is group-owned by ${ownership.group_id || ownership.template_alias || "active-group"}`,
|
|
355
|
+
group: ownership,
|
|
356
|
+
subscriber_id: resolvedTarget.subscriberId,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const resolvedProfile = resolveSoloPromptProfile(projectRoot, profileReference, options.promptProfilesOptions || {});
|
|
361
|
+
if (!resolvedProfile.ok) return resolvedProfile;
|
|
362
|
+
const built = buildSoloBootstrap({
|
|
363
|
+
nickname: resolvedTarget.meta.nickname || resolvedTarget.subscriberId,
|
|
364
|
+
agentType: resolvedTarget.meta.agent_type || "",
|
|
365
|
+
requestedProfile: resolvedProfile.requested_profile,
|
|
366
|
+
profile: resolvedProfile.profile,
|
|
367
|
+
});
|
|
368
|
+
if (!built.required) {
|
|
369
|
+
return { ok: false, error: "prompt profile is required" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (isSameSoloAssignment(
|
|
373
|
+
resolvedTarget.meta,
|
|
374
|
+
resolvedTarget.subscriberId,
|
|
375
|
+
resolvedProfile.requested_profile,
|
|
376
|
+
resolvedProfile.profile.id,
|
|
377
|
+
built.promptText,
|
|
378
|
+
)) {
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
skipped: true,
|
|
382
|
+
subscriber_id: resolvedTarget.subscriberId,
|
|
383
|
+
requested_profile: resolvedProfile.requested_profile,
|
|
384
|
+
resolved_profile: resolvedProfile.profile.id,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const injected = await injectSoloBootstrapPrompt(
|
|
389
|
+
projectRoot,
|
|
390
|
+
resolvedTarget.subscriberId,
|
|
391
|
+
built.promptText,
|
|
392
|
+
options.bootstrapOptions || {},
|
|
393
|
+
);
|
|
394
|
+
if (!injected.ok) {
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
error: injected.error || "solo bootstrap inject failed",
|
|
398
|
+
subscriber_id: resolvedTarget.subscriberId,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const fingerprint = buildSoloBootstrapFingerprint({
|
|
403
|
+
subscriberId: resolvedTarget.subscriberId,
|
|
404
|
+
requestedProfile: resolvedProfile.requested_profile,
|
|
405
|
+
resolvedProfile: resolvedProfile.profile.id,
|
|
406
|
+
promptText: built.promptText,
|
|
407
|
+
});
|
|
408
|
+
persistSoloRoleMetadata(projectRoot, resolvedTarget.subscriberId, {
|
|
409
|
+
requested_profile: resolvedProfile.requested_profile,
|
|
410
|
+
resolved_profile: resolvedProfile.profile.id,
|
|
411
|
+
bootstrap_fingerprint: fingerprint,
|
|
412
|
+
bootstrapped_subscriber_id: resolvedTarget.subscriberId,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
ok: true,
|
|
417
|
+
subscriber_id: resolvedTarget.subscriberId,
|
|
418
|
+
requested_profile: resolvedProfile.requested_profile,
|
|
419
|
+
resolved_profile: resolvedProfile.profile.id,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
module.exports = {
|
|
424
|
+
resolveSoloPromptProfile,
|
|
425
|
+
buildSoloBootstrap,
|
|
426
|
+
waitForSoloBootstrapReady,
|
|
427
|
+
injectSoloBootstrapPrompt,
|
|
428
|
+
prepareSoloUcodeBootstrap,
|
|
429
|
+
resolveExistingAgent,
|
|
430
|
+
findOwningGroup,
|
|
431
|
+
persistSoloRoleMetadata,
|
|
432
|
+
buildSoloBootstrapFingerprint,
|
|
433
|
+
rollbackLaunchAfterRoleAssignmentFailure,
|
|
434
|
+
assignSoloRoleToExistingAgent,
|
|
435
|
+
};
|
package/src/daemon/status.js
CHANGED
|
@@ -2,7 +2,7 @@ const fs = require("fs");
|
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const { getUfooPaths } = require("../ufoo/paths");
|
|
4
4
|
const { isMetaActive } = require("../bus/utils");
|
|
5
|
-
const { readReportSummary } = require("../report/store");
|
|
5
|
+
const { readReportSummary, countControllerInboxEntries } = require("../report/store");
|
|
6
6
|
|
|
7
7
|
function readBus(projectRoot) {
|
|
8
8
|
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
@@ -72,6 +72,8 @@ function normalizeCronTasks(raw = []) {
|
|
|
72
72
|
onceAt: String(task && task.onceAt ? task.onceAt : ""),
|
|
73
73
|
targets: Array.isArray(task && task.targets) ? task.targets.slice() : [],
|
|
74
74
|
prompt: String(task && task.prompt ? task.prompt : ""),
|
|
75
|
+
title: String(task && task.title ? task.title : ""),
|
|
76
|
+
label: String(task && task.label ? task.label : ""),
|
|
75
77
|
summary: String(task && task.summary ? task.summary : ""),
|
|
76
78
|
createdAt: Number(task && task.createdAt ? task.createdAt : 0) || 0,
|
|
77
79
|
lastRunAt: Number(task && task.lastRunAt ? task.lastRunAt : 0) || 0,
|
|
@@ -130,6 +132,7 @@ function buildStatus(projectRoot, options = {}) {
|
|
|
130
132
|
const decisions = readDecisions(projectRoot);
|
|
131
133
|
const unread = readUnread(projectRoot);
|
|
132
134
|
const reports = readReportSummary(projectRoot);
|
|
135
|
+
const controllerPendingTotal = countControllerInboxEntries(projectRoot, "ufoo-agent");
|
|
133
136
|
const groups = readGroups(projectRoot);
|
|
134
137
|
const subscribers = bus ? Object.keys(bus.agents || {}) : [];
|
|
135
138
|
const cronTasks = normalizeCronTasks(options.cronTasks || []);
|
|
@@ -174,6 +177,9 @@ function buildStatus(projectRoot, options = {}) {
|
|
|
174
177
|
unread,
|
|
175
178
|
decisions,
|
|
176
179
|
reports,
|
|
180
|
+
controller: {
|
|
181
|
+
pending_total: controllerPendingTotal,
|
|
182
|
+
},
|
|
177
183
|
cron: {
|
|
178
184
|
count: cronTasks.length,
|
|
179
185
|
tasks: cronTasks,
|