cowork-flow 0.0.37 → 0.0.39

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.
Files changed (111) hide show
  1. package/README.md +27 -14
  2. package/package.json +1 -1
  3. package/src/commands/init.js +39 -28
  4. package/src/commands/sync.js +2 -2
  5. package/src/lib/asset-plan.js +127 -0
  6. package/src/lib/copy-template.js +101 -141
  7. package/src/lib/host-assets.js +231 -0
  8. package/src/lib/plan-applier.js +329 -0
  9. package/src/lib/platforms.js +15 -76
  10. package/src/lib/skill-registry.js +503 -0
  11. package/template/.claude/agents/cowork-check.md +10 -1
  12. package/template/.claude/agents/cowork-implement.md +11 -2
  13. package/template/.claude/hooks/inject-workflow-state.py +51 -254
  14. package/template/.codex/agents/cowork-check.toml +12 -3
  15. package/template/.codex/agents/cowork-implement.toml +11 -2
  16. package/template/.codex/agents/default.toml +3 -2
  17. package/template/.codex/agents/explorer.toml +3 -2
  18. package/template/.codex/agents/worker.toml +2 -1
  19. package/template/.codex/hooks/inject-workflow-state.py +46 -265
  20. package/template/.cowork-flow/.version +1 -1
  21. package/template/.cowork-flow/scripts/application/__init__.py +1 -0
  22. package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
  23. package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
  24. package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
  25. package/template/.cowork-flow/scripts/application/task_context.py +765 -0
  26. package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
  27. package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
  28. package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
  29. package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
  30. package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
  31. package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
  32. package/template/.cowork-flow/scripts/commands/task.py +535 -1953
  33. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
  34. package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
  35. package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
  36. package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
  37. package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
  38. package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
  39. package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
  40. package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
  41. package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
  42. package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
  43. package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
  44. package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
  45. package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
  46. package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
  47. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
  48. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  49. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
  50. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
  51. package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
  52. package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
  53. package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
  54. package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
  55. package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
  56. package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
  57. package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
  58. package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
  59. package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
  60. package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
  61. package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
  62. package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
  63. package/template/.cowork-flow/scripts/run.py +24 -0
  64. package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  65. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  66. package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
  67. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
  68. package/template/.cowork-flow/spec/protocols/review.md +34 -0
  69. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
  70. package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
  71. package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
  72. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  73. package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
  74. package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
  75. package/template/.cowork-flow/spec/runtime/index.md +8 -0
  76. package/template/.cowork-flow/spec/runtime/rules.json +61 -10
  77. package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
  78. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
  79. package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  80. package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
  81. package/template/.cowork-flow/workflow.md +18 -13
  82. package/template/.opencode/agents/cowork-check.md +11 -0
  83. package/template/.opencode/agents/cowork-implement.md +16 -1
  84. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  85. package/template/.zcode/hooks/inject-context.js +3 -85
  86. package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
  87. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  88. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  89. package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
  90. package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  91. package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
  92. package/template/.zcode/scaffold/AGENTS.md +18 -8
  93. package/template/AGENTS.md +1 -1
  94. package/template/skills/batch-mode/SKILL.md +140 -76
  95. package/template/skills/brainstorming/SKILL.md +2 -2
  96. package/template/skills/cowork-flow/SKILL.md +42 -0
  97. package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
  98. package/template/skills/party-mode/SKILL.md +48 -119
  99. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
  100. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
  101. package/template/skills/before-dev/SKILL.md +0 -41
  102. package/template/skills/check/SKILL.md +0 -53
  103. package/template/skills/check/simplification-guide.md +0 -47
  104. package/template/skills/continue/SKILL.md +0 -29
  105. package/template/skills/doubt-review/SKILL.md +0 -118
  106. package/template/skills/finish-work/SKILL.md +0 -32
  107. package/template/skills/party-mode-v2/SKILL.md +0 -75
  108. package/template/skills/start/SKILL.md +0 -78
  109. package/template/skills/tdd/SKILL.md +0 -74
  110. package/template/skills/update-spec/SKILL.md +0 -39
  111. package/template/skills/using-cowork-flow/SKILL.md +0 -80
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ """Shared workflow-state protocol used by host hook adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ TAG_RE = re.compile(
15
+ r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*"
16
+ r"\[/workflow-state:\1\]",
17
+ re.DOTALL,
18
+ )
19
+ DEFAULT_CONTRACT_REGISTRY = {
20
+ "contracts": [
21
+ {
22
+ "id": "RUNTIME_CONTEXT_DISPATCH_V2",
23
+ "path": ".cowork-flow/spec/contracts/subagent-dispatch.md",
24
+ "digest": [
25
+ "Formal subagent work is keyed by cowork_runtime_context_id.",
26
+ "Explicit shim bind records bound_context_key before formal output is accepted.",
27
+ ],
28
+ "readWhen": [
29
+ "before formal subagent dispatch",
30
+ "when checking subagent health",
31
+ ],
32
+ },
33
+ ]
34
+ }
35
+
36
+
37
+ def find_repo_root(start: Path) -> Path | None:
38
+ current = start.resolve()
39
+ while True:
40
+ if (current / ".cowork-flow").is_dir():
41
+ return current
42
+ if current == current.parent:
43
+ return None
44
+ current = current.parent
45
+
46
+
47
+ def codex_dispatch_mode(root: Path) -> str:
48
+ _load_common(root)
49
+ try:
50
+ from common.core.config import get_codex_dispatch_mode
51
+ except Exception:
52
+ return "sub-agent"
53
+ try:
54
+ return get_codex_dispatch_mode(root)
55
+ except Exception:
56
+ return "sub-agent"
57
+
58
+
59
+ def build_hook_context(
60
+ root: Path,
61
+ hook_input: dict[str, Any],
62
+ *,
63
+ host: str,
64
+ adapter: str,
65
+ preamble: tuple[str, ...],
66
+ ) -> str:
67
+ breadcrumbs = _load_breadcrumbs(root)
68
+ runtime_context, runtime_context_id = _resolve_runtime_context(
69
+ root,
70
+ hook_input,
71
+ )
72
+ extra_lines: list[str] | None = None
73
+ if runtime_context is not None:
74
+ task_dir = runtime_context.get("task_dir")
75
+ task_path = (
76
+ task_dir.strip()
77
+ if isinstance(task_dir, str) and task_dir.strip()
78
+ else None
79
+ )
80
+ status = "delegated_subtask"
81
+ source = (
82
+ f"runtime-context:{runtime_context.get('runtime_context_id')}"
83
+ )
84
+ extra_lines = _subagent_runtime_lines(runtime_context)
85
+ elif runtime_context_id:
86
+ task_path = None
87
+ status = "delegated_subtask"
88
+ source = f"runtime-context-invalid:{runtime_context_id}"
89
+ extra_lines = [
90
+ f"Runtime context: {runtime_context_id}",
91
+ "Scope: subagent",
92
+ (
93
+ "Runtime context is missing, closed, or invalid. "
94
+ "Do not run start/resume/task start/archive/commit/spawn."
95
+ ),
96
+ ]
97
+ else:
98
+ task_path, status, source = _get_active_task(root, hook_input)
99
+
100
+ body = (
101
+ breadcrumbs.get(status)
102
+ or "Refer to .cowork-flow/workflow.md for the current step."
103
+ )
104
+ if extra_lines:
105
+ body = "\n".join([body, *extra_lines])
106
+ if task_path is None:
107
+ header = f"Status: {status}\nSource: {source}"
108
+ else:
109
+ header = f"Task: {task_path}\nStatus: {status}\nSource: {source}"
110
+ return "\n\n".join(
111
+ [
112
+ *preamble,
113
+ _build_contract_digest(root, host, adapter),
114
+ f"<workflow-state>\n{header}\n{body}\n</workflow-state>",
115
+ ]
116
+ )
117
+
118
+
119
+ def _load_breadcrumbs(root: Path) -> dict[str, str]:
120
+ path = (
121
+ root
122
+ / ".cowork-flow"
123
+ / "spec"
124
+ / "contracts"
125
+ / "workflow-state-templates.md"
126
+ )
127
+ try:
128
+ text = path.read_text(encoding="utf-8")
129
+ except OSError:
130
+ return {}
131
+ return {
132
+ match.group(1): match.group(2).strip()
133
+ for match in TAG_RE.finditer(text)
134
+ }
135
+
136
+
137
+ def _load_contract_registry(
138
+ root: Path,
139
+ ) -> tuple[list[dict[str, Any]], str | None]:
140
+ path = (
141
+ root
142
+ / ".cowork-flow"
143
+ / "spec"
144
+ / "runtime"
145
+ / "contract-registry.json"
146
+ )
147
+ try:
148
+ data = json.loads(path.read_text(encoding="utf-8"))
149
+ except OSError:
150
+ data = DEFAULT_CONTRACT_REGISTRY
151
+ warning = f"contract registry unavailable at {path}; using fallback digest"
152
+ except json.JSONDecodeError:
153
+ data = DEFAULT_CONTRACT_REGISTRY
154
+ warning = f"contract registry invalid at {path}; using fallback digest"
155
+ else:
156
+ warning = None
157
+ contracts = data.get("contracts") if isinstance(data, dict) else None
158
+ if not isinstance(contracts, list):
159
+ contracts = DEFAULT_CONTRACT_REGISTRY["contracts"]
160
+ warning = warning or (
161
+ f"contract registry has no contracts array at {path}; "
162
+ "using fallback digest"
163
+ )
164
+ return [
165
+ contract for contract in contracts if isinstance(contract, dict)
166
+ ], warning
167
+
168
+
169
+ def _build_contract_digest(
170
+ root: Path,
171
+ host: str,
172
+ adapter: str,
173
+ ) -> str:
174
+ contracts, warning = _load_contract_registry(root)
175
+ digest = hashlib.sha256()
176
+ digest.update(
177
+ json.dumps(
178
+ contracts,
179
+ ensure_ascii=False,
180
+ sort_keys=True,
181
+ ).encode("utf-8")
182
+ )
183
+ for contract in contracts:
184
+ path = contract.get("path")
185
+ if not isinstance(path, str) or not path.strip():
186
+ continue
187
+ try:
188
+ digest.update((root / path).read_bytes())
189
+ except OSError:
190
+ digest.update(f"missing:{path}".encode("utf-8"))
191
+ lines = [
192
+ f'<cowork-runtime host="{host}" adapter="{adapter}">',
193
+ f'<contract-digest fingerprint="{digest.hexdigest()[:16]}">',
194
+ (
195
+ "policy: repeat this short digest every hook; "
196
+ "read full spec files only before listed actions."
197
+ ),
198
+ ]
199
+ if warning:
200
+ lines.append(f"warning: {warning}")
201
+ for contract in contracts:
202
+ contract_id = contract.get("id")
203
+ path = contract.get("path")
204
+ if not isinstance(contract_id, str) or not contract_id.strip():
205
+ continue
206
+ path_text = (
207
+ path if isinstance(path, str) and path.strip() else "<missing-path>"
208
+ )
209
+ lines.append(f"- {contract_id}: {path_text}")
210
+ for item in _string_list(contract.get("digest"))[:2]:
211
+ lines.append(f" digest: {item}")
212
+ read_when = _string_list(contract.get("readWhen"))
213
+ if read_when:
214
+ lines.append(f" read_before: {'; '.join(read_when)}")
215
+ lines.extend(["</contract-digest>", "</cowork-runtime>"])
216
+ return "\n".join(lines)
217
+
218
+
219
+ def _get_active_task(
220
+ root: Path,
221
+ hook_input: dict[str, Any],
222
+ ) -> tuple[str | None, str, str]:
223
+ _load_common(root)
224
+ try:
225
+ from common.task.active_task import get_active_task
226
+ except Exception:
227
+ return None, "no_task", "unavailable"
228
+ active = get_active_task(root, hook_input)
229
+ if not active.task_path:
230
+ return None, "no_task", active.source
231
+ task_json = root / active.task_path / "task.json"
232
+ try:
233
+ data = json.loads(task_json.read_text(encoding="utf-8"))
234
+ except (OSError, json.JSONDecodeError):
235
+ return active.task_path, "stale", active.source
236
+ status = data.get("status")
237
+ if not isinstance(status, str) or not status.strip():
238
+ status = "unknown"
239
+ return active.task_path, status.strip(), active.source
240
+
241
+
242
+ def _resolve_runtime_context(
243
+ root: Path,
244
+ hook_input: dict[str, Any],
245
+ ) -> tuple[dict[str, Any] | None, str | None]:
246
+ _load_common(root)
247
+ try:
248
+ from common.task.active_task import (
249
+ bind_runtime_context,
250
+ read_runtime_context,
251
+ resolve_runtime_context_id,
252
+ )
253
+ except Exception:
254
+ return None, None
255
+ runtime_context_id = resolve_runtime_context_id(hook_input)
256
+ if not runtime_context_id:
257
+ return None, None
258
+ context = read_runtime_context(root, runtime_context_id)
259
+ if (
260
+ not context
261
+ or context.get("scope") != "subagent"
262
+ or context.get("status") == "closed"
263
+ ):
264
+ return None, runtime_context_id
265
+ bound = bind_runtime_context(
266
+ root,
267
+ runtime_context_id,
268
+ values=hook_input,
269
+ )
270
+ return bound or context, runtime_context_id
271
+
272
+
273
+ def _subagent_runtime_lines(context: dict[str, Any]) -> list[str]:
274
+ assignment = (
275
+ context.get("assignment")
276
+ if isinstance(context.get("assignment"), dict)
277
+ else {}
278
+ )
279
+ lines = [
280
+ f"Runtime context: {context.get('runtime_context_id')}",
281
+ f"Agent: {context.get('agent_type') or 'unknown'}",
282
+ "Scope: subagent",
283
+ "Do not run start/resume/task start/archive/commit/spawn.",
284
+ ]
285
+ goal = assignment.get("goal")
286
+ if isinstance(goal, str) and goal.strip():
287
+ lines.append(f"Goal: {goal.strip()}")
288
+ return lines
289
+
290
+
291
+ def _load_common(root: Path) -> None:
292
+ scripts_dir = root / ".cowork-flow" / "scripts"
293
+ if str(scripts_dir) not in sys.path:
294
+ sys.path.insert(0, str(scripts_dir))
295
+
296
+
297
+ def _string_list(value: object) -> list[str]:
298
+ if not isinstance(value, list):
299
+ return []
300
+ return [
301
+ item for item in value
302
+ if isinstance(item, str) and item.strip()
303
+ ]
@@ -0,0 +1 @@
1
+ """Revision-aware local state storage."""
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent operation log for recoverable multi-file writes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ from common.storage.state_store import StateStore, StateStoreError
9
+
10
+
11
+ FINAL_PHASES = ("committed", "conflicted")
12
+
13
+
14
+ class OperationLog:
15
+ """Persist and enumerate Unit of Work operation records."""
16
+
17
+ def __init__(
18
+ self,
19
+ repo_root: Path,
20
+ *,
21
+ state_store: StateStore | None = None,
22
+ ) -> None:
23
+ self.repo_root = Path(repo_root)
24
+ self.state_store = state_store or StateStore()
25
+ self.operations_dir = (
26
+ self.repo_root
27
+ / ".cowork-flow"
28
+ / ".runtime"
29
+ / "operations"
30
+ )
31
+
32
+ def path(self, operation_id: str) -> Path:
33
+ return self.operations_dir / f"{operation_id}.json"
34
+
35
+ def load(self, operation_id: str) -> dict:
36
+ return self.state_store.load(self.path(operation_id)).data
37
+
38
+ def create(self, operation_id: str, data: dict) -> dict:
39
+ path = self.path(operation_id)
40
+ if path.is_file():
41
+ return self.load(operation_id)
42
+ return self.state_store.replace(
43
+ path,
44
+ data,
45
+ expected_revision=0,
46
+ operation_id=f"{operation_id}:log:1",
47
+ ).data
48
+
49
+ def save(self, operation_id: str, data: dict) -> dict:
50
+ path = self.path(operation_id)
51
+ current = self.state_store.load(path)
52
+ return self.state_store.replace(
53
+ path,
54
+ data,
55
+ expected_revision=current.revision,
56
+ operation_id=(
57
+ f"{operation_id}:log:{current.revision + 1}"
58
+ ),
59
+ ).data
60
+
61
+ def pending(self) -> tuple[dict, ...]:
62
+ if not self.operations_dir.is_dir():
63
+ return ()
64
+ records: list[dict] = []
65
+ for path in sorted(self.operations_dir.glob("*.json")):
66
+ try:
67
+ record = self.state_store.load(path).data
68
+ except StateStoreError:
69
+ raise
70
+ if record.get("phase") not in FINAL_PHASES:
71
+ records.append(record)
72
+ return tuple(records)
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python3
2
+ """Revision-aware UTF-8 JSON state persistence."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import time
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Iterator
13
+
14
+
15
+ STATE_METADATA_KEY = "_state"
16
+ ATOMIC_REPLACE_ATTEMPTS = 5
17
+ ATOMIC_REPLACE_RETRY_SECONDS = 0.01
18
+
19
+
20
+ class StateStoreError(RuntimeError):
21
+ """Raised when versioned state cannot be read or persisted."""
22
+
23
+ def __init__(self, code: str, path: Path, detail: str) -> None:
24
+ self.code = code
25
+ self.path = path
26
+ self.detail = detail
27
+ super().__init__(f"{code}: {detail}: {path}")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class StateSnapshot:
32
+ path: Path
33
+ data: dict
34
+ revision: int
35
+ operation_id: str | None
36
+ exists: bool
37
+
38
+
39
+ class StateStore:
40
+ """Read and compare-and-swap JSON state documents."""
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ lock_timeout_seconds: float = 5.0,
46
+ lock_poll_seconds: float = 0.01,
47
+ ) -> None:
48
+ self.lock_timeout_seconds = lock_timeout_seconds
49
+ self.lock_poll_seconds = lock_poll_seconds
50
+
51
+ def load(
52
+ self,
53
+ path: str | Path,
54
+ *,
55
+ missing_ok: bool = False,
56
+ ) -> StateSnapshot:
57
+ return self._load_unlocked(Path(path), missing_ok=missing_ok)
58
+
59
+ def replace(
60
+ self,
61
+ path: str | Path,
62
+ data: dict,
63
+ *,
64
+ expected_revision: int | None,
65
+ operation_id: str,
66
+ ) -> StateSnapshot:
67
+ target = Path(path)
68
+ with self._lock(target):
69
+ current = self._load_unlocked(target, missing_ok=True)
70
+ if current.operation_id == operation_id:
71
+ if current.data == data:
72
+ return current
73
+ raise StateStoreError(
74
+ "STATE-IDEMPOTENCY-001",
75
+ target,
76
+ "operation id was already used with different state",
77
+ )
78
+ self._check_revision(target, current, expected_revision)
79
+
80
+ next_revision = current.revision + 1
81
+ persisted = dict(data)
82
+ persisted[STATE_METADATA_KEY] = {
83
+ "schema_version": 1,
84
+ "revision": next_revision,
85
+ "operation_id": operation_id,
86
+ }
87
+ self._atomic_write(target, persisted)
88
+ return StateSnapshot(
89
+ path=target,
90
+ data=dict(data),
91
+ revision=next_revision,
92
+ operation_id=operation_id,
93
+ exists=True,
94
+ )
95
+
96
+ def delete(
97
+ self,
98
+ path: str | Path,
99
+ *,
100
+ expected_revision: int | None,
101
+ operation_id: str,
102
+ ) -> bool:
103
+ del operation_id
104
+ target = Path(path)
105
+ with self._lock(target):
106
+ current = self._load_unlocked(target, missing_ok=True)
107
+ if not current.exists:
108
+ return False
109
+ self._check_revision(target, current, expected_revision)
110
+ try:
111
+ target.unlink()
112
+ except OSError as error:
113
+ raise StateStoreError(
114
+ "STATE-SAVE-001",
115
+ target,
116
+ "state file could not be deleted",
117
+ ) from error
118
+ return True
119
+
120
+ def _load_unlocked(
121
+ self,
122
+ path: Path,
123
+ *,
124
+ missing_ok: bool,
125
+ ) -> StateSnapshot:
126
+ try:
127
+ raw_text = path.read_text(encoding="utf-8")
128
+ except FileNotFoundError as error:
129
+ if missing_ok:
130
+ return StateSnapshot(path, {}, 0, None, False)
131
+ raise StateStoreError(
132
+ "STATE-LOAD-001",
133
+ path,
134
+ "state file is missing",
135
+ ) from error
136
+ except UnicodeDecodeError as error:
137
+ raise StateStoreError(
138
+ "STATE-LOAD-003",
139
+ path,
140
+ "state file is not valid UTF-8",
141
+ ) from error
142
+ except OSError as error:
143
+ raise StateStoreError(
144
+ "STATE-LOAD-004",
145
+ path,
146
+ "state file could not be read",
147
+ ) from error
148
+
149
+ try:
150
+ raw = json.loads(raw_text)
151
+ except json.JSONDecodeError as error:
152
+ raise StateStoreError(
153
+ "STATE-LOAD-002",
154
+ path,
155
+ "state file is not valid JSON",
156
+ ) from error
157
+ if not isinstance(raw, dict):
158
+ raise StateStoreError(
159
+ "STATE-LOAD-002",
160
+ path,
161
+ "state file must contain a JSON object",
162
+ )
163
+
164
+ persisted = dict(raw)
165
+ metadata = persisted.pop(STATE_METADATA_KEY, {})
166
+ if not isinstance(metadata, dict):
167
+ raise StateStoreError(
168
+ "STATE-LOAD-005",
169
+ path,
170
+ "state metadata must be a JSON object",
171
+ )
172
+ revision = metadata.get("revision", 0)
173
+ if not isinstance(revision, int) or revision < 0:
174
+ raise StateStoreError(
175
+ "STATE-LOAD-005",
176
+ path,
177
+ "state revision must be a non-negative integer",
178
+ )
179
+ operation_id = metadata.get("operation_id")
180
+ if operation_id is not None and not isinstance(operation_id, str):
181
+ raise StateStoreError(
182
+ "STATE-LOAD-005",
183
+ path,
184
+ "state operation id must be a string",
185
+ )
186
+ return StateSnapshot(
187
+ path=path,
188
+ data=persisted,
189
+ revision=revision,
190
+ operation_id=operation_id,
191
+ exists=True,
192
+ )
193
+
194
+ @staticmethod
195
+ def _check_revision(
196
+ path: Path,
197
+ current: StateSnapshot,
198
+ expected_revision: int | None,
199
+ ) -> None:
200
+ if (
201
+ expected_revision is not None
202
+ and current.revision != expected_revision
203
+ ):
204
+ raise StateStoreError(
205
+ "STATE-CONFLICT-001",
206
+ path,
207
+ "expected revision "
208
+ f"{expected_revision}, found {current.revision}",
209
+ )
210
+
211
+ @staticmethod
212
+ def _atomic_write(path: Path, data: dict) -> None:
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+ temp_path = path.with_name(
215
+ f"{path.name}.{os.getpid()}.{time.time_ns()}.tmp"
216
+ )
217
+ json_text = json.dumps(
218
+ data,
219
+ indent=2,
220
+ ensure_ascii=False,
221
+ ) + "\n"
222
+ try:
223
+ temp_path.write_text(json_text, encoding="utf-8")
224
+ StateStore._replace_with_retry(temp_path, path)
225
+ except OSError as error:
226
+ try:
227
+ temp_path.unlink(missing_ok=True)
228
+ except OSError:
229
+ pass
230
+ raise StateStoreError(
231
+ "STATE-SAVE-001",
232
+ path,
233
+ "state file could not be written atomically",
234
+ ) from error
235
+
236
+ @staticmethod
237
+ def _replace_with_retry(source: Path, target: Path) -> None:
238
+ for attempt in range(ATOMIC_REPLACE_ATTEMPTS):
239
+ try:
240
+ os.replace(source, target)
241
+ return
242
+ except PermissionError:
243
+ if attempt == ATOMIC_REPLACE_ATTEMPTS - 1:
244
+ raise
245
+ time.sleep(
246
+ ATOMIC_REPLACE_RETRY_SECONDS * (2 ** attempt)
247
+ )
248
+
249
+ @contextmanager
250
+ def _lock(self, path: Path) -> Iterator[None]:
251
+ path.parent.mkdir(parents=True, exist_ok=True)
252
+ lock_path = path.with_name(f"{path.name}.lock")
253
+ deadline = time.monotonic() + self.lock_timeout_seconds
254
+ lock_handle = None
255
+ while lock_handle is None:
256
+ try:
257
+ lock_handle = lock_path.open("x", encoding="utf-8")
258
+ except FileExistsError as error:
259
+ if time.monotonic() >= deadline:
260
+ raise StateStoreError(
261
+ "STATE-LOCK-001",
262
+ path,
263
+ "timed out waiting for state lock",
264
+ ) from error
265
+ time.sleep(self.lock_poll_seconds)
266
+ except OSError as error:
267
+ raise StateStoreError(
268
+ "STATE-LOCK-001",
269
+ path,
270
+ "state lock could not be created",
271
+ ) from error
272
+ try:
273
+ yield
274
+ finally:
275
+ lock_handle.close()
276
+ try:
277
+ lock_path.unlink(missing_ok=True)
278
+ except OSError:
279
+ pass