@smilintux/skcapstone 0.10.0 → 0.12.5
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/.env.example +10 -4
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +9 -2
- package/.openclaw-workspace.json +2 -2
- package/CLAUDE.md +37 -0
- package/MISSION.md +17 -2
- package/README.md +282 -3
- package/docker/Dockerfile +7 -7
- package/docker/compose-templates/dev-team.yml +12 -12
- package/docker/compose-templates/mini-team.yml +9 -9
- package/docker/compose-templates/ops-team.yml +10 -10
- package/docker/compose-templates/research-team.yml +10 -10
- package/docker/entrypoint.sh +4 -4
- package/docs/ADR-optional-integration-backbone.md +181 -0
- package/docs/ARCHITECTURE.md +186 -43
- package/docs/BOND_WITH_GROK.md +6 -6
- package/docs/CUSTOM_AGENT.md +123 -30
- package/docs/DREAMING.md +70 -0
- package/docs/GETTING_STARTED.md +7 -7
- package/docs/QUICKSTART.md +10 -6
- package/docs/SKJOULE_ARCHITECTURE.md +3 -3
- package/docs/SOUL_SWAPPER.md +5 -5
- package/docs/hammertime-audit.md +402 -0
- package/docs/sk-integration-HANDOFF.md +117 -0
- package/docs/skscheduler.md +155 -0
- package/docs/superpowers/examples/jobs.yaml +31 -0
- package/docs/superpowers/plans/2026-06-08-skscheduler.md +1265 -0
- package/docs/superpowers/specs/2026-06-08-skscheduler-design.md +186 -0
- package/examples/custom-bond-template.json +1 -1
- package/examples/grok-feb.json +1 -1
- package/examples/queen-ava-feb.json +1 -1
- package/launchd/{com.skcapstone.skcomm-heartbeat.plist → com.skcapstone.skcomms-heartbeat.plist} +4 -4
- package/launchd/{com.skcapstone.skcomm-queue-drain.plist → com.skcapstone.skcomms-queue-drain.plist} +4 -4
- package/launchd/install-launchd.sh +6 -6
- package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/src/index.ts +3 -2
- package/package.json +1 -1
- package/pyproject.toml +16 -10
- package/scripts/archive-sessions.sh +7 -0
- package/scripts/check-updates.py +4 -4
- package/scripts/install-bundle.sh +8 -8
- package/scripts/install.ps1 +12 -11
- package/scripts/install.sh +159 -5
- package/scripts/model-fallback-monitor.sh +102 -0
- package/scripts/nvidia-proxy.mjs +78 -26
- package/scripts/refresh-anthropic-token.sh +172 -0
- package/scripts/release.sh +98 -0
- package/scripts/session-to-memory.py +219 -0
- package/scripts/skgateway.mjs +3 -3
- package/scripts/telegram-catchup-all.sh +12 -1
- package/scripts/verify_install.sh +2 -2
- package/scripts/wargov-ufo-capture/README.md +43 -0
- package/scripts/wargov-ufo-capture/cdp_capture_release2.py +273 -0
- package/scripts/wargov-ufo-capture/cdp_capture_splc_doj.py +246 -0
- package/scripts/wargov-ufo-capture/cdp_finish.py +271 -0
- package/scripts/wargov-ufo-capture/cdp_probe.py +188 -0
- package/scripts/wargov-ufo-capture/cdp_splc_pressrelease.py +101 -0
- package/scripts/wargov-ufo-capture/parse_csv.py +95 -0
- package/scripts/wargov-ufo-capture/pull_dvids.sh +107 -0
- package/scripts/watch-anthropic-token.sh +212 -0
- package/scripts/windows/install-tasks.ps1 +7 -7
- package/scripts/windows/skcapstone-task.xml +1 -1
- package/src/skcapstone/__init__.py +45 -3
- package/src/skcapstone/_cli_monolith.py +20 -15
- package/src/skcapstone/activity.py +5 -1
- package/src/skcapstone/agent_card.py +3 -2
- package/src/skcapstone/api.py +41 -40
- package/src/skcapstone/auction.py +14 -11
- package/src/skcapstone/backup.py +2 -1
- package/src/skcapstone/blueprint_registry.py +4 -3
- package/src/skcapstone/brain_first.py +238 -0
- package/src/skcapstone/changelog.py +1 -1
- package/src/skcapstone/chat.py +22 -17
- package/src/skcapstone/cli/__init__.py +9 -1
- package/src/skcapstone/cli/_common.py +1 -0
- package/src/skcapstone/cli/agents_spawner.py +5 -2
- package/src/skcapstone/cli/alerts.py +25 -4
- package/src/skcapstone/cli/bench.py +15 -15
- package/src/skcapstone/cli/chat.py +7 -4
- package/src/skcapstone/cli/consciousness.py +5 -2
- package/src/skcapstone/cli/context_cmd.py +18 -4
- package/src/skcapstone/cli/daemon.py +11 -7
- package/src/skcapstone/cli/gtd.py +26 -1
- package/src/skcapstone/cli/housekeeping.py +3 -3
- package/src/skcapstone/cli/identity_cmd.py +378 -0
- package/src/skcapstone/cli/joule_cmd.py +7 -3
- package/src/skcapstone/cli/memory.py +8 -6
- package/src/skcapstone/cli/peers_dir.py +1 -1
- package/src/skcapstone/cli/register_cmd.py +29 -3
- package/src/skcapstone/cli/scheduler_cmd.py +167 -0
- package/src/skcapstone/cli/session.py +25 -0
- package/src/skcapstone/cli/setup.py +96 -29
- package/src/skcapstone/cli/shell_cmd.py +53 -1
- package/src/skcapstone/cli/skills_cmd.py +2 -2
- package/src/skcapstone/cli/soul.py +8 -5
- package/src/skcapstone/cli/status.py +37 -11
- package/src/skcapstone/cli/telegram.py +21 -0
- package/src/skcapstone/cli/test_cmd.py +5 -5
- package/src/skcapstone/cli/test_connection.py +2 -2
- package/src/skcapstone/cli/upgrade_cmd.py +23 -14
- package/src/skcapstone/cli/version_cmd.py +1 -1
- package/src/skcapstone/cli/watch_cmd.py +9 -6
- package/src/skcapstone/cloud9_bridge.py +14 -14
- package/src/skcapstone/codex_setup.py +255 -0
- package/src/skcapstone/config_validator.py +7 -4
- package/src/skcapstone/consciousness_config.py +5 -1
- package/src/skcapstone/consciousness_loop.py +313 -273
- package/src/skcapstone/context_loader.py +121 -0
- package/src/skcapstone/coord_federation.py +2 -1
- package/src/skcapstone/coordination.py +23 -6
- package/src/skcapstone/crush_integration.py +2 -1
- package/src/skcapstone/daemon.py +132 -77
- package/src/skcapstone/dashboard.py +10 -10
- package/src/skcapstone/data/sk-agent-picker.sh +421 -0
- package/src/skcapstone/data/systemd/skcapstone-api.socket +9 -0
- package/src/skcapstone/data/systemd/skcapstone-memory-compress.service +18 -0
- package/src/skcapstone/data/systemd/skcapstone-memory-compress.timer +11 -0
- package/src/skcapstone/data/systemd/skcapstone.service +37 -0
- package/src/skcapstone/data/systemd/skcapstone@.service +50 -0
- package/src/skcapstone/data/systemd/skcomms-heartbeat.service +18 -0
- package/{systemd/skcomm-heartbeat.timer → src/skcapstone/data/systemd/skcomms-heartbeat.timer} +2 -2
- package/src/skcapstone/data/systemd/skcomms-queue-drain.service +17 -0
- package/{systemd/skcomm-queue-drain.timer → src/skcapstone/data/systemd/skcomms-queue-drain.timer} +2 -2
- package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
- package/src/skcapstone/defaults/claude/settings.json +74 -0
- package/src/skcapstone/defaults/lumina/config/claude-hooks.md +57 -0
- package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
- package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
- package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +2 -2
- package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +2 -2
- package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +9 -9
- package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +2 -2
- package/src/skcapstone/defaults/unhinged.json +13 -0
- package/src/skcapstone/discovery.py +43 -20
- package/src/skcapstone/doctor.py +941 -22
- package/src/skcapstone/dreaming.py +1183 -109
- package/src/skcapstone/emotion_tracker.py +2 -2
- package/src/skcapstone/export.py +4 -3
- package/src/skcapstone/fuse_mount.py +14 -12
- package/src/skcapstone/gui_installer.py +2 -2
- package/src/skcapstone/heartbeat.py +1 -1
- package/src/skcapstone/housekeeping.py +14 -14
- package/src/skcapstone/install_wizard.py +209 -7
- package/src/skcapstone/itil.py +13 -4
- package/src/skcapstone/kms_scheduler.py +10 -8
- package/src/skcapstone/launchd.py +19 -19
- package/src/skcapstone/mcp_launcher.py +15 -1
- package/src/skcapstone/mcp_server.py +83 -49
- package/src/skcapstone/mcp_tools/__init__.py +2 -0
- package/src/skcapstone/mcp_tools/_helpers.py +2 -2
- package/src/skcapstone/mcp_tools/ansible_tools.py +7 -4
- package/src/skcapstone/mcp_tools/brain_first_tools.py +90 -0
- package/src/skcapstone/mcp_tools/capauth_tools.py +7 -4
- package/src/skcapstone/mcp_tools/comm_tools.py +10 -10
- package/src/skcapstone/mcp_tools/coord_tools.py +8 -4
- package/src/skcapstone/mcp_tools/did_tools.py +11 -8
- package/src/skcapstone/mcp_tools/gtd_tools.py +4 -4
- package/src/skcapstone/mcp_tools/memory_tools.py +6 -2
- package/src/skcapstone/mcp_tools/notification_tools.py +22 -6
- package/src/skcapstone/mcp_tools/{skcomm_tools.py → skcomms_tools.py} +14 -14
- package/src/skcapstone/mcp_tools/soul_tools.py +8 -2
- package/src/skcapstone/mdns_discovery.py +2 -2
- package/src/skcapstone/memory_curator.py +1 -1
- package/src/skcapstone/memory_engine.py +10 -3
- package/src/skcapstone/metrics.py +30 -16
- package/src/skcapstone/migrate_memories.py +4 -3
- package/src/skcapstone/migrate_multi_agent.py +8 -7
- package/src/skcapstone/models.py +47 -5
- package/src/skcapstone/notifications.py +42 -18
- package/src/skcapstone/onboard.py +875 -121
- package/src/skcapstone/operator_link.py +170 -0
- package/src/skcapstone/peer_directory.py +4 -4
- package/src/skcapstone/peers.py +19 -19
- package/src/skcapstone/pillars/__init__.py +7 -5
- package/src/skcapstone/pillars/consciousness.py +191 -0
- package/src/skcapstone/pillars/identity.py +51 -7
- package/src/skcapstone/pillars/memory.py +9 -3
- package/src/skcapstone/pillars/sync.py +2 -2
- package/src/skcapstone/preflight.py +3 -3
- package/src/skcapstone/providers/docker.py +28 -28
- package/src/skcapstone/register.py +6 -6
- package/src/skcapstone/registry_client.py +5 -4
- package/src/skcapstone/runtime.py +14 -3
- package/src/skcapstone/scheduled_tasks.py +254 -19
- package/src/skcapstone/scheduler_jobs.py +456 -0
- package/src/skcapstone/scheduler_runner.py +239 -0
- package/src/skcapstone/scheduler_state.py +162 -0
- package/src/skcapstone/sdk.py +310 -0
- package/src/skcapstone/service_health.py +279 -39
- package/src/skcapstone/session_briefing.py +108 -0
- package/src/skcapstone/session_capture.py +1 -1
- package/src/skcapstone/shell.py +7 -1
- package/src/skcapstone/soul.py +3 -1
- package/src/skcapstone/soul_switch.py +3 -1
- package/src/skcapstone/summary.py +6 -6
- package/src/skcapstone/sync_engine.py +15 -15
- package/src/skcapstone/sync_watcher.py +2 -2
- package/src/skcapstone/systemd.py +55 -21
- package/src/skcapstone/team_comms.py +8 -8
- package/src/skcapstone/team_engine.py +1 -1
- package/src/skcapstone/testrunner.py +3 -3
- package/src/skcapstone/trust_graph.py +40 -5
- package/src/skcapstone/unified_search.py +15 -6
- package/src/skcapstone/uninstall_wizard.py +11 -3
- package/src/skcapstone/version_check.py +8 -4
- package/src/skcapstone/warmth_anchor.py +4 -2
- package/src/skcapstone/whoami.py +4 -4
- package/systemd/skcapstone.service +4 -6
- package/systemd/skcapstone@.service +7 -8
- package/systemd/skcomms-heartbeat.service +21 -0
- package/systemd/skcomms-heartbeat.timer +12 -0
- package/systemd/skcomms-queue-drain.service +17 -0
- package/systemd/skcomms-queue-drain.timer +12 -0
- package/tests/conftest.py +39 -0
- package/tests/integration/test_consciousness_e2e.py +39 -39
- package/tests/test_agent_card.py +1 -1
- package/tests/test_agent_home_scaffold.py +34 -0
- package/tests/test_alerts_consumer_topics.py +27 -0
- package/tests/test_backup.py +2 -1
- package/tests/test_chat.py +6 -6
- package/tests/test_claude_md.py +2 -2
- package/tests/test_cli_skills.py +10 -10
- package/tests/test_cli_test_cmd.py +4 -4
- package/tests/test_cli_test_connection.py +1 -1
- package/tests/test_cloud9_bridge.py +6 -6
- package/tests/test_consciousness_e2e.py +1 -1
- package/tests/test_consciousness_loop.py +10 -10
- package/tests/test_coordination.py +25 -0
- package/tests/test_cross_package.py +21 -21
- package/tests/test_daemon.py +4 -4
- package/tests/test_daemon_shutdown.py +1 -1
- package/tests/test_docker_provider.py +29 -29
- package/tests/test_doctor.py +400 -0
- package/tests/test_doctor_skscheduler.py +50 -0
- package/tests/test_dreaming_engine.py +147 -0
- package/tests/test_dreaming_gtd_capture.py +35 -0
- package/tests/test_e2e_automated.py +8 -5
- package/tests/test_fuse_mount.py +10 -10
- package/tests/test_gtd_brief.py +46 -0
- package/tests/test_gtd_malformed_tolerance.py +31 -0
- package/tests/test_housekeeping.py +15 -15
- package/tests/test_identity_migrate.py +251 -0
- package/tests/test_integration_backbone.py +598 -0
- package/tests/test_itil_gtd_lifecycle.py +37 -0
- package/tests/test_jobs_dropins.py +84 -0
- package/tests/test_mcp_server.py +82 -37
- package/tests/test_models.py +48 -4
- package/tests/test_multi_agent.py +31 -29
- package/tests/test_notifications.py +122 -32
- package/tests/test_onboard.py +63 -75
- package/tests/test_operator_link.py +78 -0
- package/tests/test_peers.py +14 -14
- package/tests/test_pillars.py +98 -0
- package/tests/test_preflight.py +3 -3
- package/tests/test_runtime.py +21 -0
- package/tests/test_scheduled_tasks.py +11 -6
- package/tests/test_scheduler_cli.py +47 -0
- package/tests/test_scheduler_features.py +133 -0
- package/tests/test_scheduler_integration.py +87 -0
- package/tests/test_scheduler_jobs.py +155 -0
- package/tests/test_scheduler_runner.py +64 -0
- package/tests/test_scheduler_state.py +57 -0
- package/tests/test_sdk.py +70 -0
- package/tests/test_service_health_incidents.py +34 -0
- package/tests/test_service_registry.py +52 -0
- package/tests/test_session_briefing.py +130 -0
- package/tests/test_snapshots.py +4 -4
- package/tests/test_sync_pipeline.py +26 -26
- package/tests/test_team_comms.py +2 -2
- package/tests/test_testrunner.py +2 -2
- package/tests/test_trust_graph.py +18 -0
- package/tests/test_unified_search.py +2 -2
- package/tests/test_version_check.py +10 -0
- package/tests/test_version_cmd.py +8 -8
- package/tests/test_whoami.py +1 -1
- package/systemd/skcomm-heartbeat.service +0 -18
- package/systemd/skcomm-queue-drain.service +0 -17
- /package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/package.json +0 -0
- /package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/src/openclaw.plugin.json +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Node-local (never-synced) state for the skscheduler.
|
|
2
|
+
|
|
3
|
+
This module provides per-host, per-job run state that is intentionally kept
|
|
4
|
+
node-local so it never becomes a Syncthing conflict source. State is stored
|
|
5
|
+
at ``<root>/scheduler/<hostname>/state.json`` and is never replicated.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("skcapstone.scheduler_state")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SchedulerState:
|
|
20
|
+
"""Per-host job state persisted at ``<root>/scheduler/<hostname>/state.json``.
|
|
21
|
+
|
|
22
|
+
State is deliberately node-local: the file lives outside any Syncthing-
|
|
23
|
+
watched subtree so the scheduler never races with sync. Each instance
|
|
24
|
+
reads from disk on construction and writes through on every
|
|
25
|
+
:meth:`record_run` call — there is no in-process cache staleness issue
|
|
26
|
+
because schedulers are single-process per host.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
state_file: Absolute path to the JSON state file for this host.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, root: Path, hostname: str) -> None:
|
|
33
|
+
"""Initialise state for ``hostname`` rooted at ``root``.
|
|
34
|
+
|
|
35
|
+
Reads any existing state from disk. If the file is absent or
|
|
36
|
+
unreadable the state is treated as empty rather than raising.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
root: Repository (or data) root directory. The state file will
|
|
40
|
+
be created at ``root/scheduler/<hostname>/state.json``.
|
|
41
|
+
hostname: Identifier for this node (typically
|
|
42
|
+
``socket.gethostname()``). Used as the directory name so
|
|
43
|
+
multiple hosts can share the same ``root`` without collision.
|
|
44
|
+
"""
|
|
45
|
+
self.state_file: Path = Path(root) / "scheduler" / hostname / "state.json"
|
|
46
|
+
self._data: dict[str, dict] = {}
|
|
47
|
+
self._write_lock = threading.Lock()
|
|
48
|
+
if self.state_file.exists():
|
|
49
|
+
try:
|
|
50
|
+
self._data = json.loads(
|
|
51
|
+
self.state_file.read_text(encoding="utf-8")
|
|
52
|
+
)
|
|
53
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
54
|
+
logger.warning(
|
|
55
|
+
"Could not read scheduler state from %s: %s", self.state_file, exc
|
|
56
|
+
)
|
|
57
|
+
self._data = {}
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Public API
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def get(self, job: str) -> dict:
|
|
64
|
+
"""Return the state record for *job*, or a zeroed default.
|
|
65
|
+
|
|
66
|
+
The returned dict always contains at least the keys
|
|
67
|
+
``run_count``, ``error_count``, and ``last_run``. Additional keys
|
|
68
|
+
(``last_status``, ``last_error``) are present once the job has been
|
|
69
|
+
recorded at least once.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
job: Unique job identifier string.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A copy-on-read dict with the job's state. Mutating the returned
|
|
76
|
+
dict does **not** persist anything; call :meth:`record_run` to
|
|
77
|
+
persist changes.
|
|
78
|
+
"""
|
|
79
|
+
return self._data.get(
|
|
80
|
+
job, {"run_count": 0, "error_count": 0, "last_run": None}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def last_run(self, job: str) -> Optional[datetime]:
|
|
84
|
+
"""Return the timestamp of the most recent run of *job*, or ``None``.
|
|
85
|
+
|
|
86
|
+
The returned :class:`~datetime.datetime` is always timezone-aware
|
|
87
|
+
(UTC) because :meth:`record_run` stores ISO-8601 strings with a
|
|
88
|
+
``+00:00`` offset.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
job: Unique job identifier string.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A timezone-aware :class:`~datetime.datetime` if the job has run
|
|
95
|
+
at least once, otherwise ``None``.
|
|
96
|
+
"""
|
|
97
|
+
raw: Optional[str] = self.get(job).get("last_run")
|
|
98
|
+
return datetime.fromisoformat(raw) if raw else None
|
|
99
|
+
|
|
100
|
+
def record_run(
|
|
101
|
+
self,
|
|
102
|
+
job: str,
|
|
103
|
+
now: Optional[datetime] = None,
|
|
104
|
+
ok: bool = True,
|
|
105
|
+
error: str = "",
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Record the result of a job execution and persist to disk.
|
|
108
|
+
|
|
109
|
+
Increments either ``run_count`` (on success) or ``error_count`` (on
|
|
110
|
+
failure) and writes the updated state file atomically via
|
|
111
|
+
:meth:`_flush`.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
job: Unique job identifier string.
|
|
115
|
+
now: Timestamp for the run. Defaults to
|
|
116
|
+
``datetime.now(timezone.utc)`` when not provided.
|
|
117
|
+
ok: ``True`` if the job completed successfully, ``False`` on
|
|
118
|
+
error.
|
|
119
|
+
error: Human-readable error message. Ignored when *ok* is
|
|
120
|
+
``True``; stored as ``last_error`` otherwise.
|
|
121
|
+
"""
|
|
122
|
+
ts: datetime = now or datetime.now(timezone.utc)
|
|
123
|
+
with self._write_lock:
|
|
124
|
+
rec: dict = self.get(job)
|
|
125
|
+
rec["last_run"] = ts.isoformat()
|
|
126
|
+
rec["last_status"] = "ok" if ok else "error"
|
|
127
|
+
rec["last_error"] = "" if ok else error
|
|
128
|
+
rec["run_count"] = rec.get("run_count", 0) + (1 if ok else 0)
|
|
129
|
+
rec["error_count"] = rec.get("error_count", 0) + (0 if ok else 1)
|
|
130
|
+
self._data[job] = rec
|
|
131
|
+
self._flush()
|
|
132
|
+
|
|
133
|
+
def all(self) -> dict[str, dict]:
|
|
134
|
+
"""Return a shallow copy of all job state records.
|
|
135
|
+
|
|
136
|
+
Useful for introspection and dashboards. Mutations to the returned
|
|
137
|
+
dict or its values do not affect persisted state.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A dict mapping job identifier → state record for every job that
|
|
141
|
+
has been recorded at least once.
|
|
142
|
+
"""
|
|
143
|
+
return dict(self._data)
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Private helpers
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _flush(self) -> None:
|
|
150
|
+
"""Write the in-memory state to :attr:`state_file`.
|
|
151
|
+
|
|
152
|
+
Creates parent directories if they do not exist. Writes the full
|
|
153
|
+
state dict as indented JSON followed by a trailing newline so the
|
|
154
|
+
file is human-readable and POSIX-compliant.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
OSError: If the file cannot be written (e.g. permission denied).
|
|
158
|
+
"""
|
|
159
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
self.state_file.write_text(
|
|
161
|
+
json.dumps(self._data, indent=2) + "\n", encoding="utf-8"
|
|
162
|
+
)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""skcapstone.sdk — the stable public integration facade for sk* services.
|
|
2
|
+
|
|
3
|
+
This module is the **only** surface that downstream sk* services
|
|
4
|
+
(skmemory, skcomms/skcomms, skchat, sksecurity, capauth, skvoice, skseed,
|
|
5
|
+
cloud9, skgateway, …) should import. Everything here is semver-tracked and
|
|
6
|
+
will not break across minor releases; the internal modules it wraps
|
|
7
|
+
(``pubsub``, ``scheduler_jobs``, ``coordination``, ``notifications``,
|
|
8
|
+
``service_health``) are NOT part of the public contract and may change freely.
|
|
9
|
+
|
|
10
|
+
The intended consumer pattern is *optional-by-presence, default-on*::
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from skcapstone import sdk as _sk
|
|
14
|
+
_HAS = (not os.environ.get("SK_STANDALONE")) and _sk.is_available()
|
|
15
|
+
except ImportError:
|
|
16
|
+
_sk, _HAS = None, False
|
|
17
|
+
|
|
18
|
+
def alert(topic, payload, level="info"):
|
|
19
|
+
if _HAS:
|
|
20
|
+
return _sk.alert(f"myservice.{topic}", payload, level=level,
|
|
21
|
+
notify=level in ("warn", "error", "critical"))
|
|
22
|
+
return _native_alert(topic, payload, level) # service-native fallback
|
|
23
|
+
|
|
24
|
+
A service that finds skcapstone installed routes alerts through the shared
|
|
25
|
+
PubSub bus and registers scheduled work with the fleet scheduler; a service
|
|
26
|
+
that does not (or that sets ``SK_STANDALONE=1``) keeps using its own
|
|
27
|
+
mechanisms. See ``docs/ADR-optional-integration-backbone.md``.
|
|
28
|
+
|
|
29
|
+
Public API:
|
|
30
|
+
is_available() -> bool
|
|
31
|
+
alert(...) -> bool
|
|
32
|
+
register_job(...) -> str (path)
|
|
33
|
+
unregister_job(...) -> bool
|
|
34
|
+
coord_create(...) -> str (task id)
|
|
35
|
+
register_service(...) -> str (path)
|
|
36
|
+
|
|
37
|
+
Topic naming convention: ``<service>.<severity>`` (e.g. ``skmemory.error``,
|
|
38
|
+
``sksecurity.critical``). Severities: ``info | warn | error | critical``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import uuid
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Optional
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("skcapstone.sdk")
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"is_available",
|
|
55
|
+
"alert",
|
|
56
|
+
"register_job",
|
|
57
|
+
"unregister_job",
|
|
58
|
+
"coord_create",
|
|
59
|
+
"register_service",
|
|
60
|
+
"SEVERITIES",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
#: Recognised alert severities, low → high.
|
|
64
|
+
SEVERITIES = ("info", "warn", "error", "critical")
|
|
65
|
+
|
|
66
|
+
#: Severities that, by convention, also raise a desktop/Telegram notification
|
|
67
|
+
#: when ``notify`` is left at its default.
|
|
68
|
+
_NOTIFY_SEVERITIES = frozenset({"warn", "error", "critical"})
|
|
69
|
+
|
|
70
|
+
#: severity → desktop notification urgency
|
|
71
|
+
_URGENCY = {
|
|
72
|
+
"info": "low",
|
|
73
|
+
"warn": "normal",
|
|
74
|
+
"error": "normal",
|
|
75
|
+
"critical": "critical",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _shared_home() -> Path:
|
|
80
|
+
"""Resolve the shared skcapstone root (~/.skcapstone), honouring env."""
|
|
81
|
+
from . import shared_home # local import keeps facade import cheap
|
|
82
|
+
|
|
83
|
+
return shared_home()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _agent_name() -> str:
|
|
87
|
+
"""Best-effort active agent name, or 'anonymous'."""
|
|
88
|
+
from . import active_agent_name
|
|
89
|
+
|
|
90
|
+
return active_agent_name() or "anonymous"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_available(require_daemon: bool = False) -> bool:
|
|
94
|
+
"""Return whether skcapstone integration is usable from this process.
|
|
95
|
+
|
|
96
|
+
Because the alert bus, scheduler drop-ins and coordination board are all
|
|
97
|
+
file-based, in-process integration does *not* require the daemon to be
|
|
98
|
+
running — it only requires that the shared home is resolvable and
|
|
99
|
+
writable. ``is_available()`` therefore returns ``True`` whenever the
|
|
100
|
+
package imported and the home directory can be created.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
require_daemon: When ``True``, additionally probe the local daemon's
|
|
104
|
+
``/health`` endpoint and only return ``True`` if it answers. Use
|
|
105
|
+
this for capabilities that genuinely need the live daemon (most
|
|
106
|
+
consumers do not).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
``True`` if skcapstone integration can be used, else ``False``.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
home = _shared_home()
|
|
113
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
115
|
+
logger.debug("skcapstone unavailable: %s", exc)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
if not require_daemon:
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
return _daemon_healthy()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _daemon_healthy() -> bool:
|
|
125
|
+
"""Probe the local skcapstone daemon ``/health`` endpoint (best-effort)."""
|
|
126
|
+
import urllib.request
|
|
127
|
+
|
|
128
|
+
port = int(os.environ.get("SKCAPSTONE_PORT", "9383"))
|
|
129
|
+
url = os.environ.get("SKCAPSTONE_DAEMON_URL", f"http://127.0.0.1:{port}") + "/health"
|
|
130
|
+
try:
|
|
131
|
+
with urllib.request.urlopen(url, timeout=1.5) as resp: # noqa: S310 (localhost)
|
|
132
|
+
return 200 <= resp.status < 300
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
logger.debug("daemon health probe failed (%s): %s", url, exc)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def alert(
|
|
139
|
+
topic: str,
|
|
140
|
+
payload: dict[str, Any],
|
|
141
|
+
*,
|
|
142
|
+
level: str = "info",
|
|
143
|
+
notify: Optional[bool] = None,
|
|
144
|
+
ttl_seconds: int = 86400,
|
|
145
|
+
) -> bool:
|
|
146
|
+
"""Publish an alert to the shared bus, optionally raising a notification.
|
|
147
|
+
|
|
148
|
+
The alert is published to the PubSub topic ``topic`` (callers should use
|
|
149
|
+
the ``<service>.<severity>`` convention). When ``notify`` is true — or is
|
|
150
|
+
left ``None`` and ``level`` is warn/error/critical — a desktop/Telegram
|
|
151
|
+
notification is also dispatched via the notification manager.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
topic: Fully-qualified topic, e.g. ``"skmemory.error"``.
|
|
155
|
+
payload: JSON-serialisable event body.
|
|
156
|
+
level: One of :data:`SEVERITIES`. Unknown values are treated as
|
|
157
|
+
``"info"``.
|
|
158
|
+
notify: Force notification on/off. ``None`` (default) means "notify
|
|
159
|
+
iff severity is warn or higher".
|
|
160
|
+
ttl_seconds: Message TTL on the bus (default 24h).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
``True`` if the message was published (notification is best-effort and
|
|
164
|
+
does not affect the return value).
|
|
165
|
+
"""
|
|
166
|
+
if level not in SEVERITIES:
|
|
167
|
+
level = "info"
|
|
168
|
+
|
|
169
|
+
published = False
|
|
170
|
+
try:
|
|
171
|
+
from .pubsub import PubSub
|
|
172
|
+
|
|
173
|
+
bus = PubSub(_shared_home(), agent_name=_agent_name())
|
|
174
|
+
bus.publish(topic, dict(payload), ttl_seconds=ttl_seconds, tags=[level])
|
|
175
|
+
published = True
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
logger.warning("sdk.alert publish failed for %r: %s", topic, exc)
|
|
178
|
+
|
|
179
|
+
should_notify = (level in _NOTIFY_SEVERITIES) if notify is None else bool(notify)
|
|
180
|
+
if should_notify:
|
|
181
|
+
try:
|
|
182
|
+
from .notifications import notify as _desktop_notify
|
|
183
|
+
|
|
184
|
+
summary = payload.get("message") or payload.get("error") or json.dumps(payload)[:200]
|
|
185
|
+
_desktop_notify(f"[{level}] {topic}", str(summary), _URGENCY.get(level, "normal"))
|
|
186
|
+
except Exception as exc: # pragma: no cover - notification is optional
|
|
187
|
+
logger.debug("sdk.alert notify failed: %s", exc)
|
|
188
|
+
|
|
189
|
+
return published
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def register_job(spec: dict[str, Any], home: Optional[Path] = None) -> str:
|
|
193
|
+
"""Register a scheduled job with the fleet scheduler (jobs.d drop-in).
|
|
194
|
+
|
|
195
|
+
Thin wrapper over :func:`skcapstone.scheduler_jobs.register_job`. The
|
|
196
|
+
``spec`` must include a ``name`` and exactly one of ``schedule`` (cron) or
|
|
197
|
+
``every`` (interval, e.g. ``"15m"``). Re-registering the same ``name`` is
|
|
198
|
+
idempotent, so calling this on every service start is the intended usage.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
spec: Job definition (see jobs.yaml schema).
|
|
202
|
+
home: Override skcapstone root (defaults to ~/.skcapstone).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Filesystem path to the written drop-in fragment, as a string.
|
|
206
|
+
"""
|
|
207
|
+
from .scheduler_jobs import register_job as _register_job
|
|
208
|
+
|
|
209
|
+
return str(_register_job(spec, home=home))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def unregister_job(name: str, home: Optional[Path] = None) -> bool:
|
|
213
|
+
"""Remove a previously registered scheduler drop-in.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
name: The job name used at registration.
|
|
217
|
+
home: Override skcapstone root (defaults to ~/.skcapstone).
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
``True`` if a fragment existed and was removed.
|
|
221
|
+
"""
|
|
222
|
+
from .scheduler_jobs import unregister_job as _unregister_job
|
|
223
|
+
|
|
224
|
+
return _unregister_job(name, home=home)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def coord_create(
|
|
228
|
+
title: str,
|
|
229
|
+
*,
|
|
230
|
+
description: str = "",
|
|
231
|
+
priority: str = "medium",
|
|
232
|
+
tags: Optional[list[str]] = None,
|
|
233
|
+
created_by: str = "",
|
|
234
|
+
acceptance_criteria: Optional[list[str]] = None,
|
|
235
|
+
dependencies: Optional[list[str]] = None,
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Create a task on the shared coordination board.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
title: Task title.
|
|
241
|
+
description: Longer description.
|
|
242
|
+
priority: ``critical | high | medium | low``.
|
|
243
|
+
tags: Optional tag list.
|
|
244
|
+
created_by: Creator name (defaults to the active agent).
|
|
245
|
+
acceptance_criteria: Optional acceptance bullet list.
|
|
246
|
+
dependencies: Optional list of blocking task ids.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The new task's id.
|
|
250
|
+
"""
|
|
251
|
+
from .coordination import Board, Task, TaskPriority
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
prio = TaskPriority(priority)
|
|
255
|
+
except ValueError:
|
|
256
|
+
prio = TaskPriority.MEDIUM
|
|
257
|
+
|
|
258
|
+
board = Board(_shared_home())
|
|
259
|
+
task = Task(
|
|
260
|
+
title=title,
|
|
261
|
+
description=description,
|
|
262
|
+
priority=prio,
|
|
263
|
+
tags=tags or [],
|
|
264
|
+
created_by=created_by or _agent_name(),
|
|
265
|
+
acceptance_criteria=acceptance_criteria or [],
|
|
266
|
+
dependencies=dependencies or [],
|
|
267
|
+
)
|
|
268
|
+
board.create_task(task)
|
|
269
|
+
return task.id
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def register_service(
|
|
273
|
+
name: str,
|
|
274
|
+
health_url: Optional[str] = None,
|
|
275
|
+
pid_file: Optional[str] = None,
|
|
276
|
+
home: Optional[Path] = None,
|
|
277
|
+
) -> str:
|
|
278
|
+
"""Advertise a service to skcapstone's discovery registry.
|
|
279
|
+
|
|
280
|
+
Writes ``<home>/registry/<name>.json`` describing how to health-check the
|
|
281
|
+
service. ``service_health.check_all_services()`` unions these registry
|
|
282
|
+
entries with its built-in defaults, so a service that calls this on start
|
|
283
|
+
becomes discoverable without being hardcoded. Optional — health checks
|
|
284
|
+
still work with an empty registry.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
name: Service name (unique key).
|
|
288
|
+
health_url: Optional HTTP URL whose 2xx response means "up".
|
|
289
|
+
pid_file: Optional pid-file path used as a liveness fallback.
|
|
290
|
+
home: Override skcapstone root (defaults to ~/.skcapstone).
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Path to the written registry entry, as a string.
|
|
294
|
+
"""
|
|
295
|
+
base = Path(home) if home else _shared_home()
|
|
296
|
+
registry = base / "registry"
|
|
297
|
+
registry.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
entry = {
|
|
300
|
+
"name": name,
|
|
301
|
+
"health_url": health_url,
|
|
302
|
+
"pid_file": pid_file,
|
|
303
|
+
"registered_by": _agent_name(),
|
|
304
|
+
"registered_at": datetime.now(timezone.utc).isoformat(),
|
|
305
|
+
}
|
|
306
|
+
final = registry / f"{name}.json"
|
|
307
|
+
tmp = registry / f".{name}.json.{uuid.uuid4().hex[:8]}.tmp"
|
|
308
|
+
tmp.write_text(json.dumps(entry, indent=2) + "\n", encoding="utf-8")
|
|
309
|
+
tmp.rename(final)
|
|
310
|
+
return str(final)
|