arkaos 2.17.1 → 2.17.4
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/VERSION +1 -1
- package/arka/SKILL.md +9 -67
- package/arka/skills/comfyui/SKILL.md +50 -12
- package/arka/skills/conclave/SKILL.md +43 -141
- package/arka/skills/conclave/references/advisors.md +36 -0
- package/arka/skills/human-writing/SKILL.md +15 -100
- package/arka/skills/human-writing/references/forbidden-patterns.md +32 -0
- package/config/hooks/post-tool-use.sh +72 -0
- package/config/hooks/session-start.sh +16 -0
- package/config/hooks/user-prompt-submit.ps1 +2 -2
- package/config/hooks/user-prompt-submit.sh +102 -26
- package/core/agents/__pycache__/behavior_enforcer.cpython-313.pyc +0 -0
- package/core/agents/__pycache__/dna_registry.cpython-313.pyc +0 -0
- package/core/agents/adapters/__pycache__/disc_adapter.cpython-313.pyc +0 -0
- package/core/agents/adapters/disc_adapter.py +149 -0
- package/core/agents/behavior_enforcer.py +255 -0
- package/core/agents/dna_registry.py +235 -0
- package/core/forge/__init__.py +36 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
- package/core/forge/orchestrator.py +770 -0
- package/core/forge/runtime_dispatcher.py +465 -0
- package/core/governance/__pycache__/quality_api.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/quality_router.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
- package/core/governance/quality_api.py +280 -0
- package/core/governance/quality_router.py +304 -0
- package/core/governance/review_workflow.py +386 -0
- package/core/memory/__pycache__/compressor.cpython-313.pyc +0 -0
- package/core/memory/__pycache__/rehydrator.cpython-313.pyc +0 -0
- package/core/memory/__pycache__/session_store.cpython-313.pyc +0 -0
- package/core/memory/compressor.py +269 -0
- package/core/memory/rehydrator.py +204 -0
- package/core/memory/session_store.py +256 -0
- package/core/runtime/__pycache__/context_compactor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
- package/core/synapse/__init__.py +10 -3
- package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +27 -16
- package/core/synapse/kb_cache.py +382 -0
- package/core/synapse/layers.py +253 -50
- package/core/sync/__pycache__/self_healing.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/announcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/dashboard.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/state.cpython-313.pyc +0 -0
- package/core/workflow/announcer.py +246 -0
- package/core/workflow/dashboard.py +194 -0
- package/core/workflow/enforcer.py +234 -0
- package/core/workflow/recovery.py +196 -0
- package/core/workflow/rules_registry.py +484 -0
- package/core/workflow/session_summary.py +204 -0
- package/core/workflow/state.py +12 -2
- package/departments/dev/SKILL.md +10 -42
- package/departments/dev/skills/agent-design/SKILL.md +6 -26
- package/departments/dev/skills/ci-cd-pipeline/SKILL.md +6 -29
- package/departments/dev/skills/db-schema/SKILL.md +6 -24
- package/departments/dev/skills/dependency-audit/SKILL.md +1 -3
- package/departments/dev/skills/incident/SKILL.md +6 -24
- package/departments/dev/skills/mcp-builder/SKILL.md +4 -17
- package/departments/dev/skills/observability/SKILL.md +6 -29
- package/departments/dev/skills/performance-profiler/SKILL.md +5 -26
- package/departments/dev/skills/rag-architect/SKILL.md +5 -23
- package/departments/dev/skills/release/SKILL.md +4 -17
- package/departments/dev/skills/spec/SKILL.md +47 -148
- package/departments/landing/skills/landing-gen/SKILL.md +3 -15
- package/departments/marketing/skills/cold-email/SKILL.md +5 -17
- package/departments/marketing/skills/programmatic-seo/SKILL.md +6 -21
- package/departments/strategy/skills/board-advisor/SKILL.md +7 -21
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/synapse-bridge.py +27 -3
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Review Workflow Engine — structured review phases for Quality Gate.
|
|
2
|
+
|
|
3
|
+
Phases:
|
|
4
|
+
1. Submission — deliverable submitted for review
|
|
5
|
+
2. Triage — initial assessment, assign reviewers
|
|
6
|
+
3. Review — individual reviewer assessment
|
|
7
|
+
4. Deliberation — reviewers discuss findings
|
|
8
|
+
5. Verdict — final decision
|
|
9
|
+
6. Delivery — approved items proceed
|
|
10
|
+
|
|
11
|
+
Verdicts:
|
|
12
|
+
- APPROVED — passes all criteria, proceed to delivery
|
|
13
|
+
- REJECTED — fails critical criteria, must fix and resubmit
|
|
14
|
+
- REQUEST_CHANGES — minor issues, can fix and re-review
|
|
15
|
+
- ESCALATE — complex issue, requires Tier 0 decision
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReviewPhase(str, Enum):
|
|
26
|
+
SUBMISSION = "submission"
|
|
27
|
+
TRIAGE = "triage"
|
|
28
|
+
REVIEW = "review"
|
|
29
|
+
DELIBERATION = "deliberation"
|
|
30
|
+
VERDICT = "verdict"
|
|
31
|
+
DELIVERY = "delivery"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Verdict(str, Enum):
|
|
35
|
+
APPROVED = "APPROVED"
|
|
36
|
+
REJECTED = "REJECTED"
|
|
37
|
+
REQUEST_CHANGES = "REQUEST_CHANGES"
|
|
38
|
+
ESCALATE = "ESCALATE"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReviewerOpinion(str, Enum):
|
|
42
|
+
APPROVE = "APPROVE"
|
|
43
|
+
REJECT = "REJECT"
|
|
44
|
+
CHANGES = "REQUEST_CHANGES"
|
|
45
|
+
ABSTAIN = "ABSTAIN"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ReviewerVote:
|
|
50
|
+
"""A single reviewer's vote."""
|
|
51
|
+
|
|
52
|
+
reviewer: str
|
|
53
|
+
opinion: ReviewerOpinion
|
|
54
|
+
comments: str = ""
|
|
55
|
+
flagged_rules: list[str] = field(default_factory=list)
|
|
56
|
+
voted_at: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ReviewWorkflow:
|
|
61
|
+
"""A complete review workflow instance."""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
deliverable_title: str
|
|
65
|
+
deliverable_type: str
|
|
66
|
+
submitter: str
|
|
67
|
+
phase: ReviewPhase = ReviewPhase.SUBMISSION
|
|
68
|
+
verdict: Verdict | None = None
|
|
69
|
+
submitted_at: str = ""
|
|
70
|
+
triage_at: str = ""
|
|
71
|
+
review_started_at: str = ""
|
|
72
|
+
deliberation_at: str = ""
|
|
73
|
+
verdict_at: str = ""
|
|
74
|
+
delivered_at: str = ""
|
|
75
|
+
votes: list[ReviewerVote] = field(default_factory=list)
|
|
76
|
+
feedback: str = ""
|
|
77
|
+
metadata: dict = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
def __post_init__(self):
|
|
80
|
+
if not self.submitted_at:
|
|
81
|
+
self.submitted_at = datetime.now(timezone.utc).isoformat()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class PhaseResult:
|
|
86
|
+
"""Result of executing a review phase."""
|
|
87
|
+
|
|
88
|
+
phase: ReviewPhase
|
|
89
|
+
success: bool
|
|
90
|
+
output: str = ""
|
|
91
|
+
next_phase: ReviewPhase | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ReviewWorkflowEngine:
|
|
95
|
+
"""Executes structured review workflows through Quality Gate.
|
|
96
|
+
|
|
97
|
+
Enforces Constitution rules:
|
|
98
|
+
- No delivery without Quality Gate approval
|
|
99
|
+
- BLOCK violations require escalation to Tier 0
|
|
100
|
+
- Mandatory QA runs before verdict
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
on_phase_start: Callable[[ReviewPhase, str], None] | None = None,
|
|
106
|
+
on_phase_complete: Callable[[ReviewPhase, PhaseResult], None] | None = None,
|
|
107
|
+
on_verdict: Callable[[Verdict, str], None] | None = None,
|
|
108
|
+
):
|
|
109
|
+
self._on_phase_start = on_phase_start
|
|
110
|
+
self._on_phase_complete = on_phase_complete
|
|
111
|
+
self._on_verdict = on_verdict
|
|
112
|
+
self._workflows: dict[str, ReviewWorkflow] = {}
|
|
113
|
+
|
|
114
|
+
def submit(
|
|
115
|
+
self,
|
|
116
|
+
deliverable_title: str,
|
|
117
|
+
deliverable_type: str,
|
|
118
|
+
submitter: str,
|
|
119
|
+
metadata: dict | None = None,
|
|
120
|
+
) -> ReviewWorkflow:
|
|
121
|
+
"""Submit a deliverable for Quality Gate review.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
deliverable_title: Title of the deliverable
|
|
125
|
+
deliverable_type: Type of deliverable (code, copy, architecture, etc.)
|
|
126
|
+
submitter: Agent ID who submitted
|
|
127
|
+
metadata: Additional context
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
ReviewWorkflow in SUBMISSION phase
|
|
131
|
+
"""
|
|
132
|
+
workflow = ReviewWorkflow(
|
|
133
|
+
id=str(uuid.uuid4()),
|
|
134
|
+
deliverable_title=deliverable_title,
|
|
135
|
+
deliverable_type=deliverable_type,
|
|
136
|
+
submitter=submitter,
|
|
137
|
+
metadata=metadata or {},
|
|
138
|
+
)
|
|
139
|
+
self._workflows[workflow.id] = workflow
|
|
140
|
+
return workflow
|
|
141
|
+
|
|
142
|
+
def start_triage(self, workflow_id: str) -> PhaseResult:
|
|
143
|
+
"""Start triage phase — assign reviewers.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
workflow_id: ID of the workflow to triage
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
PhaseResult with triage outcome
|
|
150
|
+
"""
|
|
151
|
+
workflow = self._workflows.get(workflow_id)
|
|
152
|
+
if not workflow:
|
|
153
|
+
return PhaseResult(phase=ReviewPhase.TRIAGE, success=False, output="Workflow not found")
|
|
154
|
+
|
|
155
|
+
self._announce_phase_start(ReviewPhase.TRIAGE, workflow_id)
|
|
156
|
+
|
|
157
|
+
workflow.phase = ReviewPhase.TRIAGE
|
|
158
|
+
workflow.triage_at = datetime.now(timezone.utc).isoformat()
|
|
159
|
+
|
|
160
|
+
result = PhaseResult(
|
|
161
|
+
phase=ReviewPhase.TRIAGE,
|
|
162
|
+
success=True,
|
|
163
|
+
output=f"Triage complete for: {workflow.deliverable_title}",
|
|
164
|
+
next_phase=ReviewPhase.REVIEW,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
self._announce_phase_complete(ReviewPhase.TRIAGE, result)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
def start_review(self, workflow_id: str) -> PhaseResult:
|
|
171
|
+
"""Start the review phase.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
workflow_id: ID of the workflow to review
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
PhaseResult with review start info
|
|
178
|
+
"""
|
|
179
|
+
workflow = self._workflows.get(workflow_id)
|
|
180
|
+
if not workflow:
|
|
181
|
+
return PhaseResult(phase=ReviewPhase.REVIEW, success=False, output="Workflow not found")
|
|
182
|
+
|
|
183
|
+
if workflow.phase != ReviewPhase.TRIAGE:
|
|
184
|
+
return PhaseResult(
|
|
185
|
+
phase=ReviewPhase.REVIEW,
|
|
186
|
+
success=False,
|
|
187
|
+
output="Must complete triage before review",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self._announce_phase_start(ReviewPhase.REVIEW, workflow_id)
|
|
191
|
+
|
|
192
|
+
workflow.phase = ReviewPhase.REVIEW
|
|
193
|
+
workflow.review_started_at = datetime.now(timezone.utc).isoformat()
|
|
194
|
+
|
|
195
|
+
result = PhaseResult(
|
|
196
|
+
phase=ReviewPhase.REVIEW,
|
|
197
|
+
success=True,
|
|
198
|
+
output=f"Review started for: {workflow.deliverable_title}",
|
|
199
|
+
next_phase=ReviewPhase.DELIBERATION,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self._announce_phase_complete(ReviewPhase.REVIEW, result)
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
def record_vote(
|
|
206
|
+
self,
|
|
207
|
+
workflow_id: str,
|
|
208
|
+
reviewer: str,
|
|
209
|
+
opinion: ReviewerOpinion,
|
|
210
|
+
comments: str = "",
|
|
211
|
+
flagged_rules: list[str] | None = None,
|
|
212
|
+
) -> bool:
|
|
213
|
+
"""Record a reviewer's vote.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
workflow_id: ID of the workflow
|
|
217
|
+
reviewer: Agent ID of reviewer
|
|
218
|
+
opinion: Their vote
|
|
219
|
+
comments: Optional comments
|
|
220
|
+
flagged_rules: Constitution rules that were violated
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True if vote recorded successfully
|
|
224
|
+
"""
|
|
225
|
+
workflow = self._workflows.get(workflow_id)
|
|
226
|
+
if not workflow:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
if workflow.phase not in (ReviewPhase.REVIEW, ReviewPhase.DELIBERATION):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
vote = ReviewerVote(
|
|
233
|
+
reviewer=reviewer,
|
|
234
|
+
opinion=opinion,
|
|
235
|
+
comments=comments,
|
|
236
|
+
flagged_rules=flagged_rules or [],
|
|
237
|
+
voted_at=datetime.now(timezone.utc).isoformat(),
|
|
238
|
+
)
|
|
239
|
+
workflow.votes.append(vote)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def start_deliberation(self, workflow_id: str) -> PhaseResult:
|
|
243
|
+
"""Start deliberation phase — reviewers discuss findings.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
workflow_id: ID of the workflow
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
PhaseResult with deliberation outcome
|
|
250
|
+
"""
|
|
251
|
+
workflow = self._workflows.get(workflow_id)
|
|
252
|
+
if not workflow:
|
|
253
|
+
return PhaseResult(
|
|
254
|
+
phase=ReviewPhase.DELIBERATION, success=False, output="Workflow not found"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if not workflow.votes:
|
|
258
|
+
return PhaseResult(
|
|
259
|
+
phase=ReviewPhase.DELIBERATION,
|
|
260
|
+
success=False,
|
|
261
|
+
output="No votes recorded yet",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
self._announce_phase_start(ReviewPhase.DELIBERATION, workflow_id)
|
|
265
|
+
|
|
266
|
+
workflow.phase = ReviewPhase.DELIBERATION
|
|
267
|
+
workflow.deliberation_at = datetime.now(timezone.utc).isoformat()
|
|
268
|
+
|
|
269
|
+
result = PhaseResult(
|
|
270
|
+
phase=ReviewPhase.DELIBERATION,
|
|
271
|
+
success=True,
|
|
272
|
+
output=f"Deliberation started with {len(workflow.votes)} votes",
|
|
273
|
+
next_phase=ReviewPhase.VERDICT,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
self._announce_phase_complete(ReviewPhase.DELIBERATION, result)
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
def reach_verdict(self, workflow_id: str, verdict: Verdict, feedback: str = "") -> PhaseResult:
|
|
280
|
+
"""Record the final verdict.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
workflow_id: ID of the workflow
|
|
284
|
+
verdict: Final verdict
|
|
285
|
+
feedback: Feedback for the submitter
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
PhaseResult with verdict outcome
|
|
289
|
+
"""
|
|
290
|
+
workflow = self._workflows.get(workflow_id)
|
|
291
|
+
if not workflow:
|
|
292
|
+
return PhaseResult(
|
|
293
|
+
phase=ReviewPhase.VERDICT, success=False, output="Workflow not found"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
self._announce_phase_start(ReviewPhase.VERDICT, workflow_id)
|
|
297
|
+
|
|
298
|
+
workflow.phase = ReviewPhase.VERDICT
|
|
299
|
+
workflow.verdict = verdict
|
|
300
|
+
workflow.feedback = feedback
|
|
301
|
+
workflow.verdict_at = datetime.now(timezone.utc).isoformat()
|
|
302
|
+
|
|
303
|
+
self._announce_verdict(verdict, workflow_id)
|
|
304
|
+
|
|
305
|
+
next_phase = ReviewPhase.DELIVERY if verdict == Verdict.APPROVED else None
|
|
306
|
+
|
|
307
|
+
result = PhaseResult(
|
|
308
|
+
phase=ReviewPhase.VERDICT,
|
|
309
|
+
success=True,
|
|
310
|
+
output=f"Verdict: {verdict.value} — {feedback}",
|
|
311
|
+
next_phase=next_phase,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
self._announce_phase_complete(ReviewPhase.VERDICT, result)
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
def deliver(self, workflow_id: str) -> PhaseResult:
|
|
318
|
+
"""Mark workflow as delivered (post-approval).
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
workflow_id: ID of the workflow
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
PhaseResult with delivery outcome
|
|
325
|
+
"""
|
|
326
|
+
workflow = self._workflows.get(workflow_id)
|
|
327
|
+
if not workflow:
|
|
328
|
+
return PhaseResult(
|
|
329
|
+
phase=ReviewPhase.DELIVERY, success=False, output="Workflow not found"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if workflow.verdict != Verdict.APPROVED:
|
|
333
|
+
return PhaseResult(
|
|
334
|
+
phase=ReviewPhase.DELIVERY,
|
|
335
|
+
success=False,
|
|
336
|
+
output=f"Cannot deliver: verdict is {workflow.verdict}",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
workflow.phase = ReviewPhase.DELIVERY
|
|
340
|
+
workflow.delivered_at = datetime.now(timezone.utc).isoformat()
|
|
341
|
+
|
|
342
|
+
result = PhaseResult(
|
|
343
|
+
phase=ReviewPhase.DELIVERY,
|
|
344
|
+
success=True,
|
|
345
|
+
output=f"Delivered: {workflow.deliverable_title}",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
self._announce_phase_complete(ReviewPhase.DELIVERY, result)
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
def get_workflow(self, workflow_id: str) -> ReviewWorkflow | None:
|
|
352
|
+
"""Get a workflow by ID."""
|
|
353
|
+
return self._workflows.get(workflow_id)
|
|
354
|
+
|
|
355
|
+
def get_all_workflows(self) -> list[ReviewWorkflow]:
|
|
356
|
+
"""Get all workflows."""
|
|
357
|
+
return list(self._workflows.values())
|
|
358
|
+
|
|
359
|
+
def get_workflows_by_phase(self, phase: ReviewPhase) -> list[ReviewWorkflow]:
|
|
360
|
+
"""Get all workflows in a specific phase."""
|
|
361
|
+
return [w for w in self._workflows.values() if w.phase == phase]
|
|
362
|
+
|
|
363
|
+
def _announce_phase_start(self, phase: ReviewPhase, workflow_id: str) -> None:
|
|
364
|
+
if self._on_phase_start:
|
|
365
|
+
self._on_phase_start(phase, workflow_id)
|
|
366
|
+
|
|
367
|
+
def _announce_phase_complete(self, phase: ReviewPhase, result: PhaseResult) -> None:
|
|
368
|
+
if self._on_phase_complete:
|
|
369
|
+
self._on_phase_complete(phase, result)
|
|
370
|
+
|
|
371
|
+
def _announce_verdict(self, verdict: Verdict, workflow_id: str) -> None:
|
|
372
|
+
if self._on_verdict:
|
|
373
|
+
self._on_verdict(verdict, workflow_id)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def create_review(
|
|
377
|
+
deliverable_title: str,
|
|
378
|
+
deliverable_type: str,
|
|
379
|
+
submitter: str,
|
|
380
|
+
) -> ReviewWorkflow:
|
|
381
|
+
"""Convenience function to create a review workflow."""
|
|
382
|
+
engine = ReviewWorkflowEngine()
|
|
383
|
+
workflow = engine.submit(deliverable_title, deliverable_type, submitter)
|
|
384
|
+
engine.start_triage(workflow.id)
|
|
385
|
+
engine.start_review(workflow.id)
|
|
386
|
+
return workflow
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Memory Compression — archive old sessions, keep hot data accessible.
|
|
2
|
+
|
|
3
|
+
Hot data (last 7 days) stays uncompressed in ~/.arkaos/sessions/
|
|
4
|
+
Cold data (older than 7 days) gets compressed to ~/.arkaos/archive/
|
|
5
|
+
|
|
6
|
+
Compression is lossless — all JSON is preserved, just compressed with gzip.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import gzip
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
HOT_DAYS = 7
|
|
18
|
+
SESSIONS_DIR = Path.home() / ".arkaos" / "sessions"
|
|
19
|
+
ARCHIVE_DIR = Path.home() / ".arkaos" / "archive"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ensure_archive_dir() -> Path:
|
|
23
|
+
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return ARCHIVE_DIR
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_hot(session_dir: Path) -> bool:
|
|
28
|
+
"""Check if a session is still hot (within hot days)."""
|
|
29
|
+
meta_file = session_dir / "session-meta.json"
|
|
30
|
+
if not meta_file.exists():
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
35
|
+
ended_at = data.get("ended_at", data.get("started_at", ""))
|
|
36
|
+
if not ended_at:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
dt = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
|
40
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=HOT_DAYS)
|
|
41
|
+
return dt > cutoff
|
|
42
|
+
except (json.JSONDecodeError, ValueError, KeyError, OSError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _compress_session(session_dir: Path) -> Path:
|
|
47
|
+
"""Compress a session directory to archive.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
session_dir: Path to session directory
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to compressed archive file
|
|
54
|
+
"""
|
|
55
|
+
archive_dir = _ensure_archive_dir()
|
|
56
|
+
archive_file = archive_dir / f"{session_dir.name}.tar.gz"
|
|
57
|
+
|
|
58
|
+
if archive_file.exists():
|
|
59
|
+
archive_file.unlink()
|
|
60
|
+
|
|
61
|
+
shutil.make_archive(
|
|
62
|
+
str(archive_dir / session_dir.name),
|
|
63
|
+
format="gztar",
|
|
64
|
+
root_dir=session_dir.parent,
|
|
65
|
+
base_dir=session_dir.name,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return archive_file
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _decompress_session(archive_file: Path, target_dir: Path) -> Path:
|
|
72
|
+
"""Decompress a session from archive.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
archive_file: Path to .tar.gz archive
|
|
76
|
+
target_dir: Directory to extract into (should be SESSIONS_DIR)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Path to decompressed session directory (target_dir / session_id)
|
|
80
|
+
"""
|
|
81
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
shutil.unpack_archive(archive_file, target_dir)
|
|
83
|
+
|
|
84
|
+
import tarfile
|
|
85
|
+
|
|
86
|
+
with tarfile.open(archive_file, "r:gz") as tf:
|
|
87
|
+
names = tf.getnames()
|
|
88
|
+
if names:
|
|
89
|
+
session_name = names[0]
|
|
90
|
+
return target_dir / session_name
|
|
91
|
+
|
|
92
|
+
return target_dir
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compress_old_sessions() -> dict[str, Any]:
|
|
96
|
+
"""Compress all cold sessions to archive.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dict with counts of archived and remaining sessions
|
|
100
|
+
"""
|
|
101
|
+
if not SESSIONS_DIR.exists():
|
|
102
|
+
return {"archived": 0, "remaining": 0, "errors": []}
|
|
103
|
+
|
|
104
|
+
archived = 0
|
|
105
|
+
remaining = 0
|
|
106
|
+
errors = []
|
|
107
|
+
|
|
108
|
+
for session_dir in list(SESSIONS_DIR.iterdir()):
|
|
109
|
+
if not session_dir.is_dir():
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if _is_hot(session_dir):
|
|
113
|
+
remaining += 1
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
archive_file = _compress_session(session_dir)
|
|
118
|
+
shutil.rmtree(session_dir)
|
|
119
|
+
archived += 1
|
|
120
|
+
except Exception as e:
|
|
121
|
+
errors.append(f"{session_dir.name}: {str(e)}")
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"archived": archived,
|
|
125
|
+
"remaining": remaining,
|
|
126
|
+
"errors": errors,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def restore_session(session_id: str) -> Path | None:
|
|
131
|
+
"""Restore a session from archive.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
session_id: Session UUID to restore
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Path to restored session directory, or None if not found
|
|
138
|
+
"""
|
|
139
|
+
archive_file = ARCHIVE_DIR / f"{session_id}.tar.gz"
|
|
140
|
+
if not archive_file.exists():
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
target_dir = SESSIONS_DIR / session_id
|
|
144
|
+
if target_dir.exists():
|
|
145
|
+
return target_dir
|
|
146
|
+
|
|
147
|
+
return _decompress_session(archive_file, SESSIONS_DIR)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def list_archived_sessions(limit: int = 20) -> list[dict[str, Any]]:
|
|
151
|
+
"""List sessions in archive.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
limit: Maximum number to return
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List of archived session info dicts
|
|
158
|
+
"""
|
|
159
|
+
if not ARCHIVE_DIR.exists():
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
archived = []
|
|
163
|
+
for archive_file in sorted(
|
|
164
|
+
ARCHIVE_DIR.glob("*.tar.gz"),
|
|
165
|
+
key=lambda p: p.stat().st_mtime,
|
|
166
|
+
reverse=True,
|
|
167
|
+
)[:limit]:
|
|
168
|
+
try:
|
|
169
|
+
stat = archive_file.stat()
|
|
170
|
+
archived.append(
|
|
171
|
+
{
|
|
172
|
+
"session_id": archive_file.name[:-7],
|
|
173
|
+
"archived_at": datetime.fromtimestamp(
|
|
174
|
+
stat.st_mtime, tz=timezone.utc
|
|
175
|
+
).isoformat(),
|
|
176
|
+
"size_bytes": stat.st_size,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
except OSError:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
return archived
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def prune_archive(older_than_days: int = 90) -> int:
|
|
186
|
+
"""Delete archives older than specified days.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
older_than_days: Delete archives older than this many days
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Number of archives deleted
|
|
193
|
+
"""
|
|
194
|
+
if not ARCHIVE_DIR.exists():
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
|
198
|
+
deleted = 0
|
|
199
|
+
|
|
200
|
+
for archive_file in list(ARCHIVE_DIR.glob("*.tar.gz")):
|
|
201
|
+
try:
|
|
202
|
+
mtime = datetime.fromtimestamp(archive_file.stat().st_mtime, tz=timezone.utc)
|
|
203
|
+
if mtime < cutoff:
|
|
204
|
+
archive_file.unlink()
|
|
205
|
+
deleted += 1
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
return deleted
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_storage_stats() -> dict[str, Any]:
|
|
213
|
+
"""Get storage usage statistics.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Dict with session counts, archive size, etc.
|
|
217
|
+
"""
|
|
218
|
+
sessions_count = 0
|
|
219
|
+
sessions_size = 0
|
|
220
|
+
hot_count = 0
|
|
221
|
+
|
|
222
|
+
if SESSIONS_DIR.exists():
|
|
223
|
+
for session_dir in SESSIONS_DIR.iterdir():
|
|
224
|
+
if session_dir.is_dir():
|
|
225
|
+
sessions_count += 1
|
|
226
|
+
if _is_hot(session_dir):
|
|
227
|
+
hot_count += 1
|
|
228
|
+
sessions_size += sum(
|
|
229
|
+
f.stat().st_size for f in session_dir.rglob("*") if f.is_file()
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
archive_size = 0
|
|
233
|
+
archive_count = 0
|
|
234
|
+
if ARCHIVE_DIR.exists():
|
|
235
|
+
for archive_file in ARCHIVE_DIR.glob("*.tar.gz"):
|
|
236
|
+
archive_count += 1
|
|
237
|
+
archive_size += archive_file.stat().st_size
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"sessions": {
|
|
241
|
+
"total": sessions_count,
|
|
242
|
+
"hot": hot_count,
|
|
243
|
+
"cold": sessions_count - hot_count,
|
|
244
|
+
"size_bytes": sessions_size,
|
|
245
|
+
},
|
|
246
|
+
"archive": {
|
|
247
|
+
"count": archive_count,
|
|
248
|
+
"size_bytes": archive_size,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class SessionCompressor:
|
|
254
|
+
"""Class-based interface for compression operations."""
|
|
255
|
+
|
|
256
|
+
def compress_old(self) -> dict[str, Any]:
|
|
257
|
+
return compress_old_sessions()
|
|
258
|
+
|
|
259
|
+
def restore(self, session_id: str) -> Path | None:
|
|
260
|
+
return restore_session(session_id)
|
|
261
|
+
|
|
262
|
+
def list_archived(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
263
|
+
return list_archived_sessions(limit)
|
|
264
|
+
|
|
265
|
+
def prune(self, older_than_days: int = 90) -> int:
|
|
266
|
+
return prune_archive(older_than_days)
|
|
267
|
+
|
|
268
|
+
def stats(self) -> dict[str, Any]:
|
|
269
|
+
return get_storage_stats()
|