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.
- package/README.md +27 -14
- package/package.json +1 -1
- package/src/commands/init.js +39 -28
- package/src/commands/sync.js +2 -2
- package/src/lib/asset-plan.js +127 -0
- package/src/lib/copy-template.js +101 -141
- package/src/lib/host-assets.js +231 -0
- package/src/lib/plan-applier.js +329 -0
- package/src/lib/platforms.js +15 -76
- package/src/lib/skill-registry.js +503 -0
- package/template/.claude/agents/cowork-check.md +10 -1
- package/template/.claude/agents/cowork-implement.md +11 -2
- package/template/.claude/hooks/inject-workflow-state.py +51 -254
- package/template/.codex/agents/cowork-check.toml +12 -3
- package/template/.codex/agents/cowork-implement.toml +11 -2
- package/template/.codex/agents/default.toml +3 -2
- package/template/.codex/agents/explorer.toml +3 -2
- package/template/.codex/agents/worker.toml +2 -1
- package/template/.codex/hooks/inject-workflow-state.py +46 -265
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/__init__.py +1 -0
- package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
- package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
- package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
- package/template/.cowork-flow/scripts/application/task_context.py +765 -0
- package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
- package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
- package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
- package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
- package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
- package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
- package/template/.cowork-flow/scripts/commands/task.py +535 -1953
- package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
- package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
- package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
- package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
- package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
- package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
- package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
- package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
- package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
- package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
- package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
- package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
- package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
- package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
- package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
- package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
- package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
- package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
- package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
- package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
- package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
- package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
- package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
- package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
- package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
- package/template/.cowork-flow/scripts/run.py +24 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
- package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
- package/template/.cowork-flow/spec/protocols/review.md +34 -0
- package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
- package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
- package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
- package/template/.cowork-flow/spec/runtime/index.md +8 -0
- package/template/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
- package/template/.cowork-flow/workflow.md +18 -13
- package/template/.opencode/agents/cowork-check.md +11 -0
- package/template/.opencode/agents/cowork-implement.md +16 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +3 -85
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
- package/template/.zcode/scaffold/AGENTS.md +18 -8
- package/template/AGENTS.md +1 -1
- package/template/skills/batch-mode/SKILL.md +140 -76
- package/template/skills/brainstorming/SKILL.md +2 -2
- package/template/skills/cowork-flow/SKILL.md +42 -0
- package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
- package/template/skills/party-mode/SKILL.md +48 -119
- package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
- package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
- package/template/skills/before-dev/SKILL.md +0 -41
- package/template/skills/check/SKILL.md +0 -53
- package/template/skills/check/simplification-guide.md +0 -47
- package/template/skills/continue/SKILL.md +0 -29
- package/template/skills/doubt-review/SKILL.md +0 -118
- package/template/skills/finish-work/SKILL.md +0 -32
- package/template/skills/party-mode-v2/SKILL.md +0 -75
- package/template/skills/start/SKILL.md +0 -78
- package/template/skills/tdd/SKILL.md +0 -74
- package/template/skills/update-spec/SKILL.md +0 -39
- 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
|