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.
- imagen_cli/__init__.py +0 -0
- imagen_cli/__main__.py +3 -0
- imagen_cli/application/approval_service.py +36 -0
- imagen_cli/application/artifact_service.py +97 -0
- imagen_cli/application/batch_service.py +190 -0
- imagen_cli/application/batch_summary.py +66 -0
- imagen_cli/application/cache_service.py +262 -0
- imagen_cli/application/cancel_service.py +91 -0
- imagen_cli/application/cleanup_service.py +161 -0
- imagen_cli/application/container.py +786 -0
- imagen_cli/application/cost_service.py +25 -0
- imagen_cli/application/execution_service.py +177 -0
- imagen_cli/application/export_service.py +339 -0
- imagen_cli/application/filters_advanced.py +540 -0
- imagen_cli/application/filters_fast.py +66 -0
- imagen_cli/application/filters_frequency.py +92 -0
- imagen_cli/application/filters_numpy.py +467 -0
- imagen_cli/application/filters_rank.py +50 -0
- imagen_cli/application/filters_restoration.py +327 -0
- imagen_cli/application/filters_structure.py +223 -0
- imagen_cli/application/fork_service.py +52 -0
- imagen_cli/application/import_service.py +608 -0
- imagen_cli/application/job_state.py +41 -0
- imagen_cli/application/media_ops.py +2163 -0
- imagen_cli/application/model_catalog_service.py +153 -0
- imagen_cli/application/opener.py +25 -0
- imagen_cli/application/operator_catalog.py +2997 -0
- imagen_cli/application/operator_catalog_en.py +1859 -0
- imagen_cli/application/outcome_service.py +160 -0
- imagen_cli/application/planning_service.py +268 -0
- imagen_cli/application/postprocess_service.py +479 -0
- imagen_cli/application/postprocess_worker.py +92 -0
- imagen_cli/application/preflight.py +123 -0
- imagen_cli/application/profile_service.py +53 -0
- imagen_cli/application/quality_service.py +49 -0
- imagen_cli/application/reconcile.py +84 -0
- imagen_cli/application/release_readiness.py +56 -0
- imagen_cli/application/session_service.py +253 -0
- imagen_cli/application/sheet_service.py +97 -0
- imagen_cli/application/skill_targets.NOTICE.md +33 -0
- imagen_cli/application/skill_targets.json +397 -0
- imagen_cli/application/skill_targets.py +42 -0
- imagen_cli/application/skills_service.py +416 -0
- imagen_cli/application/superres.py +500 -0
- imagen_cli/application/update_service.py +123 -0
- imagen_cli/bundled_skills/imagen/SKILL.md +46 -0
- imagen_cli/bundled_skills/imagen/references/workflows.md +66 -0
- imagen_cli/bundled_skills/imagen-assets/SKILL.md +64 -0
- imagen_cli/bundled_skills/imagen-batch/SKILL.md +61 -0
- imagen_cli/cli/commands/asset.py +58 -0
- imagen_cli/cli/commands/batch.py +25 -0
- imagen_cli/cli/commands/cache.py +59 -0
- imagen_cli/cli/commands/create.py +179 -0
- imagen_cli/cli/commands/discovery.py +51 -0
- imagen_cli/cli/commands/history.py +156 -0
- imagen_cli/cli/commands/job.py +165 -0
- imagen_cli/cli/commands/models.py +69 -0
- imagen_cli/cli/commands/plan.py +22 -0
- imagen_cli/cli/commands/postprocess.py +125 -0
- imagen_cli/cli/commands/profile.py +141 -0
- imagen_cli/cli/commands/session.py +79 -0
- imagen_cli/cli/commands/skills.py +42 -0
- imagen_cli/cli/commands/worker.py +33 -0
- imagen_cli/cli/creation_input.py +35 -0
- imagen_cli/cli/diagnostics.py +62 -0
- imagen_cli/cli/entrypoint.py +705 -0
- imagen_cli/cli/generated_docs.py +337 -0
- imagen_cli/cli/human.py +715 -0
- imagen_cli/cli/input_readers.py +152 -0
- imagen_cli/cli/options.py +75 -0
- imagen_cli/cli/preview.py +32 -0
- imagen_cli/cli/registry.py +837 -0
- imagen_cli/cli/renderers.py +191 -0
- imagen_cli/cli/selection.py +27 -0
- imagen_cli/cli/tui/__init__.py +0 -0
- imagen_cli/cli/tui/app.py +2691 -0
- imagen_cli/cli/tui/preview.py +54 -0
- imagen_cli/cli/usage.py +177 -0
- imagen_cli/domain/__init__.py +1 -0
- imagen_cli/domain/contracts.py +123 -0
- imagen_cli/domain/enums.py +143 -0
- imagen_cli/domain/errors.py +32 -0
- imagen_cli/domain/models.py +392 -0
- imagen_cli/domain/primitive.py +80 -0
- imagen_cli/execution/configured.py +72 -0
- imagen_cli/execution/foreground.py +106 -0
- imagen_cli/execution/queued.py +110 -0
- imagen_cli/execution/recovery.py +67 -0
- imagen_cli/execution/worker.py +448 -0
- imagen_cli/i18n.py +145 -0
- imagen_cli/providers/automatic1111.py +172 -0
- imagen_cli/providers/base.py +146 -0
- imagen_cli/providers/bfl.py +240 -0
- imagen_cli/providers/comfyui.py +228 -0
- imagen_cli/providers/common.py +173 -0
- imagen_cli/providers/contracts.py +34 -0
- imagen_cli/providers/gemini.py +263 -0
- imagen_cli/providers/google_cloud.py +303 -0
- imagen_cli/providers/model_platforms.py +574 -0
- imagen_cli/providers/openai_compatible.py +160 -0
- imagen_cli/providers/openai_images.py +236 -0
- imagen_cli/providers/openai_responses.py +155 -0
- imagen_cli/providers/openrouter.py +142 -0
- imagen_cli/providers/stability.py +193 -0
- imagen_cli/storage/artifacts.py +164 -0
- imagen_cli/storage/config.py +23 -0
- imagen_cli/storage/database.py +48 -0
- imagen_cli/storage/download.py +165 -0
- imagen_cli/storage/migrations.py +138 -0
- imagen_cli/storage/private_state.py +193 -0
- imagen_cli/storage/queries.py +70 -0
- imagen_cli/storage/repositories.py +410 -0
- imagen_cli/storage/safety.py +451 -0
- imagen_cli/storage/workspace.py +26 -0
- imagen_cli-0.1.1.dist-info/METADATA +586 -0
- imagen_cli-0.1.1.dist-info/RECORD +119 -0
- imagen_cli-0.1.1.dist-info/WHEEL +4 -0
- imagen_cli-0.1.1.dist-info/entry_points.txt +2 -0
- 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,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
|
+
}
|