@phnx-labs/agents-cli 1.20.93 → 1.21.1
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.md +290 -0
- package/README.md +4 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/beta.js +3 -5
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/exec.js +13 -0
- package/dist/commands/feed.js +37 -23
- package/dist/commands/mailboxes.js +39 -1
- package/dist/commands/message.js +12 -1
- package/dist/commands/monitors.js +8 -6
- package/dist/commands/packages.js +14 -5
- package/dist/commands/projects.d.ts +28 -0
- package/dist/commands/projects.js +173 -47
- package/dist/commands/routines.js +27 -1
- package/dist/commands/rules.js +6 -0
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +16 -8
- package/dist/commands/setup-browser.js +5 -1
- package/dist/commands/setup-fleet.js +5 -0
- package/dist/commands/setup-preferences.d.ts +53 -0
- package/dist/commands/setup-preferences.js +142 -0
- package/dist/commands/setup.js +8 -9
- package/dist/commands/ssh.js +220 -10
- package/dist/commands/sync.d.ts +2 -2
- package/dist/commands/sync.js +4 -9
- package/dist/commands/watchdog.js +26 -3
- package/dist/index.js +22 -6
- package/dist/lib/activity.d.ts +13 -0
- package/dist/lib/activity.js +22 -2
- package/dist/lib/auto-dispatch.d.ts +6 -1
- package/dist/lib/auto-dispatch.js +7 -2
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- package/dist/lib/daemon.d.ts +13 -0
- package/dist/lib/daemon.js +80 -60
- package/dist/lib/device-config.d.ts +82 -0
- package/dist/lib/device-config.js +296 -0
- package/dist/lib/event-provenance.d.ts +19 -0
- package/dist/lib/event-provenance.js +48 -0
- package/dist/lib/events.d.ts +2 -2
- package/dist/lib/events.js +4 -53
- package/dist/lib/feed-broadcast.d.ts +41 -5
- package/dist/lib/feed-broadcast.js +110 -12
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +3 -1
- package/dist/lib/git.d.ts +13 -2
- package/dist/lib/git.js +38 -6
- package/dist/lib/hooks.d.ts +16 -1
- package/dist/lib/hooks.js +61 -1
- package/dist/lib/linear-project-counts.d.ts +72 -5
- package/dist/lib/linear-project-counts.js +90 -6
- package/dist/lib/linear-projects.d.ts +12 -0
- package/dist/lib/linear-projects.js +30 -0
- package/dist/lib/mailbox-gc.js +30 -7
- package/dist/lib/mailbox.d.ts +14 -1
- package/dist/lib/mailbox.js +35 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +5 -21
- package/dist/lib/menubar/install-menubar.js +35 -44
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/models.js +21 -11
- package/dist/lib/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- package/dist/lib/overdue.d.ts +1 -1
- package/dist/lib/overdue.js +54 -19
- package/dist/lib/project-import.d.ts +96 -0
- package/dist/lib/project-import.js +171 -0
- package/dist/lib/remote-agents-json.d.ts +14 -1
- package/dist/lib/remote-agents-json.js +21 -3
- package/dist/lib/routines.d.ts +53 -0
- package/dist/lib/routines.js +105 -4
- package/dist/lib/rules/run-sync.d.ts +18 -0
- package/dist/lib/rules/run-sync.js +92 -0
- package/dist/lib/scheduler.js +12 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/index.d.ts +4 -5
- package/dist/lib/secrets/index.js +4 -5
- package/dist/lib/session/active.d.ts +16 -2
- package/dist/lib/session/active.js +8 -4
- package/dist/lib/session/bundle.js +5 -1
- package/dist/lib/session/remote-list.d.ts +1 -15
- package/dist/lib/session/remote-list.js +22 -111
- package/dist/lib/session/sync/config.d.ts +13 -5
- package/dist/lib/session/sync/config.js +21 -13
- package/dist/lib/startup/command-registry.d.ts +0 -2
- package/dist/lib/startup/command-registry.js +1 -5
- package/dist/lib/state.d.ts +7 -2
- package/dist/lib/state.js +85 -10
- package/dist/lib/sync-umbrella.d.ts +11 -23
- package/dist/lib/sync-umbrella.js +14 -45
- package/dist/lib/teams/agents.d.ts +35 -3
- package/dist/lib/teams/agents.js +52 -8
- package/dist/lib/teams/scheduler.d.ts +29 -2
- package/dist/lib/teams/scheduler.js +60 -16
- package/dist/lib/types.d.ts +23 -1
- package/package.json +1 -2
- package/dist/commands/drive.d.ts +0 -10
- package/dist/commands/drive.js +0 -183
- package/dist/commands/hq.d.ts +0 -2
- package/dist/commands/hq.js +0 -58
- package/dist/commands/sessions-sync.d.ts +0 -17
- package/dist/commands/sessions-sync.js +0 -135
- package/dist/commands/sync-provision.d.ts +0 -23
- package/dist/commands/sync-provision.js +0 -107
- package/dist/lib/drive-sync.d.ts +0 -45
- package/dist/lib/drive-sync.js +0 -238
- package/dist/lib/hq/floor.d.ts +0 -87
- package/dist/lib/hq/floor.js +0 -243
- package/dist/lib/session/sync/crdt.d.ts +0 -44
- package/dist/lib/session/sync/crdt.js +0 -119
- package/dist/lib/session/sync/manifest.d.ts +0 -62
- package/dist/lib/session/sync/manifest.js +0 -100
- package/dist/lib/session/sync/provision.d.ts +0 -49
- package/dist/lib/session/sync/provision.js +0 -91
- package/dist/lib/session/sync/r2.d.ts +0 -32
- package/dist/lib/session/sync/r2.js +0 -121
- package/dist/lib/session/sync/sync.d.ts +0 -106
- package/dist/lib/session/sync/sync.js +0 -374
package/dist/lib/daemon.js
CHANGED
|
@@ -24,6 +24,7 @@ import { BrowserService } from './browser/service.js';
|
|
|
24
24
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
25
25
|
import { redactSecrets } from './redact.js';
|
|
26
26
|
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
|
|
27
|
+
import { isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
|
|
27
28
|
const PID_FILE = 'daemon.pid';
|
|
28
29
|
const LOCK_FILE = 'daemon.lock';
|
|
29
30
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -53,6 +54,11 @@ const WEDGE_THRESHOLD_TICKS = 3;
|
|
|
53
54
|
export function shouldTakeOverBroker(isHosting, brokerReachable) {
|
|
54
55
|
return !isHosting && !brokerReachable;
|
|
55
56
|
}
|
|
57
|
+
export function schedulerGateTransition(running, enabled) {
|
|
58
|
+
if (running)
|
|
59
|
+
return enabled ? 'reload' : 'stop';
|
|
60
|
+
return enabled ? 'boot' : 'none';
|
|
61
|
+
}
|
|
56
62
|
function getDaemonDir() {
|
|
57
63
|
const dir = getDaemonDirRoot();
|
|
58
64
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -398,7 +404,7 @@ export async function runDaemon() {
|
|
|
398
404
|
log('ERROR', `Stray daemon reaper failed: ${err.message}`);
|
|
399
405
|
}
|
|
400
406
|
// #416: host the secrets broker socket-first — before the scheduler and the
|
|
401
|
-
// heavy browser
|
|
407
|
+
// heavy browser services — so `agents secrets` resolves within
|
|
402
408
|
// ms of daemon start. Only host when no broker is already reachable, so we
|
|
403
409
|
// never orphan a live standalone broker's clients (that broker stays the
|
|
404
410
|
// server until it idle-exits or the daemon restarts). Best-effort: a failure
|
|
@@ -419,7 +425,23 @@ export async function runDaemon() {
|
|
|
419
425
|
catch (err) {
|
|
420
426
|
log('WARN', `Secrets broker host skipped: ${err.message}`);
|
|
421
427
|
}
|
|
422
|
-
|
|
428
|
+
// scheduler.enabled=false in this machine's device doc means NO routines fire
|
|
429
|
+
// here — the scheduler and its catchup recovery simply never start, while the
|
|
430
|
+
// daemon keeps its other duties (secrets broker, browser IPC, session sync).
|
|
431
|
+
// The refusal message is the same one the start surfaces
|
|
432
|
+
// (`routines add` auto-start, manual `routines start`) raise. The gate is
|
|
433
|
+
// re-evaluated on every SIGHUP reload (handleReload below) via
|
|
434
|
+
// schedulerGateTransition, so flipping the key never needs a daemon restart.
|
|
435
|
+
const schedulerEnabledAtBoot = isSchedulerEnabled();
|
|
436
|
+
if (!schedulerEnabledAtBoot) {
|
|
437
|
+
try {
|
|
438
|
+
assertSchedulerEnabled();
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
log('WARN', err.message);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const triggerJob = async (config) => {
|
|
423
445
|
const jobLabel = config.command
|
|
424
446
|
? 'command'
|
|
425
447
|
: config.workflow
|
|
@@ -458,7 +480,36 @@ export async function runDaemon() {
|
|
|
458
480
|
}
|
|
459
481
|
catch { /* best-effort */ }
|
|
460
482
|
}
|
|
461
|
-
}
|
|
483
|
+
};
|
|
484
|
+
let scheduler = null;
|
|
485
|
+
let catchupInterval;
|
|
486
|
+
// Catchup overlap guard. Declared up here (not beside catchupPass) because
|
|
487
|
+
// bootScheduler() runs before catchupPass's textual position — a `let` down
|
|
488
|
+
// there would still be in its TDZ at the first call and crash the daemon.
|
|
489
|
+
let catchingUp = false;
|
|
490
|
+
// Boot the scheduler + catchup recovery. Called at daemon start when the gate
|
|
491
|
+
// allows, and again from handleReload when the gate flips on (function
|
|
492
|
+
// declarations hoist — catchupPass below is in scope).
|
|
493
|
+
function bootScheduler() {
|
|
494
|
+
scheduler = new JobScheduler(triggerJob);
|
|
495
|
+
scheduler.loadAll();
|
|
496
|
+
const scheduled = scheduler.listScheduled();
|
|
497
|
+
log('INFO', `Loaded ${scheduled.length} jobs`);
|
|
498
|
+
for (const job of scheduled) {
|
|
499
|
+
log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
|
|
500
|
+
}
|
|
501
|
+
void catchupPass();
|
|
502
|
+
catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
|
|
503
|
+
}
|
|
504
|
+
// Stop the scheduler + catchup recovery (gate flipped off on reload).
|
|
505
|
+
function stopScheduler() {
|
|
506
|
+
scheduler?.stopAll();
|
|
507
|
+
scheduler = null;
|
|
508
|
+
if (catchupInterval !== undefined) {
|
|
509
|
+
clearInterval(catchupInterval);
|
|
510
|
+
catchupInterval = undefined;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
462
513
|
// Materialise opted-in project routines into the user layer on every start
|
|
463
514
|
// so a fresh daemon picks up project YAML without a separate sync step.
|
|
464
515
|
try {
|
|
@@ -470,12 +521,8 @@ export async function runDaemon() {
|
|
|
470
521
|
catch (err) {
|
|
471
522
|
log('WARN', `Project routines sync failed: ${err.message}`);
|
|
472
523
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
log('INFO', `Loaded ${scheduled.length} jobs`);
|
|
476
|
-
for (const job of scheduled) {
|
|
477
|
-
log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
|
|
478
|
-
}
|
|
524
|
+
if (schedulerEnabledAtBoot)
|
|
525
|
+
bootScheduler();
|
|
479
526
|
// Monitor engine: event-triggered watchers, beside the cron scheduler. Same
|
|
480
527
|
// daemon, same dispatch seam — a monitor is a routine whose trigger is a
|
|
481
528
|
// watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
|
|
@@ -495,15 +542,16 @@ export async function runDaemon() {
|
|
|
495
542
|
// `catchup: false`, RUN late. Runs on a timer as well as at startup: a startup
|
|
496
543
|
// pass alone misses a fire lost while the daemon stayed up but its event loop
|
|
497
544
|
// was wedged, or one lost across an OS suspend that the process survived.
|
|
498
|
-
// Overlap guard, same shape as
|
|
545
|
+
// Overlap guard, same shape as runHealCheck below. A pass
|
|
499
546
|
// awaits executeJobDetached per job and an off-box (host/cloud) dispatch can
|
|
500
547
|
// block for a while, so a slow pass could still be working when the next tick
|
|
501
548
|
// fires. Both passes would then see a job the first has not yet reached as
|
|
502
549
|
// overdue — the miss is recorded before the await, but only for jobs already
|
|
503
550
|
// processed — and spawn it twice. The idempotency of the `missed` record
|
|
504
551
|
// guards across passes, not within one that is mid-flight.
|
|
505
|
-
|
|
506
|
-
|
|
552
|
+
// Function declaration (hoisted) so bootScheduler() can schedule it. Its
|
|
553
|
+
// guard (`catchingUp`) is declared beside `scheduler` above for TDZ safety.
|
|
554
|
+
async function catchupPass() {
|
|
507
555
|
if (catchingUp)
|
|
508
556
|
return;
|
|
509
557
|
catchingUp = true;
|
|
@@ -553,9 +601,7 @@ export async function runDaemon() {
|
|
|
553
601
|
// throw must not leave the guard latched shut for the daemon's lifetime.
|
|
554
602
|
catchingUp = false;
|
|
555
603
|
}
|
|
556
|
-
}
|
|
557
|
-
await catchupPass();
|
|
558
|
-
const catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
|
|
604
|
+
}
|
|
559
605
|
// Before the BrowserService comes up, reap browser + tunnel processes
|
|
560
606
|
// spawned by previous daemons that are no longer alive. Without this,
|
|
561
607
|
// a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
|
|
@@ -588,42 +634,6 @@ export async function runDaemon() {
|
|
|
588
634
|
writeHeartbeat();
|
|
589
635
|
monitorRunningJobs();
|
|
590
636
|
}, MONITOR_TICK_MS);
|
|
591
|
-
// Cross-machine session sync: push this machine's transcripts to R2 and pull
|
|
592
|
-
// every other machine's, ~every 90s. Skipped silently when the r2.backups
|
|
593
|
-
// bundle is absent. An overlap guard prevents a slow cycle from stacking.
|
|
594
|
-
let syncing = false;
|
|
595
|
-
const runSessionSync = async () => {
|
|
596
|
-
if (syncing)
|
|
597
|
-
return;
|
|
598
|
-
syncing = true;
|
|
599
|
-
try {
|
|
600
|
-
const { isBetaEnabled } = await import('./beta.js');
|
|
601
|
-
// Off by default: session sync is an opt-in beta feature. Check the beta
|
|
602
|
-
// flag FIRST so a machine that hasn't opted in skips the keychain read
|
|
603
|
-
// (isSyncConfigured) entirely, not just the network cycle.
|
|
604
|
-
if (!isBetaEnabled('session-sync'))
|
|
605
|
-
return;
|
|
606
|
-
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
607
|
-
if (!isSyncConfigured())
|
|
608
|
-
return;
|
|
609
|
-
const { syncSessions } = await import('./session/sync/sync.js');
|
|
610
|
-
const r = await syncSessions();
|
|
611
|
-
if (r.pushed || r.pulled || r.errors.length) {
|
|
612
|
-
log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
|
|
613
|
-
(r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
|
|
614
|
-
}
|
|
615
|
-
if (r.warnings.length)
|
|
616
|
-
log('WARN', `sessions sync: ${r.warnings[0]}`);
|
|
617
|
-
}
|
|
618
|
-
catch (err) {
|
|
619
|
-
log('ERROR', `sessions sync failed: ${err.message}`);
|
|
620
|
-
}
|
|
621
|
-
finally {
|
|
622
|
-
syncing = false;
|
|
623
|
-
}
|
|
624
|
-
};
|
|
625
|
-
const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
|
|
626
|
-
void runSessionSync(); // kick once at startup
|
|
627
637
|
// Resource safety check: heal gaps between what DotAgents repos define and
|
|
628
638
|
// what's actually installed in each agent home — the slow rot that nothing
|
|
629
639
|
// else catches (a non-default version left stale, a Claude-invalid plugin
|
|
@@ -875,27 +885,37 @@ export async function runDaemon() {
|
|
|
875
885
|
catch (err) {
|
|
876
886
|
log('WARN', `Project routines sync failed: ${err.message}`);
|
|
877
887
|
}
|
|
878
|
-
scheduler.
|
|
879
|
-
|
|
880
|
-
|
|
888
|
+
// Re-evaluate the scheduler.enabled gate: flipping the key takes effect on
|
|
889
|
+
// this reload, no daemon restart needed. A `routines add` on a re-enabled
|
|
890
|
+
// box signals exactly this reload, which boots the scheduler — the
|
|
891
|
+
// "Scheduler reloaded" it prints is then truthful, not a dead-end.
|
|
892
|
+
const transition = schedulerGateTransition(scheduler !== null, isSchedulerEnabled());
|
|
893
|
+
if (transition === 'boot') {
|
|
894
|
+
log('INFO', 'scheduler.enabled is now on — booting the scheduler');
|
|
895
|
+
bootScheduler();
|
|
896
|
+
}
|
|
897
|
+
else if (transition === 'stop') {
|
|
898
|
+
log('WARN', 'scheduler.enabled is now off — stopping the scheduler; no routines will fire on this device');
|
|
899
|
+
stopScheduler();
|
|
900
|
+
}
|
|
901
|
+
else if (transition === 'reload') {
|
|
902
|
+
scheduler.reloadAll();
|
|
903
|
+
const reloaded = scheduler.listScheduled();
|
|
904
|
+
log('INFO', `Reloaded ${reloaded.length} jobs`);
|
|
905
|
+
}
|
|
881
906
|
try {
|
|
882
907
|
monitorEngine.reload();
|
|
883
908
|
}
|
|
884
909
|
catch (err) {
|
|
885
910
|
log('ERROR', `Monitor engine reload failed: ${err.message}`);
|
|
886
911
|
}
|
|
887
|
-
// Drop the memoized R2 config so rotated/added sync credentials are re-read
|
|
888
|
-
// on the next cycle instead of waiting for a restart.
|
|
889
|
-
void import('./session/sync/config.js').then(m => m.clearR2ConfigCache());
|
|
890
912
|
};
|
|
891
913
|
const handleShutdown = async () => {
|
|
892
914
|
log('INFO', 'Daemon shutting down');
|
|
893
|
-
|
|
915
|
+
stopScheduler();
|
|
894
916
|
monitorEngine.stop();
|
|
895
917
|
await browserIPC.stop();
|
|
896
918
|
clearInterval(monitorInterval);
|
|
897
|
-
clearInterval(catchupInterval);
|
|
898
|
-
clearInterval(syncInterval);
|
|
899
919
|
clearInterval(healInterval);
|
|
900
920
|
clearTimeout(healKickoff);
|
|
901
921
|
clearInterval(autoDispatchInterval);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device/user config keys — typed read/write over the two-tier agents.yaml store.
|
|
3
|
+
*
|
|
4
|
+
* One registry (`CONFIG_KEYS`) maps each CLI dotted name to where it lives:
|
|
5
|
+
* - user scope → central `~/.agents/agents.yaml` under `config:` (syncs
|
|
6
|
+
* fleet-wide via `agents repo push/pull`)
|
|
7
|
+
* - device scope → `~/.agents/devices/<host>/agents.yaml` under `config:`
|
|
8
|
+
* (per-machine; mirrors how `defaultBrowserProfile` is routed)
|
|
9
|
+
*
|
|
10
|
+
* This machine's keys go through the readMeta/updateMeta funnel (state.ts) so the
|
|
11
|
+
* partition/overlay logic stays the single writer. Another device's doc is
|
|
12
|
+
* read/written in place — the devices/ tree syncs via the DotAgents repo, so
|
|
13
|
+
* editing `devices/mac-mini/agents.yaml` locally is how `configure`/`note`
|
|
14
|
+
* target a peer (`--device`-style).
|
|
15
|
+
*
|
|
16
|
+
* `browser.profile` is NOT a `config:` key — it is the existing
|
|
17
|
+
* `Meta.defaultBrowserProfile` field; the registry entry documents the mapping
|
|
18
|
+
* and set/get route to it so there is one source of truth (no duplicate key).
|
|
19
|
+
*
|
|
20
|
+
* Unset always means today's behavior.
|
|
21
|
+
*/
|
|
22
|
+
/** Which tier of the agents.yaml store a key lives in. */
|
|
23
|
+
export type ConfigScope = 'user' | 'device';
|
|
24
|
+
/** Value type of a config key — drives validation and `--json` rendering. */
|
|
25
|
+
export type ConfigType = 'string' | 'int' | 'bool' | 'string-list';
|
|
26
|
+
/** One known config key. */
|
|
27
|
+
export interface ConfigKeySpec {
|
|
28
|
+
/** CLI dotted name, e.g. `interactive.host`. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** camelCase key under the YAML `config:` block. */
|
|
31
|
+
yamlKey: string;
|
|
32
|
+
scope: ConfigScope;
|
|
33
|
+
type: ConfigType;
|
|
34
|
+
/** One-line description for help/list output. */
|
|
35
|
+
description: string;
|
|
36
|
+
/**
|
|
37
|
+
* When set, the value lives in this top-level Meta field instead of the
|
|
38
|
+
* `config:` block (only `browser.profile` → `defaultBrowserProfile` today).
|
|
39
|
+
*/
|
|
40
|
+
field?: 'defaultBrowserProfile';
|
|
41
|
+
/** Extra validation beyond the type check; return an error string or null. */
|
|
42
|
+
validate?: (value: unknown) => string | null;
|
|
43
|
+
}
|
|
44
|
+
/** A key with its resolved value and the layer that set it. */
|
|
45
|
+
export interface ConfigEntry {
|
|
46
|
+
spec: ConfigKeySpec;
|
|
47
|
+
/** The stored value, or undefined when unset (unset = default behavior). */
|
|
48
|
+
value: unknown;
|
|
49
|
+
/** Which layer set it; undefined when unset. */
|
|
50
|
+
layer?: ConfigScope;
|
|
51
|
+
}
|
|
52
|
+
/** Options scoping a read/write to a specific device's doc (default: this machine). */
|
|
53
|
+
export interface ConfigTarget {
|
|
54
|
+
device?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare const CONFIG_KEYS: readonly ConfigKeySpec[];
|
|
57
|
+
/** Look up a key spec by CLI dotted name, or throw listing the known keys. */
|
|
58
|
+
export declare function configKeySpec(name: string): ConfigKeySpec;
|
|
59
|
+
/** Get one config key's value and the layer that set it. */
|
|
60
|
+
export declare function getConfigValue(name: string, opts?: ConfigTarget): ConfigEntry;
|
|
61
|
+
/** List every known key with its value and the layer that set it. */
|
|
62
|
+
export declare function listConfig(opts?: ConfigTarget): ConfigEntry[];
|
|
63
|
+
/** Set a config key (validated). Device-scope keys target this machine unless `opts.device` names a peer. */
|
|
64
|
+
export declare function setConfigValue(name: string, value: unknown, opts?: ConfigTarget): void;
|
|
65
|
+
/** Unset a config key — restores default behavior. No-op when already unset. */
|
|
66
|
+
export declare function unsetConfigValue(name: string, opts?: ConfigTarget): void;
|
|
67
|
+
/** True unless this machine's device doc disables the routines scheduler. */
|
|
68
|
+
export declare function isSchedulerEnabled(): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Throw when the routines scheduler is disabled on this machine, naming the
|
|
71
|
+
* setting and the fix. The single message every scheduler-start surface
|
|
72
|
+
* (auto-start on `routines add`, manual `routines start`, the daemon's own
|
|
73
|
+
* scheduler init) refuses with.
|
|
74
|
+
*/
|
|
75
|
+
export declare function assertSchedulerEnabled(): void;
|
|
76
|
+
/**
|
|
77
|
+
* Read the `agents.max-concurrent` cap for each named device from its synced
|
|
78
|
+
* device doc (no SSH). Devices without a cap are omitted — uncapped is the
|
|
79
|
+
* default. Used as an input to host ranking (teams placement, Factory
|
|
80
|
+
* auto-launch), never as a remote probe.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readMaxConcurrentCaps(devices: string[]): Record<string, number>;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device/user config keys — typed read/write over the two-tier agents.yaml store.
|
|
3
|
+
*
|
|
4
|
+
* One registry (`CONFIG_KEYS`) maps each CLI dotted name to where it lives:
|
|
5
|
+
* - user scope → central `~/.agents/agents.yaml` under `config:` (syncs
|
|
6
|
+
* fleet-wide via `agents repo push/pull`)
|
|
7
|
+
* - device scope → `~/.agents/devices/<host>/agents.yaml` under `config:`
|
|
8
|
+
* (per-machine; mirrors how `defaultBrowserProfile` is routed)
|
|
9
|
+
*
|
|
10
|
+
* This machine's keys go through the readMeta/updateMeta funnel (state.ts) so the
|
|
11
|
+
* partition/overlay logic stays the single writer. Another device's doc is
|
|
12
|
+
* read/written in place — the devices/ tree syncs via the DotAgents repo, so
|
|
13
|
+
* editing `devices/mac-mini/agents.yaml` locally is how `configure`/`note`
|
|
14
|
+
* target a peer (`--device`-style).
|
|
15
|
+
*
|
|
16
|
+
* `browser.profile` is NOT a `config:` key — it is the existing
|
|
17
|
+
* `Meta.defaultBrowserProfile` field; the registry entry documents the mapping
|
|
18
|
+
* and set/get route to it so there is one source of truth (no duplicate key).
|
|
19
|
+
*
|
|
20
|
+
* Unset always means today's behavior.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from 'fs';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import * as yaml from 'yaml';
|
|
25
|
+
import { META_HEADER, getUserAgentsDir, readMeta, updateMeta } from './state.js';
|
|
26
|
+
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
27
|
+
import { machineId } from './machine-id.js';
|
|
28
|
+
import { assertValidDeviceName } from './devices/registry.js';
|
|
29
|
+
export const CONFIG_KEYS = [
|
|
30
|
+
{
|
|
31
|
+
name: 'interactive.host',
|
|
32
|
+
yamlKey: 'interactiveHost',
|
|
33
|
+
scope: 'user',
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Device that shows the user artifacts (browser opens, dashboards) — the "online macOS box" skills should use instead of guessing.',
|
|
36
|
+
validate: (v) => {
|
|
37
|
+
try {
|
|
38
|
+
assertValidDeviceName(v);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return err?.message ?? String(err);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'browser.profile',
|
|
48
|
+
yamlKey: 'defaultBrowserProfile',
|
|
49
|
+
scope: 'device',
|
|
50
|
+
type: 'string',
|
|
51
|
+
field: 'defaultBrowserProfile',
|
|
52
|
+
description: 'Browser profile `agents browser start` resolves to without --profile (set via `agents browser profiles set-default`).',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'agents.max-concurrent',
|
|
56
|
+
yamlKey: 'maxAgents',
|
|
57
|
+
scope: 'device',
|
|
58
|
+
type: 'int',
|
|
59
|
+
description: 'Cap on concurrent agents on this device. What counts toward it depends on the consumer: ' +
|
|
60
|
+
'Factory auto-launch counts device-wide running agents; teams placement counts the team’s own roster on the device.',
|
|
61
|
+
validate: (v) => (v >= 1 ? null : 'agents.max-concurrent must be >= 1.'),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'scheduler.enabled',
|
|
65
|
+
yamlKey: 'schedulerEnabled',
|
|
66
|
+
scope: 'device',
|
|
67
|
+
type: 'bool',
|
|
68
|
+
description: 'Whether the routines scheduler (daemon) may fire on this device.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'notes',
|
|
72
|
+
yamlKey: 'notes',
|
|
73
|
+
scope: 'device',
|
|
74
|
+
type: 'string-list',
|
|
75
|
+
description: 'Free-form operator notes about this device (one entry per `agents devices note`).',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
/** Look up a key spec by CLI dotted name, or throw listing the known keys. */
|
|
79
|
+
export function configKeySpec(name) {
|
|
80
|
+
const spec = CONFIG_KEYS.find((k) => k.name === name);
|
|
81
|
+
if (!spec) {
|
|
82
|
+
throw new Error(`Unknown config key '${name}'. Known keys: ${CONFIG_KEYS.map((k) => k.name).join(', ')}.`);
|
|
83
|
+
}
|
|
84
|
+
return spec;
|
|
85
|
+
}
|
|
86
|
+
/** Throw when `value` does not match the key's declared type or validation. */
|
|
87
|
+
function assertValidValue(spec, value) {
|
|
88
|
+
switch (spec.type) {
|
|
89
|
+
case 'string':
|
|
90
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
91
|
+
throw new Error(`Config key '${spec.name}' expects a non-empty string, got ${JSON.stringify(value)}.`);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
case 'int':
|
|
95
|
+
if (!Number.isInteger(value)) {
|
|
96
|
+
throw new Error(`Config key '${spec.name}' expects an integer, got ${JSON.stringify(value)}.`);
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
case 'bool':
|
|
100
|
+
if (typeof value !== 'boolean') {
|
|
101
|
+
throw new Error(`Config key '${spec.name}' expects a boolean, got ${JSON.stringify(value)}.`);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case 'string-list':
|
|
105
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
106
|
+
throw new Error(`Config key '${spec.name}' expects a list of strings, got ${JSON.stringify(value)}.`);
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
const err = spec.validate?.(value);
|
|
111
|
+
if (err)
|
|
112
|
+
throw new Error(`Invalid value for '${spec.name}': ${err}`);
|
|
113
|
+
}
|
|
114
|
+
// ─── Sibling-device doc access ────────────────────────────────────────────────
|
|
115
|
+
/** Path to any device's doc (self or a peer) under the synced devices/ tree. */
|
|
116
|
+
function deviceDocPath(device) {
|
|
117
|
+
return path.join(getUserAgentsDir(), 'devices', device, 'agents.yaml');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read a device doc directly. Returns null when the file does not exist. A
|
|
121
|
+
* malformed file is a hard error — silently returning null would let the next
|
|
122
|
+
* write wipe the device's pins/default (same contract as the device registry).
|
|
123
|
+
* A valid-but-non-map document (a bare string, a list) is the same kind of
|
|
124
|
+
* corruption: reject it here instead of failing later with a TypeError.
|
|
125
|
+
*/
|
|
126
|
+
function readDeviceDoc(device) {
|
|
127
|
+
const p = deviceDocPath(device);
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = fs.readFileSync(p, 'utf-8');
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err && err.code === 'ENOENT')
|
|
134
|
+
return null;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
const corrupted = (detail) => new Error(`Device config corrupted at ${p}: ${detail}. Inspect and restore from backup.`);
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = yaml.parse(raw);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw corrupted(err?.message ?? String(err));
|
|
144
|
+
}
|
|
145
|
+
if (parsed === null || parsed === undefined)
|
|
146
|
+
return {};
|
|
147
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
148
|
+
throw corrupted(`expected a YAML map, got ${Array.isArray(parsed) ? 'a list' : JSON.stringify(parsed)}`);
|
|
149
|
+
}
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
152
|
+
/** Write a device doc in place (atomic). Only ever holds device-local fields. */
|
|
153
|
+
function writeDeviceDoc(device, doc) {
|
|
154
|
+
const p = deviceDocPath(device);
|
|
155
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
156
|
+
const body = Object.keys(doc).length > 0 ? doc : { agents: {} };
|
|
157
|
+
atomicWriteFileSync(p, META_HEADER + yaml.stringify(body));
|
|
158
|
+
}
|
|
159
|
+
// ─── Reads ────────────────────────────────────────────────────────────────────
|
|
160
|
+
function entryFromMeta(spec, meta) {
|
|
161
|
+
if (spec.field === 'defaultBrowserProfile') {
|
|
162
|
+
const value = meta.defaultBrowserProfile;
|
|
163
|
+
return { spec, value, layer: value !== undefined ? 'device' : undefined };
|
|
164
|
+
}
|
|
165
|
+
if (spec.scope === 'user') {
|
|
166
|
+
const value = meta.config?.[spec.yamlKey];
|
|
167
|
+
return { spec, value, layer: value !== undefined ? 'user' : undefined };
|
|
168
|
+
}
|
|
169
|
+
const value = meta.deviceConfig?.[spec.yamlKey];
|
|
170
|
+
return { spec, value, layer: value !== undefined ? 'device' : undefined };
|
|
171
|
+
}
|
|
172
|
+
/** True when `device` names this machine (case-insensitive, mirroring
|
|
173
|
+
* `isLocalDevice` in teams/scheduler.ts) — `configure ZION` on host zion must
|
|
174
|
+
* take the self path (readMeta/updateMeta funnel), not the peer-doc path. */
|
|
175
|
+
function isSelfDevice(device) {
|
|
176
|
+
return device.toLowerCase() === machineId();
|
|
177
|
+
}
|
|
178
|
+
/** Get one config key's value and the layer that set it. */
|
|
179
|
+
export function getConfigValue(name, opts) {
|
|
180
|
+
const spec = configKeySpec(name);
|
|
181
|
+
if (spec.scope === 'device' && opts?.device && !isSelfDevice(opts.device)) {
|
|
182
|
+
const doc = readDeviceDoc(opts.device) ?? {};
|
|
183
|
+
return entryFromMeta(spec, { deviceConfig: doc.config, defaultBrowserProfile: doc.defaultBrowserProfile });
|
|
184
|
+
}
|
|
185
|
+
return entryFromMeta(spec, readMeta());
|
|
186
|
+
}
|
|
187
|
+
/** List every known key with its value and the layer that set it. */
|
|
188
|
+
export function listConfig(opts) {
|
|
189
|
+
return CONFIG_KEYS.map((spec) => getConfigValue(spec.name, opts));
|
|
190
|
+
}
|
|
191
|
+
// ─── Writes ───────────────────────────────────────────────────────────────────
|
|
192
|
+
function setInMeta(spec, value) {
|
|
193
|
+
updateMeta((m) => {
|
|
194
|
+
if (spec.field === 'defaultBrowserProfile') {
|
|
195
|
+
return { ...m, defaultBrowserProfile: value };
|
|
196
|
+
}
|
|
197
|
+
if (spec.scope === 'user') {
|
|
198
|
+
return { ...m, config: { ...m.config, [spec.yamlKey]: value } };
|
|
199
|
+
}
|
|
200
|
+
return { ...m, deviceConfig: { ...m.deviceConfig, [spec.yamlKey]: value } };
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function unsetInMeta(spec) {
|
|
204
|
+
updateMeta((m) => {
|
|
205
|
+
if (spec.field === 'defaultBrowserProfile') {
|
|
206
|
+
const { defaultBrowserProfile, ...rest } = m;
|
|
207
|
+
void defaultBrowserProfile;
|
|
208
|
+
return rest;
|
|
209
|
+
}
|
|
210
|
+
const block = spec.scope === 'user' ? m.config : m.deviceConfig;
|
|
211
|
+
if (!block || !(spec.yamlKey in block))
|
|
212
|
+
return m;
|
|
213
|
+
const next = { ...block };
|
|
214
|
+
delete next[spec.yamlKey];
|
|
215
|
+
const cleaned = Object.keys(next).length > 0 ? next : undefined;
|
|
216
|
+
return spec.scope === 'user' ? { ...m, config: cleaned } : { ...m, deviceConfig: cleaned };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function setInDeviceDoc(device, spec, value) {
|
|
220
|
+
const doc = readDeviceDoc(device) ?? {};
|
|
221
|
+
if (spec.field === 'defaultBrowserProfile') {
|
|
222
|
+
doc.defaultBrowserProfile = value;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
doc.config = { ...doc.config, [spec.yamlKey]: value };
|
|
226
|
+
}
|
|
227
|
+
writeDeviceDoc(device, doc);
|
|
228
|
+
}
|
|
229
|
+
function unsetInDeviceDoc(device, spec) {
|
|
230
|
+
const doc = readDeviceDoc(device);
|
|
231
|
+
if (!doc)
|
|
232
|
+
return; // nothing stored — unset is a no-op
|
|
233
|
+
if (spec.field === 'defaultBrowserProfile') {
|
|
234
|
+
delete doc.defaultBrowserProfile;
|
|
235
|
+
}
|
|
236
|
+
else if (doc.config && spec.yamlKey in doc.config) {
|
|
237
|
+
delete doc.config[spec.yamlKey];
|
|
238
|
+
if (Object.keys(doc.config).length === 0)
|
|
239
|
+
delete doc.config;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
return; // key not present — no write needed
|
|
243
|
+
}
|
|
244
|
+
writeDeviceDoc(device, doc);
|
|
245
|
+
}
|
|
246
|
+
/** Set a config key (validated). Device-scope keys target this machine unless `opts.device` names a peer. */
|
|
247
|
+
export function setConfigValue(name, value, opts) {
|
|
248
|
+
const spec = configKeySpec(name);
|
|
249
|
+
assertValidValue(spec, value);
|
|
250
|
+
if (spec.scope === 'device' && opts?.device && !isSelfDevice(opts.device)) {
|
|
251
|
+
setInDeviceDoc(opts.device, spec, value);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
setInMeta(spec, value);
|
|
255
|
+
}
|
|
256
|
+
/** Unset a config key — restores default behavior. No-op when already unset. */
|
|
257
|
+
export function unsetConfigValue(name, opts) {
|
|
258
|
+
const spec = configKeySpec(name);
|
|
259
|
+
if (spec.scope === 'device' && opts?.device && !isSelfDevice(opts.device)) {
|
|
260
|
+
unsetInDeviceDoc(opts.device, spec);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
unsetInMeta(spec);
|
|
264
|
+
}
|
|
265
|
+
// ─── Consumers' helpers ───────────────────────────────────────────────────────
|
|
266
|
+
/** True unless this machine's device doc disables the routines scheduler. */
|
|
267
|
+
export function isSchedulerEnabled() {
|
|
268
|
+
return getConfigValue('scheduler.enabled').value !== false;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Throw when the routines scheduler is disabled on this machine, naming the
|
|
272
|
+
* setting and the fix. The single message every scheduler-start surface
|
|
273
|
+
* (auto-start on `routines add`, manual `routines start`, the daemon's own
|
|
274
|
+
* scheduler init) refuses with.
|
|
275
|
+
*/
|
|
276
|
+
export function assertSchedulerEnabled() {
|
|
277
|
+
if (isSchedulerEnabled())
|
|
278
|
+
return;
|
|
279
|
+
throw new Error(`The routines scheduler is disabled on this device (scheduler.enabled=false in ~/.agents/devices/${machineId()}/agents.yaml). ` +
|
|
280
|
+
`Re-enable with: agents devices configure ${machineId()} --scheduler on`);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Read the `agents.max-concurrent` cap for each named device from its synced
|
|
284
|
+
* device doc (no SSH). Devices without a cap are omitted — uncapped is the
|
|
285
|
+
* default. Used as an input to host ranking (teams placement, Factory
|
|
286
|
+
* auto-launch), never as a remote probe.
|
|
287
|
+
*/
|
|
288
|
+
export function readMaxConcurrentCaps(devices) {
|
|
289
|
+
const caps = {};
|
|
290
|
+
for (const device of devices) {
|
|
291
|
+
const value = getConfigValue('agents.max-concurrent', { device }).value;
|
|
292
|
+
if (typeof value === 'number')
|
|
293
|
+
caps[device] = value;
|
|
294
|
+
}
|
|
295
|
+
return caps;
|
|
296
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ActorKind } from './actor.js';
|
|
2
|
+
export interface EventProvenance {
|
|
3
|
+
osUser: string;
|
|
4
|
+
transport: 'local' | 'ssh';
|
|
5
|
+
sshClientIp?: string;
|
|
6
|
+
actor: string;
|
|
7
|
+
kind: ActorKind;
|
|
8
|
+
machineId: string;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
agent?: string;
|
|
11
|
+
launchId?: string;
|
|
12
|
+
parentSessionId?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Stamp the shared identity floor used by both operational and activity events.
|
|
16
|
+
* Explicit event payload fields may override these defaults at the call site.
|
|
17
|
+
*/
|
|
18
|
+
export declare function stampProvenance(env?: NodeJS.ProcessEnv): EventProvenance;
|
|
19
|
+
export declare function resetEventProvenanceForTest(): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import { resolveActor } from './actor.js';
|
|
3
|
+
import { machineId } from './machine-id.js';
|
|
4
|
+
import { parseSshConnection } from './session/provenance.js';
|
|
5
|
+
let cachedOrigin;
|
|
6
|
+
let cachedDeviceId;
|
|
7
|
+
/**
|
|
8
|
+
* Stamp the shared identity floor used by both operational and activity events.
|
|
9
|
+
* Explicit event payload fields may override these defaults at the call site.
|
|
10
|
+
*/
|
|
11
|
+
export function stampProvenance(env = process.env) {
|
|
12
|
+
if (!cachedOrigin) {
|
|
13
|
+
let osUser = 'unknown';
|
|
14
|
+
try {
|
|
15
|
+
osUser = os.userInfo().username;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// A uid without a passwd entry has no attributable OS user.
|
|
19
|
+
}
|
|
20
|
+
const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
|
|
21
|
+
const actor = resolveActor();
|
|
22
|
+
cachedOrigin = {
|
|
23
|
+
osUser,
|
|
24
|
+
transport: ssh ? 'ssh' : 'local',
|
|
25
|
+
...(ssh ? { sshClientIp: ssh.clientIp } : {}),
|
|
26
|
+
actor: actor.id,
|
|
27
|
+
kind: actor.kind,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const provenance = {
|
|
31
|
+
...cachedOrigin,
|
|
32
|
+
machineId: (cachedDeviceId ??= machineId()),
|
|
33
|
+
};
|
|
34
|
+
const sessionId = env.AGENT_SESSION_ID || env.AGENTS_SESSION_ID;
|
|
35
|
+
if (sessionId)
|
|
36
|
+
provenance.sessionId = sessionId;
|
|
37
|
+
if (env.AGENTS_AGENT_NAME)
|
|
38
|
+
provenance.agent = env.AGENTS_AGENT_NAME;
|
|
39
|
+
if (env.AGENT_LAUNCH_ID)
|
|
40
|
+
provenance.launchId = env.AGENT_LAUNCH_ID;
|
|
41
|
+
if (env.AGENTS_PARENT_SESSION_ID)
|
|
42
|
+
provenance.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
|
|
43
|
+
return provenance;
|
|
44
|
+
}
|
|
45
|
+
export function resetEventProvenanceForTest() {
|
|
46
|
+
cachedOrigin = undefined;
|
|
47
|
+
cachedDeviceId = undefined;
|
|
48
|
+
}
|