@heretek-ai/epistemic-swarm 0.7.19 → 0.7.21
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/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -2
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/antigravity/rules/AGENTS.md +5 -3
- package/plugins/opencode/config-io.js +55 -12
- package/plugins/opencode/index.js +611 -0
- package/plugins/opencode/tui.js +278 -6
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +81 -10
- package/runner/research_swarm.py +12 -2
- package/runner/schemas.py +18 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_config_schema.py +106 -0
- package/runner/tests/test_opencode_ux.py +462 -7
- package/runner/tests/test_opencode_v2_registrars.py +1112 -3
- package/runner/tests/test_swarm_config.py +21 -0
- package/schemas/config.schema.json +11 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/SKILL.md +26 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +40 -6
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -86,6 +86,7 @@ VALID_UPDATES = {
|
|
|
86
86
|
},
|
|
87
87
|
"opencode_auto": False,
|
|
88
88
|
"opencode_agent": "darkharvester",
|
|
89
|
+
"mcp_servers": {"searxng": False, "firecrawl": True},
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
# One clearly invalid value per canonical key.
|
|
@@ -107,6 +108,7 @@ INVALID_UPDATES = {
|
|
|
107
108
|
"agents": "claude -p",
|
|
108
109
|
"opencode_auto": "yes",
|
|
109
110
|
"opencode_agent": 5,
|
|
111
|
+
"mcp_servers": "enabled",
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
|
|
@@ -319,6 +321,71 @@ class TestMcpConfigSurface(unittest.TestCase):
|
|
|
319
321
|
self.assertFalse((Path(tmp) / "config.json").exists())
|
|
320
322
|
shown = self._call({"base_dir": tmp, "show": True})
|
|
321
323
|
self.assertEqual(shown["status"], "current")
|
|
324
|
+
# R11: the legacy-pin case must NOT write (the old test was vacuous
|
|
325
|
+
# here). Seed a pin, snapshot the bytes, and prove the show leg
|
|
326
|
+
# leaves them byte-identical while still reporting the migration.
|
|
327
|
+
cfg_file = _write_config(
|
|
328
|
+
Path(tmp), {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
|
|
329
|
+
)
|
|
330
|
+
before = cfg_file.read_bytes()
|
|
331
|
+
pinned = self._call({"base_dir": tmp})
|
|
332
|
+
self.assertEqual(pinned["status"], "current")
|
|
333
|
+
self.assertTrue(pinned["migrated"])
|
|
334
|
+
self.assertEqual(cfg_file.read_bytes(), before)
|
|
335
|
+
|
|
336
|
+
def test_write_leg_heals_legacy_pin_and_persists(self):
|
|
337
|
+
# R11 preserves phase-01 A6: a write still migrates the legacy pin on disk.
|
|
338
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
339
|
+
base = Path(tmp)
|
|
340
|
+
cfg_file = _write_config(
|
|
341
|
+
base, {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
|
|
342
|
+
)
|
|
343
|
+
payload = self._call({"max_iterations": 2, "base_dir": tmp})
|
|
344
|
+
self.assertEqual(payload["status"], "updated", payload)
|
|
345
|
+
healed = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
346
|
+
self.assertIsNone(healed["agents"]["alpha"]["backend"])
|
|
347
|
+
self.assertEqual(healed["max_iterations"], 2)
|
|
348
|
+
|
|
349
|
+
def test_guarded_write_on_legacy_pin_succeeds_and_heals(self):
|
|
350
|
+
# F1/R12: the wizard always sends `expected_hash`. With no heal-before-
|
|
351
|
+
# save, the guard still matches so the write is `updated` (never a false
|
|
352
|
+
# `stale`) and the pin is migrated on disk.
|
|
353
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
354
|
+
base = Path(tmp)
|
|
355
|
+
cfg_file = _write_config(
|
|
356
|
+
base, {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
|
|
357
|
+
)
|
|
358
|
+
start_hash = config_hash(tmp)
|
|
359
|
+
payload = self._call(
|
|
360
|
+
{"max_iterations": 3, "expected_hash": start_hash, "base_dir": tmp}
|
|
361
|
+
)
|
|
362
|
+
self.assertEqual(payload["status"], "updated", payload)
|
|
363
|
+
healed = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
364
|
+
self.assertIsNone(healed["agents"]["alpha"]["backend"])
|
|
365
|
+
self.assertEqual(healed["max_iterations"], 3)
|
|
366
|
+
|
|
367
|
+
def test_invalid_pin_file_show_reports_no_migration_and_write_blocked(self):
|
|
368
|
+
# R12/R11: an invalid file is never healed, and a write that would leave
|
|
369
|
+
# it invalid is rejected without touching the bytes.
|
|
370
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
371
|
+
base = Path(tmp)
|
|
372
|
+
cfg_file = _write_config(
|
|
373
|
+
base,
|
|
374
|
+
{
|
|
375
|
+
"max_iterations": 99,
|
|
376
|
+
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
before = cfg_file.read_bytes()
|
|
380
|
+
shown = self._call({"base_dir": tmp})
|
|
381
|
+
self.assertEqual(shown["status"], "current")
|
|
382
|
+
self.assertFalse(shown["migrated"])
|
|
383
|
+
self.assertEqual(cfg_file.read_bytes(), before)
|
|
384
|
+
blocked = self._call({"mode": "audit", "base_dir": tmp})
|
|
385
|
+
self.assertEqual(blocked["status"], "error")
|
|
386
|
+
self.assertEqual(blocked["code"], "CONFIG_VALIDATION_FAILED")
|
|
387
|
+
self.assertFalse(blocked["written"])
|
|
388
|
+
self.assertEqual(cfg_file.read_bytes(), before)
|
|
322
389
|
|
|
323
390
|
def test_null_clears_a_nullable_key(self):
|
|
324
391
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -439,6 +506,45 @@ class TestMcpConfigSurface(unittest.TestCase):
|
|
|
439
506
|
self.assertEqual(healed["max_iterations"], 2)
|
|
440
507
|
self.assertIsNone(healed["agents"]["alpha"]["backend"])
|
|
441
508
|
|
|
509
|
+
def test_updated_payload_is_single_read_coherent(self):
|
|
510
|
+
"""R10: `updated` config + hash come from ONE read of the written bytes.
|
|
511
|
+
|
|
512
|
+
Regression against the pre-R10 shape (`load_config()` then
|
|
513
|
+
`config_hash()` as two independent reads): a write landing between the
|
|
514
|
+
two would pair an era-A config with an era-B hash. We inject exactly
|
|
515
|
+
that write into `config_hash`; a single-read implementation never calls
|
|
516
|
+
it, so the returned config/hash stay coherent with the bytes on disk.
|
|
517
|
+
"""
|
|
518
|
+
import hashlib
|
|
519
|
+
|
|
520
|
+
from skills.swarm_config import configure
|
|
521
|
+
|
|
522
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
523
|
+
original = configure.config_hash
|
|
524
|
+
|
|
525
|
+
def torn(base):
|
|
526
|
+
# Simulate a concurrent writer landing era-B between the old
|
|
527
|
+
# shape's two reads, immediately before the hash read.
|
|
528
|
+
cfg = Path(base) / "config.json"
|
|
529
|
+
data = json.loads(cfg.read_text(encoding="utf-8"))
|
|
530
|
+
data["mode"] = "audit"
|
|
531
|
+
cfg.write_text(json.dumps(data), encoding="utf-8")
|
|
532
|
+
return original(base)
|
|
533
|
+
|
|
534
|
+
configure.config_hash = torn
|
|
535
|
+
try:
|
|
536
|
+
payload = self._call({"mode": "scout", "base_dir": tmp})
|
|
537
|
+
finally:
|
|
538
|
+
configure.config_hash = original
|
|
539
|
+
|
|
540
|
+
self.assertEqual(payload["status"], "updated")
|
|
541
|
+
written = (Path(tmp) / "config.json").read_bytes()
|
|
542
|
+
# The hash covers the bytes the config was derived from ...
|
|
543
|
+
self.assertEqual(payload["hash"], hashlib.sha256(written).hexdigest())
|
|
544
|
+
# ... and the config is that same era (never scout + audit-hash).
|
|
545
|
+
self.assertEqual(payload["config"]["mode"], json.loads(written)["mode"])
|
|
546
|
+
self.assertEqual(payload["config"], load_config(tmp))
|
|
547
|
+
|
|
442
548
|
def test_output_dir_deprecation_is_inert(self):
|
|
443
549
|
with tempfile.TemporaryDirectory() as tmp:
|
|
444
550
|
base = Path(tmp)
|
|
@@ -911,8 +911,8 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
|
|
|
911
911
|
res = run_node(
|
|
912
912
|
f"""
|
|
913
913
|
import plugin from "./plugins/opencode/index.js";
|
|
914
|
-
|
|
915
|
-
let
|
|
914
|
+
const hookNames = [];
|
|
915
|
+
let compactionHandler = null;
|
|
916
916
|
const host = {{
|
|
917
917
|
options: {{}},
|
|
918
918
|
location: {{directory: {str(root)!r}}},
|
|
@@ -920,16 +920,16 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
|
|
|
920
920
|
tool: {{transform: async () => ({{dispose: async () => {{}}}}), reload: async () => {{}}}},
|
|
921
921
|
session: {{
|
|
922
922
|
prompt: async () => ({{}}),
|
|
923
|
-
hook: async (name, fn) => {{
|
|
923
|
+
hook: async (name, fn) => {{ hookNames.push(name); if (name === "compaction") compactionHandler = fn; return {{dispose: async () => {{}}}}; }}
|
|
924
924
|
}}
|
|
925
925
|
}};
|
|
926
926
|
await plugin.setup(host);
|
|
927
927
|
const event = {{system: [], directory: {str(root)!r}}};
|
|
928
|
-
if (
|
|
928
|
+
if (compactionHandler) await compactionHandler(event);
|
|
929
929
|
const emptyEvent = {{system: [], directory: "/tmp/oc-v2-compaction-empty-nonexistent"}};
|
|
930
|
-
if (
|
|
930
|
+
if (compactionHandler) await compactionHandler(emptyEvent);
|
|
931
931
|
console.log(JSON.stringify({{
|
|
932
|
-
|
|
932
|
+
hookNames,
|
|
933
933
|
parts: event.system.map((p) => ({{type: p.type, text: p.text || ""}})),
|
|
934
934
|
emptySkipped: emptyEvent.system.length
|
|
935
935
|
}}));
|
|
@@ -939,7 +939,7 @@ class TestOpenCodeV2Transforms(unittest.TestCase):
|
|
|
939
939
|
res.returncode, 0, f"compaction hook test failed: {res.stderr}"
|
|
940
940
|
)
|
|
941
941
|
data = last_json_object(res.stdout)
|
|
942
|
-
self.
|
|
942
|
+
self.assertIn("compaction", data["hookNames"])
|
|
943
943
|
self.assertEqual(
|
|
944
944
|
data["emptySkipped"], 0, "empty workspace must not push state"
|
|
945
945
|
)
|
|
@@ -1747,5 +1747,460 @@ REPLAY_SINGLE = """
|
|
|
1747
1747
|
"""
|
|
1748
1748
|
|
|
1749
1749
|
|
|
1750
|
+
# ---------------------------------------------------------------------------
|
|
1751
|
+
# Phase 03 (opencode-settings-menu): TUI prefers the `iumbtems.settings` RPC
|
|
1752
|
+
# (degraded direct-fs fallback with toast), `changed` subscription with the
|
|
1753
|
+
# 5s poll as floor, and UI-only prefs that never affect run-affecting values.
|
|
1754
|
+
# ---------------------------------------------------------------------------
|
|
1755
|
+
|
|
1756
|
+
RPC_TUI_HARNESS = """
|
|
1757
|
+
import {
|
|
1758
|
+
openSettingsWizard, openSettingsPanel,
|
|
1759
|
+
subscribeSettingsChanged, getSettingsRpcClient,
|
|
1760
|
+
readUiPrefs, writeUiPrefs,
|
|
1761
|
+
SETTINGS_POLL_MS, UI_PREFS_KEY,
|
|
1762
|
+
} from "./plugins/opencode/tui.js";
|
|
1763
|
+
import { SettingsRpc } from "./plugins/opencode/index.js";
|
|
1764
|
+
import { loadConfigFromRaw } from "./plugins/opencode/config-io.js";
|
|
1765
|
+
import fs from "node:fs";
|
|
1766
|
+
import path from "node:path";
|
|
1767
|
+
|
|
1768
|
+
// Realistic RPC snapshot (phase-04 R1): `raw` is the parsed ON-DISK file config;
|
|
1769
|
+
// `config` is the defaults-merged EFFECTIVE config (the production `get` builds
|
|
1770
|
+
// it with `loadConfigFromRaw`). A partial-config mock here is exactly what let
|
|
1771
|
+
// the RPC-path provenance defect ship.
|
|
1772
|
+
function rpcState(raw, hash) {
|
|
1773
|
+
const onDisk = raw && typeof raw === "object" ? raw : {};
|
|
1774
|
+
return {
|
|
1775
|
+
raw: { ...onDisk },
|
|
1776
|
+
config: loadConfigFromRaw(onDisk),
|
|
1777
|
+
hash, setCalls: [], subs: [], unsubbed: false, staleAlways: false,
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
function rpcClient(state) {
|
|
1781
|
+
const snapshot = () => ({ config: { ...state.config }, raw: { ...state.raw }, hash: state.hash });
|
|
1782
|
+
return {
|
|
1783
|
+
get: async () => snapshot(),
|
|
1784
|
+
set: async (input) => {
|
|
1785
|
+
state.setCalls.push(input);
|
|
1786
|
+
const stale = () => {
|
|
1787
|
+
const e = new Error("stale");
|
|
1788
|
+
e.type = "stale";
|
|
1789
|
+
e.data = { current_hash: state.hash, config: { ...state.config } };
|
|
1790
|
+
throw e;
|
|
1791
|
+
};
|
|
1792
|
+
if (state.staleAlways) stale();
|
|
1793
|
+
const exp = input?.expected_hash !== undefined ? input.expected_hash : input?.expectedHash;
|
|
1794
|
+
if (exp !== undefined && exp !== null && exp !== state.hash) stale();
|
|
1795
|
+
state.raw = { ...state.raw, ...(input?.updates || {}) };
|
|
1796
|
+
state.config = loadConfigFromRaw(state.raw);
|
|
1797
|
+
state.hash = `hash${state.setCalls.length}`;
|
|
1798
|
+
return snapshot();
|
|
1799
|
+
},
|
|
1800
|
+
events: {
|
|
1801
|
+
on: (name, fn) => { state.subs.push({ name, fn }); return () => { state.unsubbed = true; }; },
|
|
1802
|
+
},
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
function tuiStore() {
|
|
1806
|
+
const map = new Map();
|
|
1807
|
+
return {
|
|
1808
|
+
map,
|
|
1809
|
+
storage: {
|
|
1810
|
+
store: (key, { initial }) => {
|
|
1811
|
+
const cur = map.has(key) ? map.get(key) : initial;
|
|
1812
|
+
return [
|
|
1813
|
+
{ ...((cur || {})) },
|
|
1814
|
+
async (mut) => {
|
|
1815
|
+
const d = { ...(((map.has(key) ? map.get(key) : initial)) || {}) };
|
|
1816
|
+
mut(d);
|
|
1817
|
+
map.set(key, d);
|
|
1818
|
+
},
|
|
1819
|
+
];
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
function dialogHost(queues) {
|
|
1825
|
+
const toasts = [];
|
|
1826
|
+
const pop = (arr) => ((!arr || arr.length === 0) ? null : arr.shift());
|
|
1827
|
+
return {
|
|
1828
|
+
toasts,
|
|
1829
|
+
ui: {
|
|
1830
|
+
toast: { show: (t) => toasts.push(t) },
|
|
1831
|
+
dialog: {
|
|
1832
|
+
select: async (a) => pop(queues.select),
|
|
1833
|
+
prompt: async (a) => pop(queues.prompt),
|
|
1834
|
+
confirm: async (a) => pop(queues.confirm),
|
|
1835
|
+
},
|
|
1836
|
+
panel: async (p) => { dialogHost._panel = p; return {}; },
|
|
1837
|
+
},
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
"""
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
class TestRpcPreferredPath(unittest.TestCase):
|
|
1844
|
+
def test_wizard_prefers_rpc_and_writes_nothing_to_disk(self):
|
|
1845
|
+
# C2 (preferred leg): with `client.rpc` the wizard round-trips through
|
|
1846
|
+
# the RPC subclient; the workspace is never touched on disk.
|
|
1847
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1848
|
+
res = run_node(
|
|
1849
|
+
RPC_TUI_HARNESS
|
|
1850
|
+
+ f"""
|
|
1851
|
+
const root = {tmp!r};
|
|
1852
|
+
const state = rpcState({{ search_engine: "duckduckgo" }}, "h0");
|
|
1853
|
+
const store = tuiStore();
|
|
1854
|
+
const h = dialogHost({{ select: ["search_engine", "brave", "__iumbtems_save__"], confirm: [true] }});
|
|
1855
|
+
const host = {{
|
|
1856
|
+
directory: root, client: {{ rpc: (def) => rpcClient(state) }},
|
|
1857
|
+
storage: store.storage, ui: h.ui,
|
|
1858
|
+
}};
|
|
1859
|
+
const r = await openSettingsWizard(host, root, {{ env: {{}} }});
|
|
1860
|
+
const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
1861
|
+
console.log(JSON.stringify({{
|
|
1862
|
+
ok: r.ok, written: r.written, hash: r.hash,
|
|
1863
|
+
setCalls: state.setCalls,
|
|
1864
|
+
files: (function walk(d) {{
|
|
1865
|
+
try {{ return fs.readdirSync(d); }} catch {{ return []; }}
|
|
1866
|
+
}})(root),
|
|
1867
|
+
degraded: toasts.includes("degraded"),
|
|
1868
|
+
viaRpc: toasts.includes("server RPC"),
|
|
1869
|
+
prefs: store.map.get(UI_PREFS_KEY) || null,
|
|
1870
|
+
}}));
|
|
1871
|
+
"""
|
|
1872
|
+
)
|
|
1873
|
+
self.assertEqual(res.returncode, 0, f"rpc wizard failed: {res.stderr}")
|
|
1874
|
+
data = last_json_object(res.stdout)
|
|
1875
|
+
self.assertTrue(data["ok"])
|
|
1876
|
+
self.assertTrue(data["written"])
|
|
1877
|
+
self.assertEqual(len(data["setCalls"]), 1)
|
|
1878
|
+
self.assertEqual(data["setCalls"][0]["updates"]["search_engine"], "brave")
|
|
1879
|
+
self.assertEqual(data["setCalls"][0]["expectedHash"], "h0")
|
|
1880
|
+
self.assertEqual(data["hash"], "hash1")
|
|
1881
|
+
# Nothing written to disk: the RPC is the write path.
|
|
1882
|
+
self.assertEqual(data["files"], [])
|
|
1883
|
+
self.assertFalse(data["degraded"])
|
|
1884
|
+
self.assertTrue(data["viaRpc"])
|
|
1885
|
+
# UI prefs recorded the save; nothing else.
|
|
1886
|
+
self.assertIn("lastSavedAt", data["prefs"])
|
|
1887
|
+
|
|
1888
|
+
def test_wizard_falls_back_without_client_with_toast(self):
|
|
1889
|
+
# C2 (fallback leg): no `client.rpc` -> direct-fs write + toast, same
|
|
1890
|
+
# validation and hash guard as before.
|
|
1891
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1892
|
+
res = run_node(
|
|
1893
|
+
RPC_TUI_HARNESS
|
|
1894
|
+
+ f"""
|
|
1895
|
+
const root = {tmp!r};
|
|
1896
|
+
const h = dialogHost({{ select: ["search_engine", "brave", "__iumbtems_save__"], confirm: [true] }});
|
|
1897
|
+
const host = {{ directory: root, ui: h.ui }};
|
|
1898
|
+
const r = await openSettingsWizard(host, root, {{ env: {{}} }});
|
|
1899
|
+
const saved = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
|
|
1900
|
+
const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
1901
|
+
console.log(JSON.stringify({{
|
|
1902
|
+
ok: r.ok, written: r.written,
|
|
1903
|
+
engine: saved.search_engine,
|
|
1904
|
+
degraded: toasts.includes("degraded path"),
|
|
1905
|
+
}}));
|
|
1906
|
+
"""
|
|
1907
|
+
)
|
|
1908
|
+
self.assertEqual(res.returncode, 0, f"fallback failed: {res.stderr}")
|
|
1909
|
+
data = last_json_object(res.stdout)
|
|
1910
|
+
self.assertTrue(data["ok"])
|
|
1911
|
+
self.assertTrue(data["written"])
|
|
1912
|
+
self.assertEqual(data["engine"], "brave")
|
|
1913
|
+
self.assertTrue(data["degraded"])
|
|
1914
|
+
|
|
1915
|
+
def test_wizard_rpc_stale_maps_to_stale_result(self):
|
|
1916
|
+
# C3 at the TUI: a stale RPC write surfaces the stale outcome and
|
|
1917
|
+
# writes nothing anywhere.
|
|
1918
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1919
|
+
res = run_node(
|
|
1920
|
+
RPC_TUI_HARNESS
|
|
1921
|
+
+ f"""
|
|
1922
|
+
const root = {tmp!r};
|
|
1923
|
+
const state = rpcState({{ search_engine: "duckduckgo" }}, "h0");
|
|
1924
|
+
state.staleAlways = true;
|
|
1925
|
+
const h = dialogHost({{ select: ["search_engine", "brave", "__iumbtems_save__"], confirm: [true] }});
|
|
1926
|
+
const host = {{
|
|
1927
|
+
directory: root, client: {{ rpc: (def) => rpcClient(state) }}, ui: h.ui,
|
|
1928
|
+
}};
|
|
1929
|
+
const r = await openSettingsWizard(host, root, {{ env: {{}} }});
|
|
1930
|
+
const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
1931
|
+
let files = [];
|
|
1932
|
+
try {{ files = fs.readdirSync(root); }} catch {{ files = []; }}
|
|
1933
|
+
console.log(JSON.stringify({{
|
|
1934
|
+
ok: r.ok, reason: r.reason, written: r.written,
|
|
1935
|
+
staleToast: toasts.includes("refusing the stale write"),
|
|
1936
|
+
files,
|
|
1937
|
+
}}));
|
|
1938
|
+
"""
|
|
1939
|
+
)
|
|
1940
|
+
self.assertEqual(res.returncode, 0, f"stale wizard failed: {res.stderr}")
|
|
1941
|
+
data = last_json_object(res.stdout)
|
|
1942
|
+
self.assertFalse(data["ok"])
|
|
1943
|
+
self.assertEqual(data["reason"], "stale")
|
|
1944
|
+
self.assertFalse(data["written"])
|
|
1945
|
+
self.assertTrue(data["staleToast"])
|
|
1946
|
+
self.assertEqual(data["files"], [])
|
|
1947
|
+
|
|
1948
|
+
def test_panel_prefers_rpc_snapshot_without_writing(self):
|
|
1949
|
+
# The panel never writes: seed a real file (the single-read snapshot
|
|
1950
|
+
# serves it) and prove the bytes are untouched.
|
|
1951
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1952
|
+
research = Path(tmp) / ".research"
|
|
1953
|
+
research.mkdir()
|
|
1954
|
+
seed = json.dumps({"mode": "audit", "search_engine": "brave"})
|
|
1955
|
+
research.joinpath("config.json").write_text(seed, encoding="utf-8")
|
|
1956
|
+
res = run_node(
|
|
1957
|
+
RPC_TUI_HARNESS
|
|
1958
|
+
+ f"""
|
|
1959
|
+
const root = {tmp!r};
|
|
1960
|
+
const state = rpcState({{ mode: "audit", search_engine: "brave" }}, "h0");
|
|
1961
|
+
const shown = [];
|
|
1962
|
+
const host = {{
|
|
1963
|
+
directory: root, client: {{ rpc: (def) => rpcClient(state) }},
|
|
1964
|
+
ui: {{ panel: async (p) => shown.push(p) }},
|
|
1965
|
+
}};
|
|
1966
|
+
const r = await openSettingsPanel(host, root, {{ env: {{}} }});
|
|
1967
|
+
const after = fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8");
|
|
1968
|
+
console.log(JSON.stringify({{
|
|
1969
|
+
ok: r.ok, degraded: r.degraded, body: shown[0].body,
|
|
1970
|
+
intact: after === {seed!r},
|
|
1971
|
+
}}));
|
|
1972
|
+
"""
|
|
1973
|
+
)
|
|
1974
|
+
self.assertEqual(res.returncode, 0, f"rpc panel failed: {res.stderr}")
|
|
1975
|
+
data = last_json_object(res.stdout)
|
|
1976
|
+
self.assertTrue(data["ok"])
|
|
1977
|
+
self.assertFalse(data["degraded"])
|
|
1978
|
+
self.assertIn("audit", data["body"])
|
|
1979
|
+
self.assertIn("Operating Mode", data["body"])
|
|
1980
|
+
self.assertIn("applies to next swarm run", data["body"])
|
|
1981
|
+
self.assertTrue(data["intact"])
|
|
1982
|
+
|
|
1983
|
+
def test_panel_rpc_path_provenance_distinguishes_sources(self):
|
|
1984
|
+
# R1 (the shipped defect): on the PREFERRED RPC path, provenance must be
|
|
1985
|
+
# default | file | env-override, derived from the RPC `raw` (on-disk)
|
|
1986
|
+
# rather than the merged effective `config`. Pre-fix, every row was
|
|
1987
|
+
# `[file]` and `[default]` was unreachable ({file: 18}).
|
|
1988
|
+
res = run_node(
|
|
1989
|
+
RPC_TUI_HARNESS
|
|
1990
|
+
+ """
|
|
1991
|
+
const state = rpcState({ search_engine: "brave" }, "h0");
|
|
1992
|
+
const shown = [];
|
|
1993
|
+
const host = {
|
|
1994
|
+
directory: "/tmp/oc-rpc-prov",
|
|
1995
|
+
client: { rpc: (def) => rpcClient(state) },
|
|
1996
|
+
ui: { panel: async (p) => shown.push(p) },
|
|
1997
|
+
};
|
|
1998
|
+
const r = await openSettingsPanel(host, "/tmp/oc-rpc-prov", {
|
|
1999
|
+
env: { IUMBTEMS_SEARCH_TIMEOUT_S: "9" },
|
|
2000
|
+
});
|
|
2001
|
+
const body = shown[0].body;
|
|
2002
|
+
const rowOf = (key) => body.split("\\n").find((l) => l.includes("(" + key + ")"));
|
|
2003
|
+
console.log(JSON.stringify({
|
|
2004
|
+
ok: r.ok,
|
|
2005
|
+
searchEngine: rowOf("search_engine"),
|
|
2006
|
+
mode: rowOf("mode"),
|
|
2007
|
+
timeout: rowOf("search_timeout_s"),
|
|
2008
|
+
fileCount: (body.match(/\\[file\\]/g) || []).length,
|
|
2009
|
+
defaultCount: (body.match(/\\[default\\]/g) || []).length,
|
|
2010
|
+
envCount: (body.match(/\\[env-override\\]/g) || []).length,
|
|
2011
|
+
}));
|
|
2012
|
+
"""
|
|
2013
|
+
)
|
|
2014
|
+
self.assertEqual(res.returncode, 0, f"rpc provenance failed: {res.stderr}")
|
|
2015
|
+
d = last_json_object(res.stdout)
|
|
2016
|
+
self.assertTrue(d["ok"])
|
|
2017
|
+
# The on-disk file sets only `search_engine` -> `file`, and exactly one row.
|
|
2018
|
+
self.assertIn("[file]", d["searchEngine"])
|
|
2019
|
+
self.assertEqual(d["fileCount"], 1)
|
|
2020
|
+
# `default` is REACHABLE for keys the on-disk file does not set.
|
|
2021
|
+
self.assertIn("[default]", d["mode"])
|
|
2022
|
+
self.assertGreater(d["defaultCount"], 0)
|
|
2023
|
+
# env-override wins for the mapped key.
|
|
2024
|
+
self.assertIn("[env-override]", d["timeout"])
|
|
2025
|
+
self.assertEqual(d["envCount"], 1)
|
|
2026
|
+
|
|
2027
|
+
def test_panel_rpc_path_warns_on_malformed_config(self):
|
|
2028
|
+
# R10: the read-only panel must warn on a malformed config (the wizard
|
|
2029
|
+
# already does), never render silent defaults, never throw, never write.
|
|
2030
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2031
|
+
research = Path(tmp) / ".research"
|
|
2032
|
+
research.mkdir()
|
|
2033
|
+
garbage = "{oops not json"
|
|
2034
|
+
research.joinpath("config.json").write_text(garbage, encoding="utf-8")
|
|
2035
|
+
res = run_node(
|
|
2036
|
+
RPC_TUI_HARNESS
|
|
2037
|
+
+ f"""
|
|
2038
|
+
const root = {tmp!r};
|
|
2039
|
+
const state = rpcState({{}}, null);
|
|
2040
|
+
const shown = [];
|
|
2041
|
+
const host = {{
|
|
2042
|
+
directory: root, client: {{ rpc: (def) => rpcClient(state) }},
|
|
2043
|
+
ui: {{ panel: async (p) => shown.push(p) }},
|
|
2044
|
+
}};
|
|
2045
|
+
const r = await openSettingsPanel(host, root, {{ env: {{}} }});
|
|
2046
|
+
const body = (shown[0] && shown[0].body) || "";
|
|
2047
|
+
const after = fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8");
|
|
2048
|
+
console.log(JSON.stringify({{
|
|
2049
|
+
ok: r.ok,
|
|
2050
|
+
warns: body.includes("Could not parse"),
|
|
2051
|
+
hasRows: body.includes("effective values"),
|
|
2052
|
+
intact: after === {garbage!r},
|
|
2053
|
+
}}));
|
|
2054
|
+
"""
|
|
2055
|
+
)
|
|
2056
|
+
self.assertEqual(res.returncode, 0, f"panel malformed failed: {res.stderr}")
|
|
2057
|
+
data = last_json_object(res.stdout)
|
|
2058
|
+
self.assertTrue(data["ok"])
|
|
2059
|
+
self.assertTrue(data["warns"])
|
|
2060
|
+
self.assertTrue(data["hasRows"])
|
|
2061
|
+
self.assertTrue(data["intact"])
|
|
2062
|
+
|
|
2063
|
+
def test_panel_rpc_path_no_notice_for_valid_config(self):
|
|
2064
|
+
# R10 companion: a valid config renders NO malformed notice.
|
|
2065
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2066
|
+
research = Path(tmp) / ".research"
|
|
2067
|
+
research.mkdir()
|
|
2068
|
+
seed = json.dumps({"mode": "audit"})
|
|
2069
|
+
research.joinpath("config.json").write_text(seed, encoding="utf-8")
|
|
2070
|
+
res = run_node(
|
|
2071
|
+
RPC_TUI_HARNESS
|
|
2072
|
+
+ f"""
|
|
2073
|
+
const root = {tmp!r};
|
|
2074
|
+
const state = rpcState({{ mode: "audit" }}, "h0");
|
|
2075
|
+
const shown = [];
|
|
2076
|
+
const host = {{
|
|
2077
|
+
directory: root, client: {{ rpc: (def) => rpcClient(state) }},
|
|
2078
|
+
ui: {{ panel: async (p) => shown.push(p) }},
|
|
2079
|
+
}};
|
|
2080
|
+
const r = await openSettingsPanel(host, root, {{ env: {{}} }});
|
|
2081
|
+
const body = (shown[0] && shown[0].body) || "";
|
|
2082
|
+
console.log(JSON.stringify({{
|
|
2083
|
+
ok: r.ok,
|
|
2084
|
+
warns: body.includes("Could not parse"),
|
|
2085
|
+
hasMode: body.includes("audit"),
|
|
2086
|
+
}}));
|
|
2087
|
+
"""
|
|
2088
|
+
)
|
|
2089
|
+
self.assertEqual(res.returncode, 0, f"panel valid failed: {res.stderr}")
|
|
2090
|
+
data = last_json_object(res.stdout)
|
|
2091
|
+
self.assertTrue(data["ok"])
|
|
2092
|
+
self.assertFalse(data["warns"])
|
|
2093
|
+
self.assertTrue(data["hasMode"])
|
|
2094
|
+
|
|
2095
|
+
def test_wizard_rpc_path_warns_on_malformed_config(self):
|
|
2096
|
+
# Phase-03 R6 (phase-02 B2 on the primary path): corrupt config with
|
|
2097
|
+
# an RPC-preferred host warns instead of rendering silent defaults.
|
|
2098
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2099
|
+
research = Path(tmp) / ".research"
|
|
2100
|
+
research.mkdir()
|
|
2101
|
+
garbage = "{oops not json"
|
|
2102
|
+
research.joinpath("config.json").write_text(garbage, encoding="utf-8")
|
|
2103
|
+
res = run_node(
|
|
2104
|
+
RPC_TUI_HARNESS
|
|
2105
|
+
+ f"""
|
|
2106
|
+
const root = {tmp!r};
|
|
2107
|
+
const state = rpcState({{}}, null);
|
|
2108
|
+
const h = dialogHost({{ select: ["__iumbtems_cancel__"] }});
|
|
2109
|
+
const host = {{
|
|
2110
|
+
directory: root, client: {{ rpc: (def) => rpcClient(state) }}, ui: h.ui,
|
|
2111
|
+
}};
|
|
2112
|
+
const r = await openSettingsWizard(host, root, {{ env: {{}} }});
|
|
2113
|
+
const toasts = h.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
2114
|
+
console.log(JSON.stringify({{
|
|
2115
|
+
cancelled: r.cancelled, written: r.written,
|
|
2116
|
+
warns: toasts.includes("Could not parse"),
|
|
2117
|
+
intact: fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8") === {garbage!r},
|
|
2118
|
+
}}));
|
|
2119
|
+
"""
|
|
2120
|
+
)
|
|
2121
|
+
self.assertEqual(res.returncode, 0, f"malformed rpc failed: {res.stderr}")
|
|
2122
|
+
data = last_json_object(res.stdout)
|
|
2123
|
+
self.assertTrue(data["cancelled"])
|
|
2124
|
+
self.assertFalse(data["written"])
|
|
2125
|
+
self.assertTrue(data["warns"])
|
|
2126
|
+
self.assertTrue(data["intact"])
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
class TestChangedSubscription(unittest.TestCase):
|
|
2130
|
+
def test_changed_event_reaches_subscriber_and_poll_floor_held(self):
|
|
2131
|
+
res = run_node(
|
|
2132
|
+
RPC_TUI_HARNESS
|
|
2133
|
+
+ """
|
|
2134
|
+
const state = rpcState({}, "h0");
|
|
2135
|
+
const withRpc = { client: { rpc: (def) => rpcClient(state) } };
|
|
2136
|
+
const seen = [];
|
|
2137
|
+
const unsub = subscribeSettingsChanged(withRpc, (e) => seen.push(e));
|
|
2138
|
+
state.subs[0].fn({ hash: "hash9", keys: ["mode"] });
|
|
2139
|
+
if (typeof unsub === "function") unsub();
|
|
2140
|
+
const withoutRpc = subscribeSettingsChanged({ ui: {} }, () => {});
|
|
2141
|
+
console.log(JSON.stringify({
|
|
2142
|
+
subscribed: state.subs.map((s) => s.name),
|
|
2143
|
+
seen,
|
|
2144
|
+
unsubbed: state.unsubbed,
|
|
2145
|
+
bare: withoutRpc,
|
|
2146
|
+
pollMs: SETTINGS_POLL_MS,
|
|
2147
|
+
rpcId: SettingsRpc.id,
|
|
2148
|
+
}));
|
|
2149
|
+
"""
|
|
2150
|
+
)
|
|
2151
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
2152
|
+
data = last_json_object(res.stdout)
|
|
2153
|
+
# Subscribed with the local event name; the received payload arrives.
|
|
2154
|
+
self.assertEqual(data["subscribed"], ["changed"])
|
|
2155
|
+
self.assertEqual(data["seen"], [{"hash": "hash9", "keys": ["mode"]}])
|
|
2156
|
+
self.assertTrue(data["unsubbed"])
|
|
2157
|
+
# No RPC -> null (the 5s mtime poll remains the floor).
|
|
2158
|
+
self.assertIsNone(data["bare"])
|
|
2159
|
+
self.assertEqual(data["pollMs"], 5000)
|
|
2160
|
+
self.assertEqual(data["rpcId"], "iumbtems.settings")
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
class TestUiPrefsBoundary(unittest.TestCase):
|
|
2164
|
+
"""C4 (TUI half): `storage.store` prefs never affect run-affecting values."""
|
|
2165
|
+
|
|
2166
|
+
def test_prefs_round_trip_without_touching_config(self):
|
|
2167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2168
|
+
research = Path(tmp) / ".research"
|
|
2169
|
+
research.mkdir()
|
|
2170
|
+
research.joinpath("config.json").write_text(
|
|
2171
|
+
json.dumps({"mode": "research"}), encoding="utf-8"
|
|
2172
|
+
)
|
|
2173
|
+
res = run_node(
|
|
2174
|
+
RPC_TUI_HARNESS
|
|
2175
|
+
+ f"""
|
|
2176
|
+
const root = {tmp!r};
|
|
2177
|
+
const store = tuiStore();
|
|
2178
|
+
const host = {{
|
|
2179
|
+
directory: root, storage: store.storage,
|
|
2180
|
+
ui: {{ panel: async (p) => {{ host._panel = p; }} }},
|
|
2181
|
+
}};
|
|
2182
|
+
// Prefs deliberately named like run-affecting keys.
|
|
2183
|
+
const wrote = await writeUiPrefs(host, {{ mode: "audit", search_engine: "evil", lastSavedAt: "x" }});
|
|
2184
|
+
const prefs = readUiPrefs(host);
|
|
2185
|
+
const r = await openSettingsPanel(host, root, {{ env: {{}} }});
|
|
2186
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
|
|
2187
|
+
console.log(JSON.stringify({{
|
|
2188
|
+
wrote, prefs, body: host._panel.body, onDisk,
|
|
2189
|
+
bare: readUiPrefs({{ ui: {{}} }}),
|
|
2190
|
+
}}));
|
|
2191
|
+
"""
|
|
2192
|
+
)
|
|
2193
|
+
self.assertEqual(res.returncode, 0, f"prefs failed: {res.stderr}")
|
|
2194
|
+
data = last_json_object(res.stdout)
|
|
2195
|
+
self.assertTrue(data["wrote"])
|
|
2196
|
+
self.assertEqual(data["prefs"]["mode"], "audit")
|
|
2197
|
+
# Effective values still come from the file, never the prefs.
|
|
2198
|
+
self.assertIn("research", data["body"])
|
|
2199
|
+
self.assertNotIn("evil", data["body"])
|
|
2200
|
+
self.assertEqual(data["onDisk"], {"mode": "research"})
|
|
2201
|
+
# No TUI storage -> {} (never throws).
|
|
2202
|
+
self.assertEqual(data["bare"], {})
|
|
2203
|
+
|
|
2204
|
+
|
|
1750
2205
|
if __name__ == "__main__":
|
|
1751
2206
|
unittest.main()
|