@ouro.bot/cli 0.1.0-alpha.855 → 0.1.0-alpha.857
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/changelog.json +13 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/daemon.js +7 -7
- package/dist/heart/external-events/router.js +73 -0
- package/dist/heart/mailbox/mailbox-http-transport.js +172 -29
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.857",
|
|
6
|
+
"changes": [
|
|
7
|
+
"The daemon now caches external-event records by file stat so the one-second reconcile tick no longer re-reads and re-parses every unchanged record, cutting idle CPU.",
|
|
8
|
+
"The record cache never keeps a filesystem read error and re-reads any entry older than a minute, so a transient read failure or a missed change cannot hide a record."
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"version": "0.1.0-alpha.856",
|
|
13
|
+
"changes": [
|
|
14
|
+
"The daemon no longer freezes for minutes after an upgrade. Mailbox watched the whole agent bundle with Node's JavaScript recursive watcher on Linux, which blocked the event loop for about 7 s at every boot and for about 150 s when a host-side chown touched every bundle file, long enough to fail the container healthcheck and get the Butler restarted. Mailbox now watches directories without blocking: async discovery, event callbacks that never touch the filesystem, a catch-up report for new directories, a 3 s cap on event debouncing, and a warning event when watches run out."
|
|
15
|
+
]
|
|
16
|
+
},
|
|
4
17
|
{
|
|
5
18
|
"version": "0.1.0-alpha.855",
|
|
6
19
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.857</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -637,6 +637,7 @@ class OuroDaemon {
|
|
|
637
637
|
externalEventRoot;
|
|
638
638
|
privilegedEventSpoolRoot;
|
|
639
639
|
privilegedEventScanner;
|
|
640
|
+
externalEventScanCache = new router_1.ExternalEventScanCache();
|
|
640
641
|
rsvpHabitRunner;
|
|
641
642
|
nativeHabitRunner;
|
|
642
643
|
nativeHabitMatch;
|
|
@@ -1701,12 +1702,11 @@ class OuroDaemon {
|
|
|
1701
1702
|
if (fs.existsSync(this.privilegedEventSpoolRoot)) {
|
|
1702
1703
|
this.privilegedEventScanner({ spoolRoot: this.privilegedEventSpoolRoot, eventRoot: this.externalEventRootPath() });
|
|
1703
1704
|
}
|
|
1704
|
-
const
|
|
1705
|
+
const scanned = this.externalEventScanCache.scan(this.externalEventRootPath());
|
|
1705
1706
|
const readinessKeys = new Set();
|
|
1706
|
-
for (const
|
|
1707
|
-
if (
|
|
1707
|
+
for (const { record: candidate } of scanned) {
|
|
1708
|
+
if (!candidate)
|
|
1708
1709
|
continue;
|
|
1709
|
-
const candidate = (0, router_1.readExternalEventRecord)(status.recordPath);
|
|
1710
1710
|
if (candidate.dispatchEnabled === false)
|
|
1711
1711
|
continue;
|
|
1712
1712
|
const retryDue = candidate.executionState === "retry_wait" && candidate.nextAttemptAt !== null && Date.parse(candidate.nextAttemptAt) <= Date.parse(now);
|
|
@@ -1739,10 +1739,10 @@ class OuroDaemon {
|
|
|
1739
1739
|
const providerEvidenceByAgent = new Map();
|
|
1740
1740
|
const runtimeEvidenceByAgent = new Map();
|
|
1741
1741
|
const recoveredByClass = new Map();
|
|
1742
|
-
for (const
|
|
1743
|
-
if (
|
|
1742
|
+
for (const { record: scannedRecord } of scanned) {
|
|
1743
|
+
if (!scannedRecord)
|
|
1744
1744
|
continue;
|
|
1745
|
-
let record =
|
|
1745
|
+
let record = scannedRecord;
|
|
1746
1746
|
if (record.dispatchEnabled === false)
|
|
1747
1747
|
continue;
|
|
1748
1748
|
if (blockedReadinessKeys.has(`${record.agent}\0${record.source}`))
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ExternalEventScanCache = void 0;
|
|
36
37
|
exports.getExternalEventRoot = getExternalEventRoot;
|
|
37
38
|
exports.externalEventRecordPath = externalEventRecordPath;
|
|
38
39
|
exports.readExternalEventRecord = readExternalEventRecord;
|
|
@@ -622,6 +623,78 @@ function listExternalEventStatus(root) {
|
|
|
622
623
|
}
|
|
623
624
|
return rows.sort((left, right) => left.agent.localeCompare(right.agent) || left.source.localeCompare(right.source) || left.eventId.localeCompare(right.eventId));
|
|
624
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Stat-keyed record cache for the daemon's reconcile tick. Records are written by atomic rename, so any write
|
|
628
|
+
* changes inode, mtime or ctime and invalidates the entry. Cached records are only used to decide what is due;
|
|
629
|
+
* every mutation (claim, revive, reconcile) re-reads the file under its own lock and compare-and-swap check, so a
|
|
630
|
+
* stale cached record can at worst cause one rejected attempt that the next tick retries from fresh data.
|
|
631
|
+
* As a backstop against a change the stat key misses, an entry older than `maxAgeMs` is re-read anyway, and a
|
|
632
|
+
* filesystem read error (one carrying an fs `code`, such as EIO or EMFILE) is reported for this scan but never
|
|
633
|
+
* cached, so a transient failure cannot hide a healthy record until its file next changes.
|
|
634
|
+
*/
|
|
635
|
+
class ExternalEventScanCache {
|
|
636
|
+
entries = new Map();
|
|
637
|
+
maxAgeMs;
|
|
638
|
+
now;
|
|
639
|
+
constructor(options = {}) {
|
|
640
|
+
this.maxAgeMs = options.maxAgeMs ?? 60_000;
|
|
641
|
+
this.now = options.now ?? Date.now;
|
|
642
|
+
}
|
|
643
|
+
scan(root) {
|
|
644
|
+
const seen = new Set();
|
|
645
|
+
const rows = [];
|
|
646
|
+
const nowMs = this.now();
|
|
647
|
+
if (fs.existsSync(root)) {
|
|
648
|
+
for (const agent of fs.readdirSync(root, { withFileTypes: true })) {
|
|
649
|
+
if (!agent.isDirectory() || agent.name.startsWith("."))
|
|
650
|
+
continue;
|
|
651
|
+
const agentPath = path.join(root, agent.name);
|
|
652
|
+
for (const source of fs.readdirSync(agentPath, { withFileTypes: true })) {
|
|
653
|
+
if (!source.isDirectory() || source.name.startsWith("."))
|
|
654
|
+
continue;
|
|
655
|
+
const sourcePath = path.join(agentPath, source.name);
|
|
656
|
+
for (const file of fs.readdirSync(sourcePath, { withFileTypes: true })) {
|
|
657
|
+
if (!file.isFile() || file.name.startsWith(".") || !file.name.endsWith(".json"))
|
|
658
|
+
continue;
|
|
659
|
+
const recordPath = path.join(sourcePath, file.name);
|
|
660
|
+
let stat;
|
|
661
|
+
try {
|
|
662
|
+
stat = fs.statSync(recordPath);
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
seen.add(recordPath);
|
|
668
|
+
const cached = this.entries.get(recordPath);
|
|
669
|
+
if (cached && cached.ino === stat.ino && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs && cached.ctimeMs === stat.ctimeMs && nowMs - cached.cachedAtMs < this.maxAgeMs) {
|
|
670
|
+
rows.push(cached.entry);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
let entry;
|
|
674
|
+
let cacheable = true;
|
|
675
|
+
try {
|
|
676
|
+
entry = { recordPath, record: readExternalEventRecord(recordPath), error: null };
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
entry = { recordPath, record: null, error: error instanceof Error ? error.message : /* v8 ignore next -- filesystem and JSON parsers throw Error objects @preserve */ String(error) };
|
|
680
|
+
cacheable = !(error && typeof error === "object" && typeof error.code === "string");
|
|
681
|
+
}
|
|
682
|
+
if (cacheable)
|
|
683
|
+
this.entries.set(recordPath, { ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, cachedAtMs: nowMs, entry });
|
|
684
|
+
else
|
|
685
|
+
this.entries.delete(recordPath);
|
|
686
|
+
rows.push(entry);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
for (const key of this.entries.keys())
|
|
692
|
+
if (!seen.has(key))
|
|
693
|
+
this.entries.delete(key);
|
|
694
|
+
return rows;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
exports.ExternalEventScanCache = ExternalEventScanCache;
|
|
625
698
|
function readExisting(recordPath) {
|
|
626
699
|
if (!fs.existsSync(recordPath))
|
|
627
700
|
return { record: null, corruptDuplicateCount: 0 };
|
|
@@ -37,9 +37,13 @@ exports.createSseBroadcaster = createSseBroadcaster;
|
|
|
37
37
|
exports.createStateChangedBroadcast = createStateChangedBroadcast;
|
|
38
38
|
exports.createBundleWatcher = createBundleWatcher;
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
40
42
|
const DEFAULT_BUNDLE_WATCHER_DEPS = {
|
|
43
|
+
platform: process.platform,
|
|
41
44
|
existsSync: fs.existsSync,
|
|
42
|
-
watch: fs.watch,
|
|
45
|
+
watch: (targetPath, options, listener) => fs.watch(targetPath, options, listener),
|
|
46
|
+
readdir: (targetPath) => fs.promises.readdir(targetPath, { withFileTypes: true }),
|
|
43
47
|
setTimeout,
|
|
44
48
|
clearTimeout,
|
|
45
49
|
};
|
|
@@ -81,49 +85,188 @@ function createStateChangedBroadcast(sse) {
|
|
|
81
85
|
sse.broadcast("state-changed", { at: new Date().toISOString() });
|
|
82
86
|
};
|
|
83
87
|
}
|
|
88
|
+
const BUNDLE_WATCH_DEBOUNCE_MS = 500;
|
|
89
|
+
// A continuous event stream must not postpone onChange forever.
|
|
90
|
+
const BUNDLE_WATCH_MAX_WAIT_MS = 3_000;
|
|
91
|
+
const BUNDLE_WATCH_DEGRADED_LOG_INTERVAL_MS = 60_000;
|
|
92
|
+
const BUNDLE_WATCH_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
|
|
93
|
+
function closeQuietly(handle) {
|
|
94
|
+
try {
|
|
95
|
+
handle.close();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Already closed.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Report "something under the bundles root changed" to Mailbox, debounced.
|
|
103
|
+
*
|
|
104
|
+
* On Linux, Node implements `fs.watch(root, { recursive: true })` in JavaScript: it stats and
|
|
105
|
+
* inotify-watches every file in the tree before returning (7-10 s of blocked event loop for a
|
|
106
|
+
* 12k-file bundle), and it answers every file event on a directory by re-reading that whole
|
|
107
|
+
* directory. A host-side `chown -R` of a bundle holding directories of ~3.5k receipts therefore
|
|
108
|
+
* keeps the daemon's event loop busy for ~150 s, long enough to fail the container healthcheck.
|
|
109
|
+
*
|
|
110
|
+
* So on Linux we watch each directory (not each file) non-recursively. The tree is discovered
|
|
111
|
+
* with async readdir, and event callbacks never touch the filesystem: they only schedule a
|
|
112
|
+
* debounced onChange, plus, for create/delete/rename events, a debounced async re-read of that
|
|
113
|
+
* one directory to pick up added or removed subdirectories. macOS and Windows have a native
|
|
114
|
+
* recursive watcher that is cheap, so they keep it. Symlinked directories are not followed, and
|
|
115
|
+
* .git and node_modules are not watched.
|
|
116
|
+
*/
|
|
84
117
|
function createBundleWatcher(bundlesRoot, onChange, deps = DEFAULT_BUNDLE_WATCHER_DEPS) {
|
|
85
|
-
const watchers =
|
|
118
|
+
const watchers = new Map();
|
|
119
|
+
const pendingDirectories = new Set();
|
|
86
120
|
let debounceTimer = null;
|
|
87
|
-
|
|
121
|
+
let rescanTimer = null;
|
|
122
|
+
let maxWaitTimer = null;
|
|
123
|
+
let rescanRunning = false;
|
|
124
|
+
let stopped = false;
|
|
125
|
+
let resourceExhausted = false;
|
|
126
|
+
let lastDegradedLogAt = Number.NEGATIVE_INFINITY;
|
|
127
|
+
function flushOnChange() {
|
|
128
|
+
if (debounceTimer)
|
|
129
|
+
deps.clearTimeout(debounceTimer);
|
|
130
|
+
if (maxWaitTimer)
|
|
131
|
+
deps.clearTimeout(maxWaitTimer);
|
|
132
|
+
debounceTimer = null;
|
|
133
|
+
maxWaitTimer = null;
|
|
134
|
+
onChange();
|
|
135
|
+
}
|
|
88
136
|
function debouncedOnChange() {
|
|
89
137
|
if (debounceTimer)
|
|
90
138
|
deps.clearTimeout(debounceTimer);
|
|
91
|
-
debounceTimer = deps.setTimeout(
|
|
139
|
+
debounceTimer = deps.setTimeout(flushOnChange, BUNDLE_WATCH_DEBOUNCE_MS);
|
|
140
|
+
if (!maxWaitTimer)
|
|
141
|
+
maxWaitTimer = deps.setTimeout(flushOnChange, BUNDLE_WATCH_MAX_WAIT_MS);
|
|
92
142
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
143
|
+
function reportWatchDegraded(targetPath, error) {
|
|
144
|
+
const code = error?.code;
|
|
145
|
+
if (code === "ENOSPC" || code === "EMFILE")
|
|
146
|
+
resourceExhausted = true;
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - lastDegradedLogAt < BUNDLE_WATCH_DEGRADED_LOG_INTERVAL_MS)
|
|
149
|
+
return;
|
|
150
|
+
lastDegradedLogAt = now;
|
|
151
|
+
const reason = typeof code === "string" ? code : error instanceof Error ? error.message : String(error);
|
|
152
|
+
(0, runtime_1.emitNervesEvent)({
|
|
153
|
+
level: "warn",
|
|
154
|
+
component: "daemon",
|
|
155
|
+
event: "daemon.mailbox_watch_degraded",
|
|
156
|
+
message: `mailbox watch degraded: ${reason} at ${targetPath}`,
|
|
157
|
+
meta: { path: targetPath, reason },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function watchPath(targetPath, recursive, listener) {
|
|
161
|
+
try {
|
|
162
|
+
const handle = deps.watch(targetPath, { recursive }, listener);
|
|
163
|
+
handle.on?.("error", (error) => {
|
|
164
|
+
closeQuietly(handle);
|
|
165
|
+
if (watchers.get(targetPath) === handle)
|
|
166
|
+
watchers.delete(targetPath);
|
|
167
|
+
if (stopped)
|
|
168
|
+
return;
|
|
169
|
+
reportWatchDegraded(targetPath, error);
|
|
106
170
|
debouncedOnChange();
|
|
107
171
|
});
|
|
108
|
-
watchers.
|
|
172
|
+
watchers.set(targetPath, handle);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Watching is best-effort; manual broadcasts still keep Mailbox usable.
|
|
177
|
+
reportWatchDegraded(targetPath, error);
|
|
178
|
+
return false;
|
|
109
179
|
}
|
|
110
180
|
}
|
|
111
|
-
|
|
112
|
-
|
|
181
|
+
function unwatchTree(directory) {
|
|
182
|
+
for (const [watched, handle] of watchers) {
|
|
183
|
+
if (watched === directory || watched.startsWith(`${directory}${path.sep}`)) {
|
|
184
|
+
closeQuietly(handle);
|
|
185
|
+
watchers.delete(watched);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function scheduleRescan(directory) {
|
|
190
|
+
pendingDirectories.add(directory);
|
|
191
|
+
if (rescanTimer)
|
|
192
|
+
return;
|
|
193
|
+
rescanTimer = deps.setTimeout(() => {
|
|
194
|
+
rescanTimer = null;
|
|
195
|
+
void drainRescans();
|
|
196
|
+
}, BUNDLE_WATCH_DEBOUNCE_MS);
|
|
197
|
+
}
|
|
198
|
+
async function drainRescans() {
|
|
199
|
+
if (rescanRunning)
|
|
200
|
+
return;
|
|
201
|
+
rescanRunning = true;
|
|
202
|
+
try {
|
|
203
|
+
while (!stopped && pendingDirectories.size > 0) {
|
|
204
|
+
const [directory] = pendingDirectories;
|
|
205
|
+
pendingDirectories.delete(directory);
|
|
206
|
+
await reconcileDirectory(directory, true);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
rescanRunning = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function watchDirectory(directory) {
|
|
214
|
+
return watchPath(directory, false, (eventType) => {
|
|
215
|
+
debouncedOnChange();
|
|
216
|
+
if (eventType === "rename")
|
|
217
|
+
scheduleRescan(directory);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// catchUp is true for rescans after boot: files written into a directory before its watch existed
|
|
221
|
+
// produced no event, so a newly watched directory reports one change itself.
|
|
222
|
+
async function reconcileDirectory(directory, catchUp) {
|
|
223
|
+
let entries;
|
|
224
|
+
try {
|
|
225
|
+
entries = await deps.readdir(directory);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
unwatchTree(directory);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (stopped)
|
|
232
|
+
return;
|
|
233
|
+
const subdirectories = new Set(entries.filter((entry) => entry.isDirectory() && !BUNDLE_WATCH_SKIPPED_DIRECTORIES.has(entry.name)).map((entry) => path.join(directory, entry.name)));
|
|
234
|
+
for (const watched of [...watchers.keys()]) {
|
|
235
|
+
if (path.dirname(watched) === directory && !subdirectories.has(watched))
|
|
236
|
+
unwatchTree(watched);
|
|
237
|
+
}
|
|
238
|
+
for (const subdirectory of subdirectories) {
|
|
239
|
+
if (stopped || resourceExhausted)
|
|
240
|
+
return;
|
|
241
|
+
if (watchers.has(subdirectory) || !watchDirectory(subdirectory))
|
|
242
|
+
continue;
|
|
243
|
+
if (catchUp)
|
|
244
|
+
debouncedOnChange();
|
|
245
|
+
await reconcileDirectory(subdirectory, catchUp);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (deps.existsSync(bundlesRoot)) {
|
|
249
|
+
if (deps.platform === "linux") {
|
|
250
|
+
if (watchDirectory(bundlesRoot))
|
|
251
|
+
void reconcileDirectory(bundlesRoot, false);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
watchPath(bundlesRoot, true, debouncedOnChange);
|
|
255
|
+
}
|
|
113
256
|
}
|
|
114
257
|
return {
|
|
115
258
|
stop() {
|
|
259
|
+
stopped = true;
|
|
116
260
|
if (debounceTimer)
|
|
117
261
|
deps.clearTimeout(debounceTimer);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
watchers.length = 0;
|
|
262
|
+
if (maxWaitTimer)
|
|
263
|
+
deps.clearTimeout(maxWaitTimer);
|
|
264
|
+
if (rescanTimer)
|
|
265
|
+
deps.clearTimeout(rescanTimer);
|
|
266
|
+
pendingDirectories.clear();
|
|
267
|
+
for (const handle of watchers.values())
|
|
268
|
+
closeQuietly(handle);
|
|
269
|
+
watchers.clear();
|
|
127
270
|
},
|
|
128
271
|
};
|
|
129
272
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.857",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.857",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|