imagen-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.
Files changed (119) hide show
  1. imagen_cli/__init__.py +0 -0
  2. imagen_cli/__main__.py +3 -0
  3. imagen_cli/application/approval_service.py +36 -0
  4. imagen_cli/application/artifact_service.py +97 -0
  5. imagen_cli/application/batch_service.py +190 -0
  6. imagen_cli/application/batch_summary.py +66 -0
  7. imagen_cli/application/cache_service.py +262 -0
  8. imagen_cli/application/cancel_service.py +91 -0
  9. imagen_cli/application/cleanup_service.py +161 -0
  10. imagen_cli/application/container.py +786 -0
  11. imagen_cli/application/cost_service.py +25 -0
  12. imagen_cli/application/execution_service.py +177 -0
  13. imagen_cli/application/export_service.py +339 -0
  14. imagen_cli/application/filters_advanced.py +540 -0
  15. imagen_cli/application/filters_fast.py +66 -0
  16. imagen_cli/application/filters_frequency.py +92 -0
  17. imagen_cli/application/filters_numpy.py +467 -0
  18. imagen_cli/application/filters_rank.py +50 -0
  19. imagen_cli/application/filters_restoration.py +327 -0
  20. imagen_cli/application/filters_structure.py +223 -0
  21. imagen_cli/application/fork_service.py +52 -0
  22. imagen_cli/application/import_service.py +608 -0
  23. imagen_cli/application/job_state.py +41 -0
  24. imagen_cli/application/media_ops.py +2163 -0
  25. imagen_cli/application/model_catalog_service.py +153 -0
  26. imagen_cli/application/opener.py +25 -0
  27. imagen_cli/application/operator_catalog.py +2997 -0
  28. imagen_cli/application/operator_catalog_en.py +1859 -0
  29. imagen_cli/application/outcome_service.py +160 -0
  30. imagen_cli/application/planning_service.py +268 -0
  31. imagen_cli/application/postprocess_service.py +479 -0
  32. imagen_cli/application/postprocess_worker.py +92 -0
  33. imagen_cli/application/preflight.py +123 -0
  34. imagen_cli/application/profile_service.py +53 -0
  35. imagen_cli/application/quality_service.py +49 -0
  36. imagen_cli/application/reconcile.py +84 -0
  37. imagen_cli/application/release_readiness.py +56 -0
  38. imagen_cli/application/session_service.py +253 -0
  39. imagen_cli/application/sheet_service.py +97 -0
  40. imagen_cli/application/skill_targets.NOTICE.md +33 -0
  41. imagen_cli/application/skill_targets.json +397 -0
  42. imagen_cli/application/skill_targets.py +42 -0
  43. imagen_cli/application/skills_service.py +416 -0
  44. imagen_cli/application/superres.py +500 -0
  45. imagen_cli/application/update_service.py +123 -0
  46. imagen_cli/bundled_skills/imagen/SKILL.md +46 -0
  47. imagen_cli/bundled_skills/imagen/references/workflows.md +66 -0
  48. imagen_cli/bundled_skills/imagen-assets/SKILL.md +64 -0
  49. imagen_cli/bundled_skills/imagen-batch/SKILL.md +61 -0
  50. imagen_cli/cli/commands/asset.py +58 -0
  51. imagen_cli/cli/commands/batch.py +25 -0
  52. imagen_cli/cli/commands/cache.py +59 -0
  53. imagen_cli/cli/commands/create.py +179 -0
  54. imagen_cli/cli/commands/discovery.py +51 -0
  55. imagen_cli/cli/commands/history.py +156 -0
  56. imagen_cli/cli/commands/job.py +165 -0
  57. imagen_cli/cli/commands/models.py +69 -0
  58. imagen_cli/cli/commands/plan.py +22 -0
  59. imagen_cli/cli/commands/postprocess.py +125 -0
  60. imagen_cli/cli/commands/profile.py +141 -0
  61. imagen_cli/cli/commands/session.py +79 -0
  62. imagen_cli/cli/commands/skills.py +42 -0
  63. imagen_cli/cli/commands/worker.py +33 -0
  64. imagen_cli/cli/creation_input.py +35 -0
  65. imagen_cli/cli/diagnostics.py +62 -0
  66. imagen_cli/cli/entrypoint.py +705 -0
  67. imagen_cli/cli/generated_docs.py +337 -0
  68. imagen_cli/cli/human.py +715 -0
  69. imagen_cli/cli/input_readers.py +152 -0
  70. imagen_cli/cli/options.py +75 -0
  71. imagen_cli/cli/preview.py +32 -0
  72. imagen_cli/cli/registry.py +837 -0
  73. imagen_cli/cli/renderers.py +191 -0
  74. imagen_cli/cli/selection.py +27 -0
  75. imagen_cli/cli/tui/__init__.py +0 -0
  76. imagen_cli/cli/tui/app.py +2691 -0
  77. imagen_cli/cli/tui/preview.py +54 -0
  78. imagen_cli/cli/usage.py +177 -0
  79. imagen_cli/domain/__init__.py +1 -0
  80. imagen_cli/domain/contracts.py +123 -0
  81. imagen_cli/domain/enums.py +143 -0
  82. imagen_cli/domain/errors.py +32 -0
  83. imagen_cli/domain/models.py +392 -0
  84. imagen_cli/domain/primitive.py +80 -0
  85. imagen_cli/execution/configured.py +72 -0
  86. imagen_cli/execution/foreground.py +106 -0
  87. imagen_cli/execution/queued.py +110 -0
  88. imagen_cli/execution/recovery.py +67 -0
  89. imagen_cli/execution/worker.py +448 -0
  90. imagen_cli/i18n.py +145 -0
  91. imagen_cli/providers/automatic1111.py +172 -0
  92. imagen_cli/providers/base.py +146 -0
  93. imagen_cli/providers/bfl.py +240 -0
  94. imagen_cli/providers/comfyui.py +228 -0
  95. imagen_cli/providers/common.py +173 -0
  96. imagen_cli/providers/contracts.py +34 -0
  97. imagen_cli/providers/gemini.py +263 -0
  98. imagen_cli/providers/google_cloud.py +303 -0
  99. imagen_cli/providers/model_platforms.py +574 -0
  100. imagen_cli/providers/openai_compatible.py +160 -0
  101. imagen_cli/providers/openai_images.py +236 -0
  102. imagen_cli/providers/openai_responses.py +155 -0
  103. imagen_cli/providers/openrouter.py +142 -0
  104. imagen_cli/providers/stability.py +193 -0
  105. imagen_cli/storage/artifacts.py +164 -0
  106. imagen_cli/storage/config.py +23 -0
  107. imagen_cli/storage/database.py +48 -0
  108. imagen_cli/storage/download.py +165 -0
  109. imagen_cli/storage/migrations.py +138 -0
  110. imagen_cli/storage/private_state.py +193 -0
  111. imagen_cli/storage/queries.py +70 -0
  112. imagen_cli/storage/repositories.py +410 -0
  113. imagen_cli/storage/safety.py +451 -0
  114. imagen_cli/storage/workspace.py +26 -0
  115. imagen_cli-0.1.1.dist-info/METADATA +586 -0
  116. imagen_cli-0.1.1.dist-info/RECORD +119 -0
  117. imagen_cli-0.1.1.dist-info/WHEEL +4 -0
  118. imagen_cli-0.1.1.dist-info/entry_points.txt +2 -0
  119. imagen_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
