workspaceguard-cli 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,158 @@
1
+ """
2
+ Core isolation + metering logic. Ported from src/core/isolation-guard.ts.
3
+ Never imports a specific backend directly -- all backend-specific behavior
4
+ goes through the BackendAdapter interface, a fixed architectural boundary.
5
+ The CLI and the library export are both thin wrappers around this class;
6
+ there is exactly one implementation of the isolation/metering logic.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import math
12
+ import os
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass
15
+ from typing import DefaultDict, List, Optional
16
+
17
+ from .circuit_breaker import CircuitBreaker
18
+ from .config import load_config, resolve_workspace_id, save_config, set_workspace_cap, upsert_workspace
19
+ from .log import ConsoleLogger, FailClosedEvent, Logger
20
+ from .namespace import ensure_workspace_dirs
21
+ from .types import BackendAdapter, IdentityNotFoundError, WorkspaceGuardConfig
22
+ from .usage import UsageMeter, WorkspaceUsage
23
+ from .vault import Vault
24
+
25
+
26
+ @dataclass
27
+ class WorkspaceUsageReport:
28
+ workspace_id: str
29
+ identity: str
30
+ monthly_message_cap: Optional[int]
31
+ percent_used: Optional[int]
32
+ period: str
33
+ message_count: int
34
+ estimated_bytes: int
35
+
36
+
37
+ class IsolationGuard:
38
+ def __init__(
39
+ self,
40
+ data_dir: str,
41
+ backend: BackendAdapter,
42
+ logger: Optional[Logger] = None,
43
+ circuit_open_cooldown_ms: Optional[int] = None,
44
+ ) -> None:
45
+ self._data_dir = data_dir
46
+ self._backend = backend
47
+ self._logger = logger or ConsoleLogger()
48
+ self._vault = Vault(os.path.join(self._data_dir, ".workspaceguard", "master.key"))
49
+ kwargs = {} if circuit_open_cooldown_ms is None else {"open_cooldown_ms": circuit_open_cooldown_ms}
50
+ self._circuit = CircuitBreaker(backend.name, self._logger, **kwargs)
51
+ self._usage = UsageMeter(self._data_dir)
52
+ self._config = WorkspaceGuardConfig(backend="odysseus", identity_header="", workspaces=[])
53
+ # Per-workspace lock so a concurrent quota check-then-record can't
54
+ # race across two calls to chat() for the same workspace (in-process
55
+ # only, matching this project's documented single-sidecar-process
56
+ # deployment model -- see usage.py).
57
+ self._workspace_locks: DefaultDict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
58
+
59
+ @property
60
+ def _config_path(self) -> str:
61
+ return os.path.join(self._data_dir, "workspaceguard.config.yaml")
62
+
63
+ async def init(self) -> None:
64
+ await self._vault.init()
65
+ self._config = load_config(self._config_path)
66
+
67
+ async def add_workspace(self, workspace_id: str, identity: str) -> None:
68
+ self._config = upsert_workspace(self._config, workspace_id, identity)
69
+ save_config(self._config_path, self._config)
70
+ await ensure_workspace_dirs(self._data_dir, workspace_id)
71
+
72
+ async def set_secret(self, workspace_id: str, secret: str) -> None:
73
+ await self._vault.write_secret(self._data_dir, workspace_id, secret)
74
+
75
+ async def rotate_key(self, workspace_id: str) -> None:
76
+ await self._vault.rotate(self._data_dir, workspace_id)
77
+
78
+ def resolve_workspace(self, identity_header_value: Optional[str]) -> str:
79
+ """
80
+ Resolves a workspace from an untrusted identity header value. Fails
81
+ closed on any miss -- never falls back to a default workspace. This
82
+ is the single choke point every request-handling path must go
83
+ through.
84
+ """
85
+ workspace_id = resolve_workspace_id(self._config, identity_header_value)
86
+ if not workspace_id:
87
+ self._logger.log(
88
+ FailClosedEvent(
89
+ reason="identity_not_found",
90
+ detail={"identityHeaderValue": identity_header_value},
91
+ )
92
+ )
93
+ raise IdentityNotFoundError(identity_header_value)
94
+ return workspace_id
95
+
96
+ async def chat(self, identity_header_value: Optional[str], message: str) -> str:
97
+ """
98
+ The quota check, backend call, and usage record are wrapped in a
99
+ single per-workspace lock so two concurrent chat() calls for the
100
+ same workspace can never both pass the same pre-cap quota check
101
+ (regression: previously unlocked, letting N concurrent requests at
102
+ cap-1 all read the same count and all proceed, and letting
103
+ concurrent record() calls lose updates to each other). Different
104
+ workspaces never contend with each other.
105
+ """
106
+ workspace_id = self.resolve_workspace(identity_header_value)
107
+ entry = next((w for w in self._config.workspaces if w.workspace_id == workspace_id), None)
108
+ cap = entry.monthly_message_cap if entry is not None else None
109
+
110
+ async with self._workspace_locks[workspace_id]:
111
+ await self._usage.check_quota(workspace_id, cap)
112
+
113
+ async def _call() -> str:
114
+ return await self._backend.forward_chat(workspace_id, message)
115
+
116
+ response = await self._circuit.call(_call)
117
+ await self._usage.record(workspace_id, message)
118
+ return response
119
+
120
+ async def status(self) -> List[dict]:
121
+ return [{"workspaceId": w.workspace_id, "identity": w.identity} for w in self._config.workspaces]
122
+
123
+ async def set_cap(self, workspace_id: str, cap: Optional[int]) -> None:
124
+ self._config = set_workspace_cap(self._config, workspace_id, cap)
125
+ save_config(self._config_path, self._config)
126
+
127
+ async def usage_report(self) -> List[WorkspaceUsageReport]:
128
+ """
129
+ The admin-visibility surface this project actually ships (the OSS
130
+ free tier) -- a hosted billing dashboard on top of this data is a
131
+ separate, closed-source product, never built in this repo.
132
+ """
133
+ ids = [w.workspace_id for w in self._config.workspaces]
134
+ usage_by_id = await self._usage.get_all(ids)
135
+ reports: List[WorkspaceUsageReport] = []
136
+ for w in self._config.workspaces:
137
+ usage = usage_by_id.get(w.workspace_id) or WorkspaceUsage(period="", message_count=0, estimated_bytes=0)
138
+ percent_used: Optional[int] = None
139
+ if w.monthly_message_cap is not None and w.monthly_message_cap > 0:
140
+ # math.floor(x + 0.5), not the builtin round(): Python's
141
+ # round() uses banker's rounding (round-half-to-even) while
142
+ # the TS original's Math.round() rounds half-up -- this
143
+ # kept percentUsed diverging between the two --json outputs
144
+ # for exact-half cases (e.g. 12.5% -> 12 vs 13) despite both
145
+ # READMEs documenting the outputs as identical.
146
+ percent_used = math.floor((usage.message_count / w.monthly_message_cap) * 100 + 0.5)
147
+ reports.append(
148
+ WorkspaceUsageReport(
149
+ workspace_id=w.workspace_id,
150
+ identity=w.identity,
151
+ monthly_message_cap=w.monthly_message_cap,
152
+ percent_used=percent_used,
153
+ period=usage.period,
154
+ message_count=usage.message_count,
155
+ estimated_bytes=usage.estimated_bytes,
156
+ )
157
+ )
158
+ return reports
workspaceguard/log.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ Structured event logging. Ported from src/core/log.ts. Every fail-closed
3
+ decision and every circuit-breaker state change gets one JSON line -- the
4
+ only way the "zero cross-workspace leaks" claim is verifiable outside of
5
+ tests.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Dict, Literal, Optional
15
+
16
+
17
+ @dataclass
18
+ class FailClosedEvent:
19
+ reason: str
20
+ detail: Dict[str, Any]
21
+ type: Literal["fail_closed"] = "fail_closed"
22
+
23
+
24
+ @dataclass
25
+ class CircuitStateChangeEvent:
26
+ backend: str
27
+ state: Literal["open", "closed"]
28
+ type: Literal["circuit_state_change"] = "circuit_state_change"
29
+
30
+
31
+ LogEvent = Any # FailClosedEvent | CircuitStateChangeEvent
32
+
33
+
34
+ class Logger(ABC):
35
+ @abstractmethod
36
+ def log(self, event: LogEvent) -> None: ...
37
+
38
+
39
+ class ConsoleLogger(Logger):
40
+ def log(self, event: LogEvent) -> None:
41
+ line: Dict[str, Any] = {"ts": datetime.now(timezone.utc).isoformat()}
42
+ line.update(vars(event))
43
+ print(json.dumps(line), file=sys.stdout)
@@ -0,0 +1,21 @@
1
+ """
2
+ Chat history and memory get a directory boundary per workspace, not a
3
+ shared table/file with a workspace column -- easier to verify by inspection
4
+ than a query filter. Ported from src/core/namespace.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+
11
+ def chat_history_dir(data_dir: str, workspace_id: str) -> str:
12
+ return os.path.join(data_dir, "workspaces", workspace_id, "chat")
13
+
14
+
15
+ def memory_dir(data_dir: str, workspace_id: str) -> str:
16
+ return os.path.join(data_dir, "workspaces", workspace_id, "memory")
17
+
18
+
19
+ async def ensure_workspace_dirs(data_dir: str, workspace_id: str) -> None:
20
+ os.makedirs(chat_history_dir(data_dir, workspace_id), exist_ok=True)
21
+ os.makedirs(memory_dir(data_dir, workspace_id), exist_ok=True)
File without changes
@@ -0,0 +1,78 @@
1
+ """
2
+ Shared types for WorkspaceGuard's isolation and metering pipeline. Used by
3
+ both the CLI (workspaceguard/cli.py) and the programmatic library API
4
+ (workspaceguard/__init__.py).
5
+
6
+ This is a Python port of the TypeScript original's src/core/types.ts. Field
7
+ names follow Python (snake_case) convention; the config/usage persistence
8
+ layer (workspaceguard/config.py, workspaceguard/usage.py) re-serializes to
9
+ the same camelCase keys the npm package's YAML/JSON files use, so a
10
+ `workspaceguard.config.yaml` or `usage.json` written by one distribution
11
+ stays readable by the other.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass, field
17
+ from typing import List, Optional
18
+
19
+
20
+ @dataclass
21
+ class WorkspaceEntry:
22
+ workspace_id: str
23
+ identity: str
24
+ monthly_message_cap: Optional[int] = None
25
+ """Monthly message cap for usage metering. None = unlimited (free-tier default)."""
26
+
27
+
28
+ @dataclass
29
+ class WorkspaceGuardConfig:
30
+ backend: str = "odysseus"
31
+ identity_header: str = ""
32
+ workspaces: List[WorkspaceEntry] = field(default_factory=list)
33
+
34
+
35
+ class BackendAdapter(ABC):
36
+ """
37
+ Fixed architectural boundary: all backend-specific behavior goes through
38
+ this interface. Core isolation/metering logic never imports a specific
39
+ backend directly.
40
+ """
41
+
42
+ name: str
43
+
44
+ @abstractmethod
45
+ async def health_check(self) -> bool: ...
46
+
47
+ @abstractmethod
48
+ async def forward_chat(self, workspace_id: str, message: str) -> str: ...
49
+
50
+
51
+ class IdentityNotFoundError(Exception):
52
+ def __init__(self, header_value: Optional[str]) -> None:
53
+ super().__init__("no workspace configured for this identity")
54
+ self.header_value = header_value
55
+
56
+
57
+ class VaultDecryptionError(Exception):
58
+ def __init__(self, workspace_id: str) -> None:
59
+ super().__init__(f"could not decrypt vault for workspace {workspace_id}")
60
+ self.workspace_id = workspace_id
61
+
62
+
63
+ class BackendUnreachableError(Exception):
64
+ def __init__(self, backend: str) -> None:
65
+ super().__init__(f"backend {backend} did not respond")
66
+ self.backend = backend
67
+
68
+
69
+ class BackendCircuitOpenError(Exception):
70
+ def __init__(self, backend: str) -> None:
71
+ super().__init__(f"backend {backend} circuit is open, refusing calls")
72
+ self.backend = backend
73
+
74
+
75
+ class WorkspaceNotFoundError(Exception):
76
+ def __init__(self, workspace_id: str) -> None:
77
+ super().__init__(f'no workspace configured with id "{workspace_id}"')
78
+ self.workspace_id = workspace_id
@@ -0,0 +1,149 @@
1
+ """
2
+ Per-workspace usage metering -- the wedge this project ships instead of
3
+ per-user isolation (already native in the target platform, see README).
4
+ Ported from src/core/usage.ts. Reads/writes are not lock-guarded against
5
+ concurrent processes, the same accepted tradeoff config.py already makes for
6
+ `workspaceguard.config.yaml` (single-sidecar-process deployment model).
7
+
8
+ The usage.json file uses camelCase keys (period, messageCount,
9
+ estimatedBytes) to stay wire-compatible with the npm package's own store.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from typing import Dict, List, Optional
18
+
19
+ UsageStore = Dict[str, "WorkspaceUsage"]
20
+
21
+
22
+ @dataclass
23
+ class WorkspaceUsage:
24
+ period: str
25
+ message_count: int
26
+ estimated_bytes: int
27
+
28
+
29
+ class QuotaExceededError(Exception):
30
+ def __init__(self, workspace_id: str, cap: int) -> None:
31
+ super().__init__(f"workspace {workspace_id} has reached its monthly cap of {cap} messages")
32
+ self.workspace_id = workspace_id
33
+ self.cap = cap
34
+
35
+
36
+ def _current_period() -> str:
37
+ now = datetime.now(timezone.utc)
38
+ return f"{now.year}-{now.month:02d}"
39
+
40
+
41
+ def usage_path(data_dir: str) -> str:
42
+ return os.path.join(data_dir, ".workspaceguard", "usage.json")
43
+
44
+
45
+ def _load_usage_raw(data_dir: str) -> Dict[str, dict]:
46
+ """
47
+ Only a missing file (no workspace has recorded usage yet) is a
48
+ legitimate empty store. Every other failure (permission error, disk
49
+ error, a corrupted/truncated JSON file) must propagate so a caller like
50
+ check_quota() fails closed instead of silently reading every
51
+ workspace's usage as zero -- a catch-everything here used to let any
52
+ I/O hiccup or a race-corrupted usage.json lift every quota in the
53
+ store.
54
+ """
55
+ try:
56
+ with open(usage_path(data_dir), "r", encoding="utf-8") as fh:
57
+ raw = fh.read()
58
+ except FileNotFoundError:
59
+ return {}
60
+
61
+ try:
62
+ parsed = json.loads(raw)
63
+ except json.JSONDecodeError as err:
64
+ raise ValueError(f"usage store at {usage_path(data_dir)} is corrupted and could not be parsed: {err}") from err
65
+ if not isinstance(parsed, dict):
66
+ raise ValueError(f"usage store at {usage_path(data_dir)} has an unexpected shape (expected a JSON object)")
67
+ return parsed
68
+
69
+
70
+ def load_usage(data_dir: str) -> UsageStore:
71
+ raw = _load_usage_raw(data_dir)
72
+ store: UsageStore = {}
73
+ for workspace_id, entry in raw.items():
74
+ try:
75
+ store[workspace_id] = WorkspaceUsage(
76
+ period=entry["period"],
77
+ message_count=entry["messageCount"],
78
+ estimated_bytes=entry["estimatedBytes"],
79
+ )
80
+ except (KeyError, TypeError):
81
+ continue
82
+ return store
83
+
84
+
85
+ def save_usage(data_dir: str, store: UsageStore) -> None:
86
+ path = usage_path(data_dir)
87
+ os.makedirs(os.path.dirname(path), exist_ok=True)
88
+ payload = {
89
+ workspace_id: {
90
+ "period": usage.period,
91
+ "messageCount": usage.message_count,
92
+ "estimatedBytes": usage.estimated_bytes,
93
+ }
94
+ for workspace_id, usage in store.items()
95
+ }
96
+ with open(path, "w", encoding="utf-8") as fh:
97
+ json.dump(payload, fh, indent=2)
98
+
99
+
100
+ def current_usage_for(store: UsageStore, workspace_id: str) -> WorkspaceUsage:
101
+ """
102
+ Rolls a workspace's counter over to a fresh period on read -- a stale
103
+ count from last month must never inflate this month's total or trip a
104
+ cap that shouldn't apply yet (no cron/scheduler needed, since every real
105
+ read/write already knows what period it is).
106
+ """
107
+ existing = store.get(workspace_id)
108
+ period = _current_period()
109
+ if existing is None or existing.period != period:
110
+ return WorkspaceUsage(period=period, message_count=0, estimated_bytes=0)
111
+ return existing
112
+
113
+
114
+ class UsageMeter:
115
+ def __init__(self, data_dir: str) -> None:
116
+ self.data_dir = data_dir
117
+
118
+ async def record(self, workspace_id: str, message: str) -> WorkspaceUsage:
119
+ store = load_usage(self.data_dir)
120
+ usage = current_usage_for(store, workspace_id)
121
+ usage = WorkspaceUsage(
122
+ period=usage.period,
123
+ message_count=usage.message_count + 1,
124
+ estimated_bytes=usage.estimated_bytes + len(message.encode("utf-8")),
125
+ )
126
+ store[workspace_id] = usage
127
+ save_usage(self.data_dir, store)
128
+ return usage
129
+
130
+ async def get(self, workspace_id: str) -> WorkspaceUsage:
131
+ store = load_usage(self.data_dir)
132
+ return current_usage_for(store, workspace_id)
133
+
134
+ async def get_all(self, workspace_ids: List[str]) -> Dict[str, WorkspaceUsage]:
135
+ store = load_usage(self.data_dir)
136
+ return {workspace_id: current_usage_for(store, workspace_id) for workspace_id in workspace_ids}
137
+
138
+ async def check_quota(self, workspace_id: str, cap: Optional[int]) -> None:
139
+ """
140
+ Raises QuotaExceededError once a workspace has reached its
141
+ configured cap. cap=None means unlimited -- never blocks a
142
+ workspace that has no cap set, so this stays free/OSS-safe by
143
+ default.
144
+ """
145
+ if cap is None:
146
+ return
147
+ usage = await self.get(workspace_id)
148
+ if usage.message_count >= cap:
149
+ raise QuotaExceededError(workspace_id, cap)
@@ -0,0 +1,130 @@
1
+ """
2
+ One derived AES-256-GCM key per workspace: chat history and memory get a
3
+ directory boundary (namespace.py), the API-key vault gets its own encrypted
4
+ file per workspace so a leaked file alone (without the master key) reveals
5
+ nothing. Ported from src/core/vault.ts, using the `cryptography` package's
6
+ AESGCM primitive (the Python ecosystem's standard, actively maintained
7
+ choice for authenticated symmetric encryption) in place of Node's
8
+ node:crypto.
9
+
10
+ On-disk layout is kept identical to the TypeScript original (12-byte IV +
11
+ 16-byte GCM auth tag + ciphertext, concatenated) so a vault file's format is
12
+ conceptually portable between the two distributions even though a real
13
+ deployment would run one or the other against a given data directory, not
14
+ both against the same one simultaneously.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import hmac
20
+ import os
21
+ import secrets
22
+ from typing import Optional
23
+
24
+ from cryptography.exceptions import InvalidTag
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
26
+
27
+ from .types import VaultDecryptionError
28
+
29
+ KEY_BYTES = 32
30
+ IV_BYTES = 12
31
+ TAG_BYTES = 16
32
+
33
+
34
+ class Vault:
35
+ def __init__(self, master_key_path: str) -> None:
36
+ self._master_key_path = master_key_path
37
+ self._master_key: Optional[bytes] = None
38
+
39
+ async def init(self) -> None:
40
+ try:
41
+ with open(self._master_key_path, "r", encoding="utf-8") as fh:
42
+ raw = fh.read().strip()
43
+ import base64
44
+
45
+ self._master_key = base64.b64decode(raw)
46
+ except FileNotFoundError:
47
+ import base64
48
+
49
+ self._master_key = secrets.token_bytes(KEY_BYTES)
50
+ os.makedirs(os.path.dirname(self._master_key_path), exist_ok=True)
51
+ with open(self._master_key_path, "w", encoding="utf-8") as fh:
52
+ fh.write(base64.b64encode(self._master_key).decode("ascii"))
53
+ os.chmod(self._master_key_path, 0o600)
54
+
55
+ def _require_master_key(self) -> bytes:
56
+ if self._master_key is None:
57
+ raise RuntimeError("Vault.init() must run before any vault operation")
58
+ return self._master_key
59
+
60
+ def _derive_workspace_key(self, workspace_id: str, generation: int) -> bytes:
61
+ master = self._require_master_key()
62
+ message = f"{workspace_id}:{generation}".encode("utf-8")
63
+ return hmac.new(master, message, hashlib.sha256).digest()
64
+
65
+ def vault_path(self, data_dir: str, workspace_id: str) -> str:
66
+ return os.path.join(data_dir, "workspaces", workspace_id, "vault.bin")
67
+
68
+ def _generation_path(self, data_dir: str, workspace_id: str) -> str:
69
+ return os.path.join(data_dir, "workspaces", workspace_id, "key-generation")
70
+
71
+ def _read_generation(self, data_dir: str, workspace_id: str) -> int:
72
+ try:
73
+ with open(self._generation_path(data_dir, workspace_id), "r", encoding="utf-8") as fh:
74
+ return int(fh.read().strip() or "0")
75
+ except (FileNotFoundError, ValueError):
76
+ return 0
77
+
78
+ async def write_secret(self, data_dir: str, workspace_id: str, plaintext: str) -> None:
79
+ generation = self._read_generation(data_dir, workspace_id)
80
+ key = self._derive_workspace_key(workspace_id, generation)
81
+ iv = secrets.token_bytes(IV_BYTES)
82
+ aesgcm = AESGCM(key)
83
+ sealed = aesgcm.encrypt(iv, plaintext.encode("utf-8"), None)
84
+ ciphertext, auth_tag = sealed[:-TAG_BYTES], sealed[-TAG_BYTES:]
85
+ path = self.vault_path(data_dir, workspace_id)
86
+ os.makedirs(os.path.dirname(path), exist_ok=True)
87
+ with open(path, "wb") as fh:
88
+ fh.write(iv + auth_tag + ciphertext)
89
+ os.chmod(path, 0o600)
90
+
91
+ async def read_secret(self, data_dir: str, workspace_id: str) -> str:
92
+ generation = self._read_generation(data_dir, workspace_id)
93
+ key = self._derive_workspace_key(workspace_id, generation)
94
+ path = self.vault_path(data_dir, workspace_id)
95
+ try:
96
+ with open(path, "rb") as fh:
97
+ payload = fh.read()
98
+ except FileNotFoundError:
99
+ raise VaultDecryptionError(workspace_id)
100
+ iv = payload[:IV_BYTES]
101
+ auth_tag = payload[IV_BYTES : IV_BYTES + TAG_BYTES]
102
+ ciphertext = payload[IV_BYTES + TAG_BYTES :]
103
+ try:
104
+ aesgcm = AESGCM(key)
105
+ plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag, None)
106
+ return plaintext.decode("utf-8")
107
+ except (InvalidTag, ValueError):
108
+ raise VaultDecryptionError(workspace_id)
109
+
110
+ async def rotate(self, data_dir: str, workspace_id: str) -> None:
111
+ """
112
+ Rotation increments the per-workspace key generation BEFORE
113
+ re-encrypting, so the new ciphertext is under a genuinely different
114
+ derived key -- anything encrypted under the old generation can no
115
+ longer be decrypted once the generation file is bumped. Behavior for
116
+ an in-flight stream during rotation is a known open question, not
117
+ resolved here (matches the TypeScript original).
118
+ """
119
+ try:
120
+ existing: Optional[str] = await self.read_secret(data_dir, workspace_id)
121
+ except VaultDecryptionError:
122
+ existing = None
123
+ current_generation = self._read_generation(data_dir, workspace_id)
124
+ next_generation = current_generation + 1
125
+ generation_path = self._generation_path(data_dir, workspace_id)
126
+ os.makedirs(os.path.dirname(generation_path), exist_ok=True)
127
+ with open(generation_path, "w", encoding="utf-8") as fh:
128
+ fh.write(str(next_generation))
129
+ if existing is not None:
130
+ await self.write_secret(data_dir, workspace_id, existing)