u-foo 2.5.0 → 2.5.2
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/bin/uagy.js +42 -4
- package/bin/uclaude.js +42 -1
- package/bin/ucode.js +42 -1
- package/bin/ucodex.js +42 -1
- package/package.json +1 -1
- package/src/agents/internal/internalRunner.js +57 -78
- package/src/agents/launch/notifier.js +35 -111
- package/src/agents/launch/ptyRunner.js +28 -48
- package/src/agents/prompts/defaultBootstrap.js +16 -1
- package/src/app/cli/busCoreCommands.js +19 -1
- package/src/app/cli/run.js +1 -53
- package/src/code/agent.js +41 -140
- package/src/coordination/bus/deliveryQueue.js +308 -0
- package/src/coordination/bus/index.js +3 -27
- package/src/coordination/bus/message.js +35 -15
- package/src/coordination/bus/queue.js +23 -7
- package/src/runtime/daemon/deliveryScheduler.js +205 -0
- package/src/runtime/daemon/index.js +27 -38
- package/src/runtime/daemon/reportControlBus.js +20 -43
- package/src/coordination/bus/daemon.js +0 -535
|
@@ -64,10 +64,25 @@ function buildDefaultStartupBootstrapPrompt({ agentType = "", projectRoot = "" }
|
|
|
64
64
|
`Session bootstrap for ${displayAgent}.`,
|
|
65
65
|
"Adopt the following ufoo coordination protocol silently.",
|
|
66
66
|
"Do not reply to this bootstrap message unless the user explicitly asks about it. After applying it, continue the active task or wait for user input.",
|
|
67
|
-
SHARED_UFOO_PROTOCOL,
|
|
68
67
|
];
|
|
69
68
|
|
|
70
69
|
const root = asTrimmedString(projectRoot) || process.cwd();
|
|
70
|
+
const requestedProfile = asTrimmedString(process.env.UFOO_PROMPT_PROFILE);
|
|
71
|
+
if (requestedProfile) {
|
|
72
|
+
try {
|
|
73
|
+
const { loadPromptProfileRegistry, resolvePromptProfileReference } = require("./promptProfiles");
|
|
74
|
+
const registry = loadPromptProfileRegistry(root);
|
|
75
|
+
const profile = resolvePromptProfileReference(registry, requestedProfile);
|
|
76
|
+
if (profile && profile.prompt) {
|
|
77
|
+
segments.push(`Your role: ${profile.id} (${profile.short_name || "Custom Role"}).\nAdopt the following role context:\n\n${profile.prompt}`);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
segments.push(SHARED_UFOO_PROTOCOL);
|
|
85
|
+
|
|
71
86
|
const teamActivity = loadTeamActivityContext(root);
|
|
72
87
|
if (teamActivity) {
|
|
73
88
|
segments.push(teamActivity);
|
|
@@ -39,6 +39,18 @@ function parseSendArgs(cmdArgs = []) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function resolvePollSubscriber(cmdArgs = [], env = process.env) {
|
|
43
|
+
const positionals = cmdArgs.filter((arg) => typeof arg === "string" && !arg.startsWith("--"));
|
|
44
|
+
const subscriber = String(positionals[0] || env.UFOO_SUBSCRIBER_ID || "").trim();
|
|
45
|
+
if (!subscriber) {
|
|
46
|
+
throw new Error("poll requires [subscriber] or UFOO_SUBSCRIBER_ID");
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
subscriber,
|
|
50
|
+
autoAck: cmdArgs.includes("--ack") || cmdArgs.includes("--auto-ack"),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
async function runBusCoreCommand(eventBus, cmd, cmdArgs = []) {
|
|
43
55
|
switch (cmd) {
|
|
44
56
|
case "init":
|
|
@@ -76,6 +88,12 @@ async function runBusCoreCommand(eventBus, cmd, cmdArgs = []) {
|
|
|
76
88
|
case "check":
|
|
77
89
|
await eventBus.check(cmdArgs[0]);
|
|
78
90
|
return {};
|
|
91
|
+
case "poll":
|
|
92
|
+
{
|
|
93
|
+
const parsed = resolvePollSubscriber(cmdArgs);
|
|
94
|
+
await eventBus.check(parsed.subscriber, parsed.autoAck);
|
|
95
|
+
}
|
|
96
|
+
return {};
|
|
79
97
|
case "ack":
|
|
80
98
|
await eventBus.ack(cmdArgs[0]);
|
|
81
99
|
return {};
|
|
@@ -99,4 +117,4 @@ async function runBusCoreCommand(eventBus, cmd, cmdArgs = []) {
|
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
module.exports = { runBusCoreCommand };
|
|
120
|
+
module.exports = { runBusCoreCommand, resolvePollSubscriber };
|
package/src/app/cli/run.js
CHANGED
|
@@ -1524,32 +1524,6 @@ async function runCli(argv) {
|
|
|
1524
1524
|
process.exitCode = 1;
|
|
1525
1525
|
});
|
|
1526
1526
|
});
|
|
1527
|
-
bus
|
|
1528
|
-
.command("daemon")
|
|
1529
|
-
.description("Start/stop daemon that auto-injects /bus into terminals")
|
|
1530
|
-
.option("--interval <n>", "Poll interval in seconds", "2")
|
|
1531
|
-
.option("--daemon", "Run in background")
|
|
1532
|
-
.option("--stop", "Stop running daemon")
|
|
1533
|
-
.option("--status", "Check daemon status")
|
|
1534
|
-
.action((opts) => {
|
|
1535
|
-
const EventBus = require("../../coordination/bus");
|
|
1536
|
-
const eventBus = new EventBus(process.cwd());
|
|
1537
|
-
(async () => {
|
|
1538
|
-
try {
|
|
1539
|
-
const interval = parseInt(opts.interval, 10) * 1000 || 2000;
|
|
1540
|
-
if (opts.stop) {
|
|
1541
|
-
await eventBus.daemon("stop");
|
|
1542
|
-
} else if (opts.status) {
|
|
1543
|
-
await eventBus.daemon("status");
|
|
1544
|
-
} else {
|
|
1545
|
-
await eventBus.daemon("start", { background: opts.daemon, interval });
|
|
1546
|
-
}
|
|
1547
|
-
} catch (err) {
|
|
1548
|
-
console.error(err.message);
|
|
1549
|
-
process.exitCode = 1;
|
|
1550
|
-
}
|
|
1551
|
-
})();
|
|
1552
|
-
});
|
|
1553
1527
|
bus
|
|
1554
1528
|
.command("inject")
|
|
1555
1529
|
.description("Inject /bus into a Terminal.app tab by subscriber ID")
|
|
@@ -1739,6 +1713,7 @@ async function runCli(argv) {
|
|
|
1739
1713
|
console.log(" ufoo online send --nickname <name> --text <msg> [--channel <ch>] [--room <id>]");
|
|
1740
1714
|
console.log(" ufoo online inbox <nickname> [--clear] [--unread]");
|
|
1741
1715
|
console.log(" ufoo bus wake <target> [--reason <reason>] [--no-shake]");
|
|
1716
|
+
console.log(" ufoo bus poll [subscriber] [--ack]");
|
|
1742
1717
|
console.log(" ufoo bus <args...> (JS bus implementation)");
|
|
1743
1718
|
console.log(" ufoo ctx <subcmd> ... (doctor|lint|decisions|sync)");
|
|
1744
1719
|
console.log(" ufoo history <build|show|prompt> [limit]");
|
|
@@ -2377,33 +2352,6 @@ async function runCli(argv) {
|
|
|
2377
2352
|
});
|
|
2378
2353
|
return;
|
|
2379
2354
|
}
|
|
2380
|
-
if (sub === "daemon") {
|
|
2381
|
-
// 使用 JavaScript daemon
|
|
2382
|
-
const EventBus = require("../../coordination/bus");
|
|
2383
|
-
const eventBus = new EventBus(process.cwd());
|
|
2384
|
-
|
|
2385
|
-
(async () => {
|
|
2386
|
-
try {
|
|
2387
|
-
const hasStop = rest.includes("--stop");
|
|
2388
|
-
const hasStatus = rest.includes("--status");
|
|
2389
|
-
const hasDaemon = rest.includes("--daemon");
|
|
2390
|
-
const intervalIdx = rest.indexOf("--interval");
|
|
2391
|
-
const interval = intervalIdx !== -1 ? parseInt(rest[intervalIdx + 1], 10) * 1000 : 2000;
|
|
2392
|
-
|
|
2393
|
-
if (hasStop) {
|
|
2394
|
-
await eventBus.daemon("stop");
|
|
2395
|
-
} else if (hasStatus) {
|
|
2396
|
-
await eventBus.daemon("status");
|
|
2397
|
-
} else {
|
|
2398
|
-
await eventBus.daemon("start", { background: hasDaemon, interval });
|
|
2399
|
-
}
|
|
2400
|
-
} catch (err) {
|
|
2401
|
-
console.error(err.message);
|
|
2402
|
-
process.exitCode = 1;
|
|
2403
|
-
}
|
|
2404
|
-
})();
|
|
2405
|
-
return;
|
|
2406
|
-
}
|
|
2407
2355
|
if (sub === "inject") {
|
|
2408
2356
|
// 使用 JavaScript inject
|
|
2409
2357
|
const EventBus = require("../../coordination/bus");
|
package/src/code/agent.js
CHANGED
|
@@ -31,6 +31,7 @@ const {
|
|
|
31
31
|
listUcodeSkills,
|
|
32
32
|
showSkill,
|
|
33
33
|
} = require("./skills");
|
|
34
|
+
const { DeliveryQueue } = require("../coordination/bus/deliveryQueue");
|
|
34
35
|
|
|
35
36
|
function printPrompt() {
|
|
36
37
|
process.stdout.write("> ");
|
|
@@ -883,110 +884,31 @@ function getPendingBusCount(workspaceRoot = process.cwd(), subscriberId = "") {
|
|
|
883
884
|
function drainJsonlFile(filePath = "") {
|
|
884
885
|
const target = String(filePath || "").trim();
|
|
885
886
|
if (!target) return { drained: [], rawLines: [], error: "" };
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
let renamed = false;
|
|
887
|
+
const queue = new DeliveryQueue(target);
|
|
888
|
+
const drained = [];
|
|
889
|
+
const rawLines = [];
|
|
890
|
+
const claims = [];
|
|
891
891
|
try {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}
|
|
901
|
-
} catch {
|
|
902
|
-
// ignore
|
|
892
|
+
queue.recover();
|
|
893
|
+
while (true) {
|
|
894
|
+
const claim = queue.claimNext();
|
|
895
|
+
if (!claim) break;
|
|
896
|
+
claims.push(claim);
|
|
897
|
+
drained.push(claim.event);
|
|
898
|
+
rawLines.push(JSON.stringify(claim.event));
|
|
899
|
+
queue.completeClaim(claim);
|
|
903
900
|
}
|
|
901
|
+
} catch (err) {
|
|
902
|
+
for (const claim of claims) queue.restoreClaim(claim);
|
|
904
903
|
return { drained: [], rawLines: [], error: err && err.message ? err.message : "drain failed" };
|
|
905
904
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
}
|
|
914
|
-
}).filter(Boolean);
|
|
915
|
-
|
|
916
|
-
// Keep processing file around for potential requeue decisions by caller.
|
|
917
|
-
return { drained, rawLines, error: "", processingFile };
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
function requeueJsonlLines(filePath = "", lines = []) {
|
|
921
|
-
const target = String(filePath || "").trim();
|
|
922
|
-
const list = Array.isArray(lines) ? lines.filter((l) => String(l || "").trim()) : [];
|
|
923
|
-
if (!target || list.length === 0) return;
|
|
924
|
-
try {
|
|
925
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
926
|
-
fs.appendFileSync(target, `${list.join("\n")}\n`, "utf8");
|
|
927
|
-
} catch {
|
|
928
|
-
// ignore requeue errors
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
function cleanupProcessingFile(filePath = "") {
|
|
933
|
-
const target = String(filePath || "").trim();
|
|
934
|
-
if (!target) return;
|
|
935
|
-
try {
|
|
936
|
-
if (fs.existsSync(target)) fs.rmSync(target, { force: true });
|
|
937
|
-
} catch {
|
|
938
|
-
// ignore
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
function recoverStaleProcessingFiles(pendingFilePath = "", options = {}) {
|
|
943
|
-
const pendingFile = String(pendingFilePath || "").trim();
|
|
944
|
-
if (!pendingFile) return 0;
|
|
945
|
-
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : 30000;
|
|
946
|
-
const dir = path.dirname(pendingFile);
|
|
947
|
-
const base = path.basename(pendingFile);
|
|
948
|
-
const prefix = `${base}.processing.`;
|
|
949
|
-
const now = Date.now();
|
|
950
|
-
let recovered = 0;
|
|
951
|
-
|
|
952
|
-
try {
|
|
953
|
-
if (!fs.existsSync(dir)) return 0;
|
|
954
|
-
const names = fs.readdirSync(dir);
|
|
955
|
-
for (const name of names) {
|
|
956
|
-
if (!name || !name.startsWith(prefix)) continue;
|
|
957
|
-
const fullPath = path.join(dir, name);
|
|
958
|
-
let stat = null;
|
|
959
|
-
try {
|
|
960
|
-
stat = fs.statSync(fullPath);
|
|
961
|
-
} catch {
|
|
962
|
-
continue;
|
|
963
|
-
}
|
|
964
|
-
if (!stat || !stat.isFile()) continue;
|
|
965
|
-
const pidMatch = name.match(/\.processing\.(\d+)\./);
|
|
966
|
-
const pid = pidMatch ? parseInt(pidMatch[1], 10) : NaN;
|
|
967
|
-
const pidDead = Number.isFinite(pid) && pid > 0 && !isPidAlive(pid);
|
|
968
|
-
const tooOld = Number.isFinite(maxAgeMs) && maxAgeMs > 0 && (now - stat.mtimeMs >= maxAgeMs);
|
|
969
|
-
if (!pidDead && !tooOld) continue;
|
|
970
|
-
|
|
971
|
-
let content = "";
|
|
972
|
-
try {
|
|
973
|
-
content = String(fs.readFileSync(fullPath, "utf8") || "");
|
|
974
|
-
} catch {
|
|
975
|
-
content = "";
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
const lines = content.split(/\r?\n/).filter((line) => String(line || "").trim());
|
|
979
|
-
if (lines.length > 0) {
|
|
980
|
-
requeueJsonlLines(pendingFile, lines);
|
|
981
|
-
}
|
|
982
|
-
cleanupProcessingFile(fullPath);
|
|
983
|
-
recovered += 1;
|
|
984
|
-
}
|
|
985
|
-
} catch {
|
|
986
|
-
return recovered;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
return recovered;
|
|
905
|
+
return {
|
|
906
|
+
drained,
|
|
907
|
+
rawLines,
|
|
908
|
+
error: "",
|
|
909
|
+
claims,
|
|
910
|
+
processingFile: claims[0] ? claims[0].processingFile : "",
|
|
911
|
+
};
|
|
990
912
|
}
|
|
991
913
|
|
|
992
914
|
function extractTaskFromBusEvent(evt) {
|
|
@@ -1144,39 +1066,22 @@ async function runUbusCommand(state = {}, options = {}) {
|
|
|
1144
1066
|
|
|
1145
1067
|
// Prefer consuming pending.jsonl directly (stable, ANSI/wrapping-proof).
|
|
1146
1068
|
const pendingFile = resolvePendingQueueFile(runtimeWorkspace, subscriberId);
|
|
1147
|
-
|
|
1148
|
-
|
|
1069
|
+
const queue = pendingFile ? new DeliveryQueue(pendingFile) : null;
|
|
1070
|
+
if (queue) queue.recover();
|
|
1149
1071
|
const hasPendingFile = Boolean(pendingFile && fs.existsSync(pendingFile));
|
|
1150
|
-
const drainedRes = hasPendingFile ? drainJsonlFile(pendingFile) : { drained: [], rawLines: [], error: "", processingFile: "" };
|
|
1151
|
-
if (drainedRes && drainedRes.error) {
|
|
1152
|
-
return {
|
|
1153
|
-
ok: false,
|
|
1154
|
-
summary: "",
|
|
1155
|
-
error: drainedRes.error,
|
|
1156
|
-
handled: 0,
|
|
1157
|
-
subscriberId,
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
const rawLines = Array.isArray(drainedRes.rawLines) ? drainedRes.rawLines : [];
|
|
1161
|
-
const messages = rawLines
|
|
1162
|
-
.map((rawLine) => {
|
|
1163
|
-
try {
|
|
1164
|
-
const evt = JSON.parse(rawLine);
|
|
1165
|
-
const msg = extractTaskFromBusEvent(evt);
|
|
1166
|
-
if (!msg) return null;
|
|
1167
|
-
return { ...msg, rawLine };
|
|
1168
|
-
} catch {
|
|
1169
|
-
return null;
|
|
1170
|
-
}
|
|
1171
|
-
})
|
|
1172
|
-
.filter(Boolean);
|
|
1173
1072
|
let handled = 0;
|
|
1174
1073
|
const sendErrors = [];
|
|
1175
|
-
const failedRawLines = [];
|
|
1176
1074
|
const messageExchanges = [];
|
|
1177
1075
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1076
|
+
if (queue && hasPendingFile) {
|
|
1077
|
+
while (fs.existsSync(pendingFile)) {
|
|
1078
|
+
const claim = queue.claimNext();
|
|
1079
|
+
if (!claim) break;
|
|
1080
|
+
const message = extractTaskFromBusEvent(claim.event);
|
|
1081
|
+
if (!message) {
|
|
1082
|
+
queue.completeClaim(claim);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1180
1085
|
let nlResult;
|
|
1181
1086
|
|
|
1182
1087
|
// Notify that we received the message (for immediate display)
|
|
@@ -1200,32 +1105,28 @@ async function runUbusCommand(state = {}, options = {}) {
|
|
|
1200
1105
|
signal: options.signal,
|
|
1201
1106
|
});
|
|
1202
1107
|
} catch (err) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1108
|
+
const errorMessage = err && err.message ? err.message : "task failed";
|
|
1109
|
+
sendErrors.push(`task from ${message.publisher} failed: ${errorMessage}`);
|
|
1110
|
+
queue.restoreClaim(claim);
|
|
1205
1111
|
// Send error notification
|
|
1206
|
-
shell(`ufoo bus send ${shellQuote(message.publisher)} ${shellQuote(`❌ Error: ${
|
|
1207
|
-
|
|
1112
|
+
shell(`ufoo bus send ${shellQuote(message.publisher)} ${shellQuote(`❌ Error: ${errorMessage}`)}`);
|
|
1113
|
+
break;
|
|
1208
1114
|
}
|
|
1209
1115
|
const reply = String(formatNl(nlResult, false) || "").replace(/\s+/g, " ").trim() || "Done.";
|
|
1210
1116
|
const sendRes = shell(`ufoo bus send ${shellQuote(message.publisher)} ${shellQuote(reply.slice(0, 2000))}`);
|
|
1211
1117
|
if (!sendRes.ok) {
|
|
1212
1118
|
sendErrors.push(`reply to ${message.publisher} failed: ${sendRes.error || "send failed"}`);
|
|
1213
|
-
|
|
1214
|
-
|
|
1119
|
+
queue.restoreClaim(claim);
|
|
1120
|
+
break;
|
|
1215
1121
|
}
|
|
1216
1122
|
handled += 1;
|
|
1123
|
+
queue.completeClaim(claim);
|
|
1217
1124
|
messageExchanges.push({
|
|
1218
1125
|
from: message.publisher,
|
|
1219
1126
|
task: message.task,
|
|
1220
1127
|
reply,
|
|
1221
1128
|
});
|
|
1222
1129
|
}
|
|
1223
|
-
} finally {
|
|
1224
|
-
// If we drained the pending file but had failures, requeue only failed lines.
|
|
1225
|
-
if (failedRawLines.length > 0) {
|
|
1226
|
-
requeueJsonlLines(pendingFile, failedRawLines);
|
|
1227
|
-
}
|
|
1228
|
-
cleanupProcessingFile(drainedRes.processingFile);
|
|
1229
1130
|
}
|
|
1230
1131
|
|
|
1231
1132
|
// Fallback: if there is no pending file, fall back to CLI `bus check` parsing.
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const {
|
|
4
|
+
ensureDir,
|
|
5
|
+
appendJSONL,
|
|
6
|
+
generateInstanceId,
|
|
7
|
+
readJSONL,
|
|
8
|
+
writeFileAtomic,
|
|
9
|
+
subscriberToSafeName,
|
|
10
|
+
isPidAlive,
|
|
11
|
+
} = require("./utils");
|
|
12
|
+
|
|
13
|
+
const PROCESSING_STALE_MS = 30000;
|
|
14
|
+
|
|
15
|
+
function positiveSeq(event) {
|
|
16
|
+
const seq = Number(event && event.seq);
|
|
17
|
+
return Number.isFinite(seq) && seq > 0 ? seq : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function eventToJsonl(events = []) {
|
|
21
|
+
const lines = events
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((event) => JSON.stringify(event));
|
|
24
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJsonlLoose(filePath) {
|
|
28
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
29
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
30
|
+
if (!content.trim()) return [];
|
|
31
|
+
return content.split(/\r?\n/)
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((line) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(line);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const QUEUE_TYPES = {
|
|
44
|
+
AGENT_MESSAGE: "agent_message",
|
|
45
|
+
DAEMON_CONTROL: "daemon_control",
|
|
46
|
+
REPORT: "report",
|
|
47
|
+
WAKE: "wake",
|
|
48
|
+
DELIVERY_STATUS: "delivery_status",
|
|
49
|
+
EVENT: "event",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function inferQueueType(event = {}) {
|
|
53
|
+
if (event.queue_type) return String(event.queue_type);
|
|
54
|
+
if (event.event === "message") return QUEUE_TYPES.AGENT_MESSAGE;
|
|
55
|
+
if (event.event === "wake") return QUEUE_TYPES.WAKE;
|
|
56
|
+
if (event.event === "delivery") return QUEUE_TYPES.DELIVERY_STATUS;
|
|
57
|
+
if (event.type === "report/control" || event.event === "controller_report") return QUEUE_TYPES.REPORT;
|
|
58
|
+
if (String(event.target || "") === "ufoo-agent") return QUEUE_TYPES.DAEMON_CONTROL;
|
|
59
|
+
return QUEUE_TYPES.EVENT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultDeliveryForType(queueType) {
|
|
63
|
+
if (queueType === QUEUE_TYPES.AGENT_MESSAGE) {
|
|
64
|
+
return { mode: "inject", gate: "idle", max_inflight: 1 };
|
|
65
|
+
}
|
|
66
|
+
if (queueType === QUEUE_TYPES.WAKE) {
|
|
67
|
+
return { mode: "notify_only", gate: "none", max_inflight: 1 };
|
|
68
|
+
}
|
|
69
|
+
if (queueType === QUEUE_TYPES.DAEMON_CONTROL || queueType === QUEUE_TYPES.REPORT || queueType === QUEUE_TYPES.DELIVERY_STATUS) {
|
|
70
|
+
return { mode: "daemon_consume", gate: "none", max_inflight: 1 };
|
|
71
|
+
}
|
|
72
|
+
return { mode: "self_consume", gate: "none", max_inflight: 1 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function defaultAckForType(queueType) {
|
|
76
|
+
if (queueType === QUEUE_TYPES.AGENT_MESSAGE) return { policy: "on_delivery" };
|
|
77
|
+
if (queueType === QUEUE_TYPES.WAKE) return { policy: "fire_and_forget" };
|
|
78
|
+
return { policy: "on_consume" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeQueueEnvelope(event = {}, overrides = {}) {
|
|
82
|
+
const queueType = overrides.queueType || overrides.queue_type || inferQueueType(event);
|
|
83
|
+
const existingDelivery = event.delivery && typeof event.delivery === "object" ? event.delivery : {};
|
|
84
|
+
const overrideDelivery = overrides.delivery && typeof overrides.delivery === "object" ? overrides.delivery : {};
|
|
85
|
+
const existingAck = event.ack && typeof event.ack === "object" ? event.ack : {};
|
|
86
|
+
const overrideAck = overrides.ack && typeof overrides.ack === "object" ? overrides.ack : {};
|
|
87
|
+
return {
|
|
88
|
+
...event,
|
|
89
|
+
queue_type: queueType,
|
|
90
|
+
delivery: {
|
|
91
|
+
...defaultDeliveryForType(queueType),
|
|
92
|
+
...existingDelivery,
|
|
93
|
+
...overrideDelivery,
|
|
94
|
+
},
|
|
95
|
+
ack: {
|
|
96
|
+
...defaultAckForType(queueType),
|
|
97
|
+
...existingAck,
|
|
98
|
+
...overrideAck,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stripQueueEnvelope(event = {}) {
|
|
104
|
+
if (!event || typeof event !== "object") return event;
|
|
105
|
+
const stripped = { ...event };
|
|
106
|
+
delete stripped.queue_type;
|
|
107
|
+
delete stripped.delivery;
|
|
108
|
+
delete stripped.ack;
|
|
109
|
+
return stripped;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class DeliveryQueue {
|
|
113
|
+
constructor(pendingFile) {
|
|
114
|
+
this.pendingFile = pendingFile;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static forSubscriber(busDir, subscriber) {
|
|
118
|
+
return new DeliveryQueue(path.join(
|
|
119
|
+
busDir,
|
|
120
|
+
"queues",
|
|
121
|
+
subscriberToSafeName(subscriber),
|
|
122
|
+
"pending.jsonl"
|
|
123
|
+
));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
queueDir() {
|
|
127
|
+
return path.dirname(this.pendingFile);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ensureQueueDir() {
|
|
131
|
+
ensureDir(this.queueDir());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
processingPatternPrefix() {
|
|
135
|
+
return `${path.basename(this.pendingFile)}.processing.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
processingFiles() {
|
|
139
|
+
const dir = this.queueDir();
|
|
140
|
+
if (!fs.existsSync(dir)) return [];
|
|
141
|
+
const prefix = this.processingPatternPrefix();
|
|
142
|
+
return fs.readdirSync(dir)
|
|
143
|
+
.filter((name) => name.startsWith(prefix))
|
|
144
|
+
.sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
|
|
145
|
+
.map((name) => path.join(dir, name));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
processingFileInfo(filePath) {
|
|
149
|
+
const name = path.basename(filePath || "");
|
|
150
|
+
const escapedBase = path.basename(this.pendingFile).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
151
|
+
const match = name.match(new RegExp(`^${escapedBase}\\.processing\\.(\\d+)(?:\\.(\\d+))?`));
|
|
152
|
+
const pid = match ? parseInt(match[1], 10) : NaN;
|
|
153
|
+
const timestamp = match && match[2] ? parseInt(match[2], 10) : NaN;
|
|
154
|
+
return {
|
|
155
|
+
pid: Number.isFinite(pid) && pid > 0 ? pid : 0,
|
|
156
|
+
timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
isRecoverableProcessingFile(filePath, options = {}) {
|
|
161
|
+
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : PROCESSING_STALE_MS;
|
|
162
|
+
const info = this.processingFileInfo(filePath);
|
|
163
|
+
if (info.pid > 0) return !isPidAlive(info.pid);
|
|
164
|
+
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) return false;
|
|
165
|
+
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
if (info.timestamp > 0 && now - info.timestamp >= maxAgeMs) return true;
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const stat = fs.statSync(filePath);
|
|
171
|
+
return Boolean(stat && stat.isFile() && now - stat.mtimeMs >= maxAgeMs);
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
readPending() {
|
|
178
|
+
this.recover();
|
|
179
|
+
return readJSONL(this.pendingFile);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
readPendingRaw() {
|
|
183
|
+
return readJSONL(this.pendingFile);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
append(event) {
|
|
187
|
+
if (!event) return;
|
|
188
|
+
this.ensureQueueDir();
|
|
189
|
+
appendJSONL(this.pendingFile, normalizeQueueEnvelope(event));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
writePending(events = []) {
|
|
193
|
+
this.ensureQueueDir();
|
|
194
|
+
const items = Array.isArray(events) ? events.filter(Boolean) : [];
|
|
195
|
+
if (items.length === 0) {
|
|
196
|
+
try {
|
|
197
|
+
fs.rmSync(this.pendingFile, { force: true });
|
|
198
|
+
} catch {
|
|
199
|
+
// ignore cleanup errors
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
writeFileAtomic(this.pendingFile, eventToJsonl(items));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
mergeAndSort(events = []) {
|
|
207
|
+
const sequenced = new Map();
|
|
208
|
+
const unsequenced = [];
|
|
209
|
+
for (const event of events) {
|
|
210
|
+
if (!event) continue;
|
|
211
|
+
const seq = positiveSeq(event);
|
|
212
|
+
if (seq > 0) {
|
|
213
|
+
if (!sequenced.has(seq)) sequenced.set(seq, event);
|
|
214
|
+
} else {
|
|
215
|
+
unsequenced.push(event);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return [
|
|
219
|
+
...Array.from(sequenced.entries())
|
|
220
|
+
.sort(([a], [b]) => a - b)
|
|
221
|
+
.map(([, event]) => event),
|
|
222
|
+
...unsequenced,
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
recover(options = {}) {
|
|
227
|
+
const files = this.processingFiles()
|
|
228
|
+
.filter((file) => this.isRecoverableProcessingFile(file, options));
|
|
229
|
+
if (files.length === 0) return { recovered: 0, files: [] };
|
|
230
|
+
|
|
231
|
+
const all = [...this.readPendingRaw()];
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
all.push(...readJsonlLoose(file));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const merged = this.mergeAndSort(all);
|
|
237
|
+
this.writePending(merged);
|
|
238
|
+
|
|
239
|
+
for (const file of files) {
|
|
240
|
+
try {
|
|
241
|
+
fs.rmSync(file, { force: true });
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore cleanup errors
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { recovered: merged.length, files };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
claimNext() {
|
|
251
|
+
this.recover();
|
|
252
|
+
const pending = this.readPendingRaw();
|
|
253
|
+
if (pending.length === 0) return null;
|
|
254
|
+
|
|
255
|
+
const event = pending[0];
|
|
256
|
+
const remaining = pending.slice(1);
|
|
257
|
+
const processingFile = `${this.pendingFile}.processing.${process.pid}.${Date.now()}.${generateInstanceId()}`;
|
|
258
|
+
this.ensureQueueDir();
|
|
259
|
+
writeFileAtomic(processingFile, eventToJsonl([event]));
|
|
260
|
+
this.writePending(remaining);
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
event,
|
|
264
|
+
processingFile,
|
|
265
|
+
pendingFile: this.pendingFile,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
completeClaim(claim) {
|
|
270
|
+
const processingFile = claim && claim.processingFile;
|
|
271
|
+
if (!processingFile) return false;
|
|
272
|
+
try {
|
|
273
|
+
if (fs.existsSync(processingFile)) {
|
|
274
|
+
fs.rmSync(processingFile, { force: true });
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
restoreClaim(claim) {
|
|
284
|
+
const event = claim && claim.event;
|
|
285
|
+
const processingFile = claim && claim.processingFile;
|
|
286
|
+
if (!event) return false;
|
|
287
|
+
|
|
288
|
+
const merged = this.mergeAndSort([event, ...this.readPendingRaw()]);
|
|
289
|
+
this.writePending(merged);
|
|
290
|
+
if (processingFile) {
|
|
291
|
+
try {
|
|
292
|
+
fs.rmSync(processingFile, { force: true });
|
|
293
|
+
} catch {
|
|
294
|
+
// ignore cleanup errors
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
DeliveryQueue,
|
|
303
|
+
QUEUE_TYPES,
|
|
304
|
+
normalizeQueueEnvelope,
|
|
305
|
+
stripQueueEnvelope,
|
|
306
|
+
inferQueueType,
|
|
307
|
+
positiveSeq,
|
|
308
|
+
};
|