imagen_cli/__init__.py ADDED
File without changes
imagen_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from imagen_cli.cli.entrypoint import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,36 @@
1
+ """Approval lifecycle facade: pending, approved, consumed, expired, invalidated."""
2
+
3
+ from typing import Any
4
+
5
+ from imagen_cli.application.planning_service import PlanningService
6
+ from imagen_cli.storage.repositories import Repositories
7
+
8
+ __all__ = ["ApprovalService"]
9
+
10
+
11
+ class ApprovalService:
12
+ def __init__(self, repositories: Repositories) -> None:
13
+ self.repositories = repositories
14
+ self.planning = PlanningService(repositories)
15
+
16
+ def approve(self, plan_id: str, digest: str) -> dict[str, Any]:
17
+ return self.planning.approve(plan_id, digest).model_dump()
18
+
19
+ def state(self, plan_id: str) -> str:
20
+ rows = self.repositories.list("approvals", plan_id=plan_id)
21
+ if not rows:
22
+ raise KeyError(plan_id)
23
+ return str(rows[0]["state"])
24
+
25
+ def invalidate(self, plan_id: str, reason: str) -> str:
26
+ """Only an unconsumed approval may be invalidated; execution rights never return."""
27
+ rows = self.repositories.list("approvals", plan_id=plan_id)
28
+ if not rows:
29
+ raise KeyError(plan_id)
30
+ approval = rows[0]
31
+ if approval["state"] == "consumed":
32
+ raise ValueError("consumed approval is immutable")
33
+ self.repositories.update(
34
+ "approvals", approval["id"], {"state": "invalidated"},
35
+ )
36
+ return "invalidated"
@@ -0,0 +1,97 @@
1
+ """Save existing provider output through the shared durable artifact store."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from imagen_cli.domain.primitive import new_id
7
+ from imagen_cli.providers.base import ArtifactDescriptor
8
+ from imagen_cli.storage.artifacts import ArtifactStore
9
+ from imagen_cli.storage.repositories import Repositories
10
+
11
+
12
+ class ArtifactService:
13
+ def __init__(self, repositories: Repositories, artifact_store: ArtifactStore) -> None:
14
+ self.repositories = repositories
15
+ self.artifact_store = artifact_store
16
+
17
+ def _job_turn(self, job_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
18
+ job = self.repositories.get("jobs", job_id)
19
+ if job is None:
20
+ raise KeyError(job_id)
21
+ turn = self.repositories.get("turns", job["turn_id"])
22
+ if turn is None or turn.get("job_id") != job_id:
23
+ raise ValueError("job must belong to its producer turn")
24
+ return job, turn
25
+
26
+ def _reference(self, row: dict[str, Any]) -> None:
27
+ _, turn = self._job_turn(row["producer_job_id"])
28
+ index = row["origin_manifest"]["output_index"]
29
+ refs = self.repositories.list(
30
+ "artifact_references",
31
+ turn_id=turn["id"],
32
+ role="output",
33
+ index=index,
34
+ )
35
+ if refs:
36
+ if refs[0]["artifact_id"] != row["id"]:
37
+ raise ValueError("output slot already occupied")
38
+ return
39
+ self.repositories.insert(
40
+ "artifact_references",
41
+ {
42
+ "session_id": turn["session_id"],
43
+ "turn_id": turn["id"],
44
+ "artifact_id": row["id"],
45
+ "role": "output",
46
+ "index": index,
47
+ },
48
+ )
49
+
50
+ def save(
51
+ self,
52
+ job_id: str,
53
+ data: bytes,
54
+ *,
55
+ artifact_id: str | None = None,
56
+ index: int | None = None,
57
+ ) -> dict[str, Any]:
58
+ _, turn = self._job_turn(job_id)
59
+ with self.repositories.transaction():
60
+ refs = self.repositories.list("artifact_references", turn_id=turn["id"], role="output")
61
+ if index is None:
62
+ index = max((ref["index"] for ref in refs), default=-1) + 1
63
+ if type(index) is not int or index < 0:
64
+ raise ValueError("invalid output index")
65
+ if any(ref["index"] == index for ref in refs):
66
+ raise ValueError("output slot already occupied")
67
+ row = self.artifact_store.stage(
68
+ artifact_id or new_id("art"),
69
+ data,
70
+ producer_job_id=job_id,
71
+ producer_turn_id=turn["id"],
72
+ origin_manifest={"kind": "provider_output", "output_index": index},
73
+ )
74
+ self._reference(row)
75
+ return dict(self.artifact_store.publish(row["id"]))
76
+
77
+ def fetch(
78
+ self,
79
+ job_id: str,
80
+ descriptor: ArtifactDescriptor,
81
+ fetch: Callable[[ArtifactDescriptor], bytes],
82
+ ) -> dict[str, Any]:
83
+ # Authorization and bounded retrieval belong to the injected provider/download boundary.
84
+ self._job_turn(job_id)
85
+ return self.save(
86
+ job_id,
87
+ fetch(descriptor),
88
+ artifact_id=descriptor.id,
89
+ index=descriptor.index,
90
+ )
91
+
92
+ def reconcile(self, artifact_id: str) -> dict[str, Any]:
93
+ row = self.repositories.get("artifacts", artifact_id)
94
+ if row is None:
95
+ raise KeyError(artifact_id)
96
+ self._reference(row)
97
+ return dict(self.artifact_store.reconcile(artifact_id))
@@ -0,0 +1,190 @@
1
+ """Bounded batch orchestration over ordinary child jobs; children are never rewritten."""
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from imagen_cli.application.batch_summary import summarize
9
+ from imagen_cli.domain.contracts import CreationInput
10
+ from imagen_cli.domain.primitive import new_id
11
+ from imagen_cli.storage.repositories import Repositories
12
+
13
+ DEFAULT_CONCURRENCY = 2
14
+ MAX_CONCURRENCY = 8
15
+ MAX_REQUESTS = 100
16
+
17
+ Prepare = Callable[[CreationInput], Any]
18
+ RunChild = Callable[[Any, int], str]
19
+ ProviderLimit = Callable[[Any], int | None]
20
+
21
+
22
+ def effective_concurrency(
23
+ requested: int | None, provider_limits: Sequence[int | None]
24
+ ) -> int:
25
+ """Requested concurrency capped at 8 and never above a declared provider limit."""
26
+ value = DEFAULT_CONCURRENCY if requested is None else requested
27
+ if type(value) is not int or not 1 <= value <= MAX_CONCURRENCY:
28
+ raise ValueError(f"concurrency must be 1-{MAX_CONCURRENCY}")
29
+ limits: list[int] = []
30
+ for limit in provider_limits:
31
+ if limit is None:
32
+ continue
33
+ if type(limit) is not int or limit < 1:
34
+ raise ValueError("provider limit must be positive")
35
+ limits.append(limit)
36
+ return min([value, *limits])
37
+
38
+
39
+ def _now() -> str:
40
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
41
+
42
+
43
+ def _rows(repositories: Repositories, table: str, **filters: Any) -> list[dict[str, Any]]:
44
+ rows: list[dict[str, Any]] = []
45
+ while True:
46
+ page = repositories.list(table, limit=1000, offset=len(rows), **filters)
47
+ rows.extend(page)
48
+ if len(page) < 1000:
49
+ return rows
50
+
51
+
52
+ class BatchService:
53
+ def __init__(
54
+ self,
55
+ repositories: Repositories,
56
+ *,
57
+ prepare: Prepare | None = None,
58
+ run: RunChild | None = None,
59
+ provider_limit: ProviderLimit | None = None,
60
+ ) -> None:
61
+ self.repositories = repositories
62
+ self.prepare = prepare or (lambda creation: creation)
63
+ self.run = run
64
+ self.provider_limit = provider_limit
65
+
66
+ def submit(
67
+ self,
68
+ requests: Sequence[CreationInput | Mapping[str, Any]],
69
+ concurrency: int | None = None,
70
+ ) -> tuple[str, dict[str, Any]]:
71
+ if isinstance(requests, (str, bytes)) or not 1 <= len(requests) <= MAX_REQUESTS:
72
+ raise ValueError(f"batch requires 1-{MAX_REQUESTS} requests")
73
+ creations = [
74
+ request
75
+ if isinstance(request, CreationInput)
76
+ else CreationInput.model_validate(request)
77
+ for request in requests
78
+ ]
79
+ # Every child is validated and planned offline before any provider call.
80
+ prepared = [self.prepare(creation) for creation in creations]
81
+ limits = [
82
+ self.provider_limit(item) if self.provider_limit is not None else None
83
+ for item in prepared
84
+ ]
85
+ effective = effective_concurrency(concurrency, limits)
86
+ if self.run is None:
87
+ raise ValueError("batch execution is unavailable")
88
+ with ThreadPoolExecutor(max_workers=effective) as pool:
89
+ futures = [
90
+ pool.submit(self.run, item, index) for index, item in enumerate(prepared)
91
+ ]
92
+ # Read in request order so child identities stay ordered regardless of finish order.
93
+ job_ids = [future.result() for future in futures]
94
+ if len(set(job_ids)) != len(job_ids):
95
+ raise ValueError("batch child jobs must be unique")
96
+ identity = new_id("bat")
97
+ with self.repositories.transaction():
98
+ summary = summarize(self._facts(job_ids))
99
+ self.repositories.insert(
100
+ "batches",
101
+ {
102
+ "id": identity,
103
+ "child_job_ids": job_ids,
104
+ "concurrency": effective,
105
+ "summary_state": summary["state"],
106
+ "created_at": _now(),
107
+ },
108
+ )
109
+ return self._status(summary["state"]), self._data(identity)
110
+
111
+ def facts(self, batch_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
112
+ batch = self.repositories.get("batches", batch_id)
113
+ if batch is None:
114
+ raise KeyError(batch_id)
115
+ return batch, self._facts(batch.get("child_job_ids", []))
116
+
117
+ def get(self, batch_id: str) -> tuple[str, dict[str, Any]]:
118
+ if self.repositories.get("batches", batch_id) is None:
119
+ return "not_found", {}
120
+ return "succeeded", self._data(batch_id)
121
+
122
+ def wait(self, batch_id: str, *, timeout: int = 300) -> tuple[str, dict[str, Any]]:
123
+ if type(timeout) is not int or not 1 <= timeout <= 86400:
124
+ raise ValueError("invalid timeout")
125
+ if self.repositories.get("batches", batch_id) is None:
126
+ return "not_found", {}
127
+ import time
128
+
129
+ deadline = time.monotonic() + timeout
130
+ while True:
131
+ data = self._data(batch_id)
132
+ state = data["summary"]["state"]
133
+ if state != "running" or time.monotonic() >= deadline:
134
+ break
135
+ time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
136
+ state = data["summary"]["state"]
137
+ if state == "running":
138
+ return "wait_timeout", data
139
+ return self._status(state), data
140
+
141
+ def _child(self, job_id: str) -> dict[str, Any]:
142
+ job = self.repositories.get("jobs", job_id)
143
+ if job is None:
144
+ raise ValueError("batch child job is missing")
145
+ saved = sum(
146
+ 1
147
+ for row in _rows(self.repositories, "artifacts", producer_job_id=job_id)
148
+ if row.get("save_state") == "published"
149
+ )
150
+ return {
151
+ "job_id": job_id,
152
+ "state": job.get("state"),
153
+ "remote_state": job.get("remote_state"),
154
+ "local_save_state": job.get("local_save_state"),
155
+ "actual_count": job.get("actual_count", 0),
156
+ "requested_count": job.get("requested_count", 0),
157
+ "saved_artifact_count": saved,
158
+ }
159
+
160
+ def _facts(self, job_ids: Sequence[str]) -> list[dict[str, Any]]:
161
+ return [self._child(job_id) for job_id in job_ids]
162
+
163
+ def _data(self, batch_id: str) -> dict[str, Any]:
164
+ batch, children = self.facts(batch_id)
165
+ summary = summarize(children)
166
+ self.repositories.update("batches", batch_id, {"summary_state": summary["state"]})
167
+ events: list[dict[str, Any]] = []
168
+ for child in children:
169
+ for row in _rows(self.repositories, "events", job_id=child["job_id"]):
170
+ events.append(
171
+ {
172
+ "job_id": row["job_id"],
173
+ "job_seq": row["job_seq"],
174
+ "event_type": row["event_type"],
175
+ "timestamp": row["timestamp"],
176
+ "safe_payload": row.get("safe_payload", {}),
177
+ }
178
+ )
179
+ return {
180
+ "batch": self.repositories.get("batches", batch_id),
181
+ "summary": summary,
182
+ "children": children,
183
+ "events": events,
184
+ }
185
+
186
+ @staticmethod
187
+ def _status(state: str) -> str:
188
+ if state == "running":
189
+ return "wait_timeout"
190
+ return state
@@ -0,0 +1,66 @@
1
+ """Truthful batch aggregates; child facts stay untouched and unknown is not terminal."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ _ACTIVE = {"queued", "running"}
7
+
8
+
9
+ def _complete(child: Mapping[str, Any]) -> bool:
10
+ saved = child.get("saved_artifact_count", 0)
11
+ actual = child.get("actual_count", 0)
12
+ return (
13
+ child.get("state") == "succeeded"
14
+ and child.get("remote_state") == "succeeded"
15
+ and child.get("local_save_state") == "saved"
16
+ and type(saved) is int
17
+ and type(actual) is int
18
+ and actual > 0
19
+ and saved >= actual
20
+ )
21
+
22
+
23
+ def _partial(child: Mapping[str, Any]) -> bool:
24
+ saved = child.get("saved_artifact_count", 0)
25
+ return (
26
+ not _complete(child)
27
+ and (child.get("local_save_state") == "partial" or (type(saved) is int and saved > 0))
28
+ )
29
+
30
+
31
+ def summarize(children: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
32
+ """Apply the six-step priority; success counts jobs, saved count counts files."""
33
+ complete_count = sum(1 for child in children if _complete(child))
34
+ saved_count = sum(
35
+ child.get("saved_artifact_count", 0)
36
+ for child in children
37
+ if type(child.get("saved_artifact_count", 0)) is int
38
+ )
39
+ unknown = any(
40
+ child.get("state") == "unknown" or child.get("remote_state") == "unknown"
41
+ for child in children
42
+ )
43
+ partial = any(_partial(child) for child in children)
44
+ if any(child.get("state") in _ACTIVE for child in children):
45
+ state = "running"
46
+ elif children and complete_count == len(children):
47
+ state = "succeeded"
48
+ elif complete_count:
49
+ # Any non-complete child after the active check leaves a mixed outcome.
50
+ state = "partial_success"
51
+ elif partial:
52
+ state = "unknown" if unknown else "partial_success"
53
+ elif unknown:
54
+ state = "unknown"
55
+ elif any(child.get("state") == "failed" for child in children):
56
+ state = "failed"
57
+ elif children and all(child.get("state") == "canceled" for child in children):
58
+ state = "canceled"
59
+ else:
60
+ state = "failed"
61
+ return {
62
+ "state": state,
63
+ "child_count": len(children),
64
+ "complete_success_count": complete_count,
65
+ "saved_artifact_count": saved_count,
66
+ }
@@ -0,0 +1,262 @@
1
+ """Cache audit and confirmed reclamation; reports file bytes only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from imagen_cli.storage import queries
14
+ from imagen_cli.storage.artifacts import ArtifactStore
15
+ from imagen_cli.storage.private_state import PrivateStateStore
16
+ from imagen_cli.storage.repositories import Repositories
17
+ from imagen_cli.storage.safety import safe_path
18
+
19
+ TERMINAL_JOB_STATES = {"succeeded", "failed", "canceled"}
20
+ STAGING_STATES = {"staging", "save_failed"}
21
+ ROW_ONLY_KINDS = {"expired_approval", "expired_plan"}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Entry:
26
+ kind: str
27
+ identity: str
28
+ bytes: int
29
+ detail: dict[str, Any]
30
+
31
+ def as_json(self) -> dict[str, Any]:
32
+ return {"kind": self.kind, "id": self.identity, "bytes": self.bytes, **self.detail}
33
+
34
+
35
+ def _utc(value: str) -> datetime:
36
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
37
+
38
+
39
+ def _parse(value: Any) -> datetime | None:
40
+ if not isinstance(value, str):
41
+ return None
42
+ try:
43
+ return _utc(value)
44
+ except ValueError:
45
+ return None
46
+
47
+
48
+ def _size(path: Path) -> int:
49
+ try:
50
+ return path.stat().st_size
51
+ except OSError:
52
+ return 0
53
+
54
+
55
+ def _paths(row: dict[str, Any]) -> list[str]:
56
+ names = {row.get("staging_relpath"), row.get("managed_relpath")}
57
+ return sorted(name for name in names if isinstance(name, str))
58
+
59
+
60
+ def audit(
61
+ repositories: Repositories,
62
+ store: ArtifactStore,
63
+ *,
64
+ now: datetime | None = None,
65
+ staging_days: int = 7,
66
+ deleted_days: int = 30,
67
+ private_store: PrivateStateStore | None = None,
68
+ ) -> list[Entry]:
69
+ """Classify reclaimable rows; kinds are mutually exclusive by save_state."""
70
+ moment = (now or datetime.now(UTC)).astimezone(UTC)
71
+ if staging_days < 0 or deleted_days < 0:
72
+ raise ValueError("retention days must be non-negative")
73
+ sessions = {row["id"]: row for row in queries.iter_all(repositories, "sessions")}
74
+ holders = queries.reference_sessions(repositories)
75
+ jobs = {row["id"]: row for row in queries.iter_all(repositories, "jobs")}
76
+ entries: list[Entry] = []
77
+
78
+ for row in queries.iter_all(repositories, "artifacts", include_private=True):
79
+ identity = row["id"]
80
+ state = row["save_state"]
81
+ if state in STAGING_STATES:
82
+ created = _parse(row.get("created_at"))
83
+ producer = row.get("producer_job_id")
84
+ job = jobs.get(producer) if isinstance(producer, str) else None
85
+ if created is None or moment - created < timedelta(days=staging_days):
86
+ continue
87
+ if job is not None and job.get("state") not in TERMINAL_JOB_STATES:
88
+ continue
89
+ size = sum(_size(safe_path(store.root, name)) for name in _paths(row))
90
+ entries.append(Entry("staging_artifact", identity, size, {"save_state": state}))
91
+ continue
92
+ if state != "published":
93
+ continue
94
+ holding = holders.get(identity, set())
95
+ if not holding:
96
+ size = sum(_size(safe_path(store.root, name)) for name in _paths(row))
97
+ entries.append(Entry("unreferenced_artifact", identity, size, {}))
98
+ continue
99
+ deleted = [sessions[s]["deleted_at"] for s in holding if s in sessions]
100
+ if len(deleted) != len(holding) or not all(deleted):
101
+ continue
102
+ stamps = [_parse(value) for value in deleted]
103
+ if any(stamp is None for stamp in stamps):
104
+ continue
105
+ oldest = max(stamp for stamp in stamps if stamp is not None)
106
+ if moment - oldest < timedelta(days=deleted_days):
107
+ continue
108
+ size = sum(_size(safe_path(store.root, name)) for name in _paths(row))
109
+ entries.append(Entry("deleted_session_artifact", identity, size, {}))
110
+
111
+ approvals = {row["plan_id"]: row for row in queries.iter_all(repositories, "approvals")}
112
+ jobs_by_plan = {
113
+ row["plan_id"] for row in jobs.values() if isinstance(row.get("plan_id"), str)
114
+ }
115
+ for row in queries.iter_all(repositories, "approvals"):
116
+ expires = _parse(row.get("expires_at"))
117
+ if expires is not None and expires < moment and row.get("state") != "consumed":
118
+ entries.append(Entry("expired_approval", row["id"], 0, {"state": row["state"]}))
119
+ for row in queries.iter_all(repositories, "plans"):
120
+ expires = _parse(row.get("expires_at"))
121
+ if expires is None or expires >= moment:
122
+ continue
123
+ if row["id"] in jobs_by_plan or row["id"] in approvals:
124
+ continue
125
+ entries.append(Entry("expired_plan", row["id"], 0, {}))
126
+
127
+ if private_store is not None:
128
+ turns = {
129
+ row["id"]: row for row in queries.iter_all(repositories, "turns")
130
+ }
131
+ # A profile key is private state too; it must never be reclaimed as a job payload.
132
+ credentials = {
133
+ row["credential_ref"][8:] for row in queries.iter_all(repositories, "profiles")
134
+ if isinstance(row.get("credential_ref"), str)
135
+ and row["credential_ref"].startswith("private:")
136
+ }
137
+ claimed: set[str] = set()
138
+ for row in queries.iter_all(repositories, "native_contexts", include_private=True):
139
+ relpath = row.get("private_relpath")
140
+ if not isinstance(relpath, str):
141
+ continue
142
+ claimed.add(relpath)
143
+ expires = _parse(row.get("expires_at"))
144
+ source = row.get("source_turn_id")
145
+ turn = turns.get(source) if isinstance(source, str) else None
146
+ session_id = turn.get("session_id") if turn else None
147
+ orphaned = bool(
148
+ session_id and sessions.get(session_id, {}).get("deleted_at")
149
+ )
150
+ if not ((expires is not None and expires < moment) or (expires is None and orphaned)):
151
+ continue
152
+ size = _size(safe_path(private_store.root, relpath))
153
+ entries.append(Entry("expired_native_context", row["id"], size, {}))
154
+ for identity in private_store.identities():
155
+ if identity in jobs or identity in credentials or identity + ".bin" in claimed:
156
+ continue
157
+ size = _size(safe_path(private_store.root, identity + ".bin"))
158
+ entries.append(Entry("orphan_job_payload", identity, size, {}))
159
+
160
+ return sorted(entries, key=lambda item: (item.kind, item.identity))
161
+
162
+
163
+ def digest(entries: list[Entry]) -> str:
164
+ payload = [[item.kind, item.identity, item.bytes] for item in entries]
165
+ encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
166
+ return hashlib.sha256(encoded).hexdigest()
167
+
168
+
169
+ def report(entries: list[Entry]) -> dict[str, Any]:
170
+ total = sum(item.bytes for item in entries)
171
+ return {
172
+ "entries": [item.as_json() for item in entries],
173
+ "plan_digest": digest(entries),
174
+ "planned_bytes": total,
175
+ "count": len(entries),
176
+ }
177
+
178
+
179
+ def purge(
180
+ repositories: Repositories,
181
+ store: ArtifactStore,
182
+ plan_digest: str,
183
+ *,
184
+ clock: Callable[[], datetime] | None = None,
185
+ staging_days: int = 7,
186
+ deleted_days: int = 30,
187
+ private_store: PrivateStateStore | None = None,
188
+ max_entries: int = 2000,
189
+ ) -> dict[str, Any]:
190
+ """Delete exactly the confirmed plan; any audit drift refuses the whole run."""
191
+ now = (clock or (lambda: datetime.now(UTC)))().astimezone(UTC)
192
+ with repositories.transaction():
193
+ entries = audit(
194
+ repositories, store, now=now,
195
+ staging_days=staging_days, deleted_days=deleted_days,
196
+ private_store=private_store,
197
+ )
198
+ if len(entries) > max_entries:
199
+ raise ValueError("cache plan exceeds the per-run entry limit")
200
+ if digest(entries) != plan_digest:
201
+ raise ValueError("cache plan digest mismatch")
202
+ results: list[dict[str, Any]] = []
203
+ planned = sum(item.bytes for item in entries)
204
+ freed = 0
205
+ for entry in sorted(entries, key=lambda item: (item.kind, item.identity)):
206
+ if entry.kind in ROW_ONLY_KINDS:
207
+ table = "approvals" if entry.kind == "expired_approval" else "plans"
208
+ repositories.delete(table, entry.identity)
209
+ results.append({"id": entry.identity, "kind": entry.kind, "result": "deleted"})
210
+ continue
211
+ if entry.kind == "expired_native_context":
212
+ row = repositories.get("native_contexts", entry.identity, include_private=True)
213
+ if row is None or private_store is None:
214
+ results.append({"id": entry.identity, "kind": entry.kind,
215
+ "result": "already_missing"})
216
+ continue
217
+ private_store.delete(row["private_relpath"])
218
+ repositories.delete("native_contexts", entry.identity)
219
+ freed += entry.bytes
220
+ results.append({"id": entry.identity, "kind": entry.kind, "result": "deleted"})
221
+ continue
222
+ if entry.kind == "orphan_job_payload":
223
+ if private_store is not None:
224
+ private_store.delete(entry.identity + ".bin")
225
+ freed += entry.bytes
226
+ results.append({"id": entry.identity, "kind": entry.kind, "result": "deleted"})
227
+ continue
228
+ row = repositories.get("artifacts", entry.identity, include_private=True)
229
+ if row is None:
230
+ results.append({
231
+ "id": entry.identity, "kind": entry.kind, "result": "already_missing",
232
+ })
233
+ continue
234
+ failed: OSError | None = None
235
+ released = 0
236
+ for name in _paths(row):
237
+ path = safe_path(store.root, name)
238
+ try:
239
+ size = path.stat().st_size
240
+ path.unlink()
241
+ released += size
242
+ except FileNotFoundError:
243
+ continue
244
+ except OSError as error:
245
+ failed = error
246
+ break
247
+ if failed is not None:
248
+ results.append({
249
+ "id": entry.identity, "kind": entry.kind,
250
+ "result": "failed", "error": str(failed),
251
+ })
252
+ continue
253
+ queries.delete_references(repositories, entry.identity)
254
+ repositories.delete("artifacts", entry.identity)
255
+ freed += released
256
+ results.append({"id": entry.identity, "kind": entry.kind, "result": "deleted"})
257
+ return {
258
+ "planned_bytes": planned,
259
+ "deleted_bytes": freed,
260
+ "results": results,
261
+ "remaining": sum(1 for item in results if item["result"] == "failed"),
262
+ }