generic-ml-wrapper 0.1.0__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.
- generic_ml_wrapper/__init__.py +13 -0
- generic_ml_wrapper/adapter/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
- generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
- generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
- generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
- generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
- generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
- generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
- generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
- generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
- generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
- generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
- generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
- generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
- generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
- generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
- generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
- generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
- generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
- generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
- generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
- generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
- generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
- generic_ml_wrapper/application/__init__.py +3 -0
- generic_ml_wrapper/application/domain/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/client_status.py +44 -0
- generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
- generic_ml_wrapper/application/domain/model/run.py +35 -0
- generic_ml_wrapper/application/domain/model/session.py +24 -0
- generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
- generic_ml_wrapper/application/domain/model/workspace.py +31 -0
- generic_ml_wrapper/application/domain/service/__init__.py +3 -0
- generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
- generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
- generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
- generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
- generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
- generic_ml_wrapper/application/port/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
- generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
- generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
- generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
- generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
- generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
- generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
- generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
- generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
- generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
- generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
- generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
- generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
- generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
- generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
- generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
- generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
- generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
- generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
- generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
- generic_ml_wrapper/application/usecase/__init__.py +3 -0
- generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
- generic_ml_wrapper/application/usecase/export_usage.py +93 -0
- generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
- generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
- generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
- generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
- generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
- generic_ml_wrapper/application/usecase/set_credential.py +27 -0
- generic_ml_wrapper/application/usecase/start_job.py +125 -0
- generic_ml_wrapper/application/wiring/__init__.py +3 -0
- generic_ml_wrapper/application/wiring/composition.py +230 -0
- generic_ml_wrapper/common/__init__.py +3 -0
- generic_ml_wrapper/common/config.py +179 -0
- generic_ml_wrapper/common/log.py +83 -0
- generic_ml_wrapper/common/paths.py +25 -0
- generic_ml_wrapper/common/spec_loader.py +67 -0
- generic_ml_wrapper/py.typed +0 -0
- generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
- generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
- generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
- generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
- generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
- generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
- generic_ml_wrapper-0.1.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for reading, seeding, and compiling workflows."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkflowSourcePort(ABC):
|
|
11
|
+
"""Seed default workflows, check/create workflow folders, and compile context."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def seed(self) -> None:
|
|
15
|
+
"""Copy the packaged default workflows into the user's home, missing-only."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def names(self) -> list[str]:
|
|
19
|
+
"""Return the names of the runnable workflows, sorted.
|
|
20
|
+
|
|
21
|
+
Excludes the shared base and the create-workflow meta-workflow.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The runnable workflow names (empty if none exist).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def exists(self, name: str) -> bool:
|
|
29
|
+
"""Return whether a runnable workflow exists.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: The workflow name.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
``True`` if the workflow has a ``workflow.md``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def create(self, name: str) -> str:
|
|
40
|
+
"""Create an empty folder for a new workflow.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: The workflow name.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The absolute path to the created folder.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def compile(self, name: str) -> str:
|
|
51
|
+
"""Compile a workflow's operating context.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
name: The workflow name.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The shared base followed by the workflow's steps.
|
|
58
|
+
"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for inspecting the working environment."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.model.workspace import Workspace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkspaceInspectorPort(ABC):
|
|
13
|
+
"""Report the run's working environment (folder and git state).
|
|
14
|
+
|
|
15
|
+
This is client-agnostic: the wrapper computes these facts itself rather than
|
|
16
|
+
reading them from a client's payload, so every client's status line carries
|
|
17
|
+
them identically.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def inspect(self) -> Workspace:
|
|
22
|
+
"""Inspect the current working environment.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The working directory and git state (fields the environment does not
|
|
26
|
+
provide are ``None``; ``dirty`` is ``0`` when clean or unknown).
|
|
27
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The Bootstrap use case: ensure the runtime layout exists."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from generic_ml_wrapper.application.port.inbound.bootstrap import Bootstrap
|
|
8
|
+
from generic_ml_wrapper.application.port.outbound.layout_seeder import LayoutSeederPort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BootstrapUseCase(Bootstrap):
|
|
12
|
+
"""Ensure the runtime layout by delegating to a layout seeder."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, seeder: LayoutSeederPort) -> None:
|
|
15
|
+
"""Wire the use case to its outbound seeder.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
seeder: The seeder that creates missing directories and the config.
|
|
19
|
+
"""
|
|
20
|
+
self._seeder = seeder
|
|
21
|
+
|
|
22
|
+
def execute(self) -> None:
|
|
23
|
+
"""Ensure the runtime layout exists (idempotent, missing-only)."""
|
|
24
|
+
self._seeder.ensure()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The ExportUsage use case: assemble a job's usage report."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.port.inbound.export_usage import (
|
|
10
|
+
ExportUsage,
|
|
11
|
+
ModelTotal,
|
|
12
|
+
SessionCost,
|
|
13
|
+
TurnRow,
|
|
14
|
+
UsageReport,
|
|
15
|
+
)
|
|
16
|
+
from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
|
|
17
|
+
from generic_ml_wrapper.application.port.outbound.usage_store import UsageStorePort
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
|
|
21
|
+
|
|
22
|
+
_UNKNOWN_MODEL = "(unknown)"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExportUsageUseCase(ExportUsage):
|
|
26
|
+
"""Assemble a job's usage report from the per-turn store and the session-cost store."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, usage: UsageStorePort, turns: PerTurnMeteringPort) -> None:
|
|
29
|
+
"""Wire the use case to its usage stores.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
usage: The per-session cost store (from the status line).
|
|
33
|
+
turns: The per-turn token store (from a metering gateway).
|
|
34
|
+
"""
|
|
35
|
+
self._usage = usage
|
|
36
|
+
self._turns = turns
|
|
37
|
+
|
|
38
|
+
def execute(self, job: str) -> UsageReport:
|
|
39
|
+
"""Build a job's usage report.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
job: The job identifier.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Per-turn rows (chronological), per-model totals, per-session cost, and
|
|
46
|
+
job totals.
|
|
47
|
+
"""
|
|
48
|
+
recorded = self._turns.turns_for_job(job)
|
|
49
|
+
turns = tuple(sorted((_row(turn) for turn in recorded), key=lambda row: row.timestamp))
|
|
50
|
+
models = _model_totals(recorded)
|
|
51
|
+
costs = self._usage.session_costs(job)
|
|
52
|
+
session_costs = tuple(SessionCost(session, costs[session]) for session in sorted(costs))
|
|
53
|
+
return UsageReport(
|
|
54
|
+
job=job,
|
|
55
|
+
turns=turns,
|
|
56
|
+
models=models,
|
|
57
|
+
session_costs=session_costs,
|
|
58
|
+
turn_count=len(recorded),
|
|
59
|
+
input_tokens=sum(model.input_tokens for model in models),
|
|
60
|
+
output_tokens=sum(model.output_tokens for model in models),
|
|
61
|
+
cache_tokens=sum(model.cache_tokens for model in models),
|
|
62
|
+
duration_s=round(sum(model.duration_s for model in models), 1),
|
|
63
|
+
total_usd=round(sum(cost.cost_usd for cost in session_costs), 2),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _row(turn: TurnUsage) -> TurnRow:
|
|
68
|
+
return TurnRow(
|
|
69
|
+
timestamp=turn.timestamp,
|
|
70
|
+
model=turn.model or _UNKNOWN_MODEL,
|
|
71
|
+
duration_s=turn.duration_s,
|
|
72
|
+
input_tokens=turn.input_tokens,
|
|
73
|
+
output_tokens=turn.output_tokens,
|
|
74
|
+
cache_tokens=turn.cache_creation_tokens + turn.cache_read_tokens,
|
|
75
|
+
turn_id=turn.turn_id,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _model_totals(recorded: list[TurnUsage]) -> tuple[ModelTotal, ...]:
|
|
80
|
+
# model -> [calls, input, output, cache, duration]
|
|
81
|
+
totals: dict[str, list[float]] = {}
|
|
82
|
+
for turn in recorded:
|
|
83
|
+
model = turn.model or _UNKNOWN_MODEL
|
|
84
|
+
acc = totals.setdefault(model, [0, 0, 0, 0, 0.0])
|
|
85
|
+
acc[0] += 1
|
|
86
|
+
acc[1] += turn.input_tokens
|
|
87
|
+
acc[2] += turn.output_tokens
|
|
88
|
+
acc[3] += turn.cache_creation_tokens + turn.cache_read_tokens
|
|
89
|
+
acc[4] += turn.duration_s
|
|
90
|
+
return tuple(
|
|
91
|
+
ModelTotal(model, int(a[0]), int(a[1]), int(a[2]), int(a[3]), round(a[4], 1))
|
|
92
|
+
for model, a in sorted(totals.items())
|
|
93
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The ListJobs use case: summarise each job's recorded sessions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from generic_ml_wrapper.application.port.inbound.list_jobs import JobSummary, ListJobs
|
|
8
|
+
from generic_ml_wrapper.application.port.outbound.session_store import SessionStorePort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ListJobsUseCase(ListJobs):
|
|
12
|
+
"""Summarise each job that has recorded sessions."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, store: SessionStorePort) -> None:
|
|
15
|
+
"""Wire the use case to the session store.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
store: Where jobs and their sessions are read from.
|
|
19
|
+
"""
|
|
20
|
+
self._store = store
|
|
21
|
+
|
|
22
|
+
def execute(self) -> list[JobSummary]:
|
|
23
|
+
"""List the jobs with recorded activity.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
One summary per job, sorted by job id.
|
|
27
|
+
"""
|
|
28
|
+
return [
|
|
29
|
+
JobSummary(job=job, session_count=len(self._store.ids_for_job(job)))
|
|
30
|
+
for job in self._store.jobs()
|
|
31
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The ListSessions use case: summarise a job's recorded sessions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from generic_ml_wrapper.application.port.inbound.list_sessions import ListSessions, SessionSummary
|
|
8
|
+
from generic_ml_wrapper.application.port.outbound.session_store import SessionStorePort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ListSessionsUseCase(ListSessions):
|
|
12
|
+
"""Summarise the sessions recorded for a job."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, store: SessionStorePort) -> None:
|
|
15
|
+
"""Wire the use case to the session store.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
store: Where the job's sessions are read from.
|
|
19
|
+
"""
|
|
20
|
+
self._store = store
|
|
21
|
+
|
|
22
|
+
def execute(self, job: str) -> list[SessionSummary]:
|
|
23
|
+
"""List a job's sessions.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
job: The job identifier.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
One summary per session, oldest first.
|
|
30
|
+
"""
|
|
31
|
+
return [
|
|
32
|
+
SessionSummary(session_id=session.session_id, client=session.client)
|
|
33
|
+
for session in self._store.sessions_for_job(job)
|
|
34
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The ListWorkflows use case: the runnable workflow names."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from generic_ml_wrapper.application.port.inbound.list_workflows import ListWorkflows
|
|
8
|
+
from generic_ml_wrapper.application.port.outbound.workflow_source import WorkflowSourcePort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ListWorkflowsUseCase(ListWorkflows):
|
|
12
|
+
"""List the runnable workflows from the workflow source."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, workflows: WorkflowSourcePort) -> None:
|
|
15
|
+
"""Wire the use case to the workflow source.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
workflows: Where the workflows are read from.
|
|
19
|
+
"""
|
|
20
|
+
self._workflows = workflows
|
|
21
|
+
|
|
22
|
+
def execute(self) -> list[str]:
|
|
23
|
+
"""List the runnable workflow names.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The workflow names, sorted (empty if none exist).
|
|
27
|
+
"""
|
|
28
|
+
return self._workflows.names()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The NewWorkflow use case: author a workflow via the create-workflow interview."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.model.identifiers import IdentifierError, WorkflowName
|
|
10
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
11
|
+
from generic_ml_wrapper.application.domain.model.session import Session
|
|
12
|
+
from generic_ml_wrapper.application.domain.service.session_naming import next_session_id
|
|
13
|
+
from generic_ml_wrapper.application.port.inbound.new_workflow import (
|
|
14
|
+
NewWorkflow,
|
|
15
|
+
NewWorkflowCommand,
|
|
16
|
+
WorkflowExistsError,
|
|
17
|
+
WorkflowNameError,
|
|
18
|
+
)
|
|
19
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCallerProvider
|
|
20
|
+
from generic_ml_wrapper.application.port.outbound.session_store import SessionStorePort
|
|
21
|
+
from generic_ml_wrapper.application.port.outbound.workflow_source import WorkflowSourcePort
|
|
22
|
+
|
|
23
|
+
_META = "create-workflow"
|
|
24
|
+
_RESERVED = frozenset({_META, "_common"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NewWorkflowUseCase(NewWorkflow):
|
|
28
|
+
"""Create a workflow folder and run the create-workflow authoring session."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
workflows: WorkflowSourcePort,
|
|
33
|
+
store: SessionStorePort,
|
|
34
|
+
callers: CliCallerProvider,
|
|
35
|
+
uuid_factory: Callable[[], str],
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Wire the use case to its outbound ports.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
workflows: Seeds, checks, creates, and compiles workflows.
|
|
41
|
+
store: Records the authoring session.
|
|
42
|
+
callers: Resolves the client caller for the run.
|
|
43
|
+
uuid_factory: Mints a client-side session uuid.
|
|
44
|
+
"""
|
|
45
|
+
self._workflows = workflows
|
|
46
|
+
self._store = store
|
|
47
|
+
self._callers = callers
|
|
48
|
+
self._uuid_factory = uuid_factory
|
|
49
|
+
|
|
50
|
+
def execute(self, command: NewWorkflowCommand) -> int:
|
|
51
|
+
"""Run the authoring session for a new workflow.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
command: The request describing the workflow name and client.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The client's exit code.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
WorkflowNameError: If the name is invalid or reserved.
|
|
61
|
+
WorkflowExistsError: If a workflow with that name already exists.
|
|
62
|
+
"""
|
|
63
|
+
name = command.name
|
|
64
|
+
try:
|
|
65
|
+
WorkflowName(name)
|
|
66
|
+
except IdentifierError as error:
|
|
67
|
+
raise WorkflowNameError(str(error)) from error
|
|
68
|
+
if name in _RESERVED:
|
|
69
|
+
message = f"reserved workflow name: {name!r}"
|
|
70
|
+
raise WorkflowNameError(message)
|
|
71
|
+
self._workflows.seed()
|
|
72
|
+
if self._workflows.exists(name):
|
|
73
|
+
message = f"workflow already exists: {name!r}"
|
|
74
|
+
raise WorkflowExistsError(message)
|
|
75
|
+
|
|
76
|
+
folder = self._workflows.create(name)
|
|
77
|
+
# The authoring session's store is rooted apart from real work jobs (the
|
|
78
|
+
# composition root injects the authoring root), so the job is just the
|
|
79
|
+
# workflow name — no prefix, no folder-name collision with a real job.
|
|
80
|
+
job = name
|
|
81
|
+
session = Session(
|
|
82
|
+
session_id=next_session_id(job, self._store.ids_for_job(job)),
|
|
83
|
+
job=job,
|
|
84
|
+
client=command.client,
|
|
85
|
+
uuid=self._uuid_factory(),
|
|
86
|
+
)
|
|
87
|
+
self._store.record(session)
|
|
88
|
+
|
|
89
|
+
run = RunContext(
|
|
90
|
+
job=job,
|
|
91
|
+
session_id=session.session_id,
|
|
92
|
+
client=session.client,
|
|
93
|
+
uuid=session.uuid,
|
|
94
|
+
resume=False,
|
|
95
|
+
cwd=folder,
|
|
96
|
+
context=self._workflows.compile(_META),
|
|
97
|
+
kickoff=(
|
|
98
|
+
f"You are creating a new workflow named {name!r}. Your working "
|
|
99
|
+
f"directory is its folder ({folder}); write the new workflow.md there. "
|
|
100
|
+
"Follow the create-workflow steps: interview me, then draft and save "
|
|
101
|
+
"the workflow. Start by asking what this workflow is for."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
caller = self._callers.for_run(run)
|
|
105
|
+
caller.start_metering()
|
|
106
|
+
try:
|
|
107
|
+
return caller.start_client()
|
|
108
|
+
finally:
|
|
109
|
+
caller.end_metering()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The RenderStatusline use case: parse a client payload, record usage, render."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
|
|
14
|
+
|
|
15
|
+
from generic_ml_wrapper.application.domain.service.statusline_renderer import (
|
|
16
|
+
render_statusline,
|
|
17
|
+
render_usage_row,
|
|
18
|
+
)
|
|
19
|
+
from generic_ml_wrapper.application.port.inbound.render_statusline import RenderStatusline
|
|
20
|
+
from generic_ml_wrapper.application.port.outbound.client_status import ClientStatusParserPort
|
|
21
|
+
from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
|
|
22
|
+
from generic_ml_wrapper.application.port.outbound.usage_store import UsageStorePort
|
|
23
|
+
from generic_ml_wrapper.application.port.outbound.workspace import WorkspaceInspectorPort
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RenderStatuslineUseCase(RenderStatusline):
|
|
27
|
+
"""Parse the client's status payload, record its session cost, and render a line."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
parser: ClientStatusParserPort,
|
|
32
|
+
usage: UsageStorePort,
|
|
33
|
+
workspace: WorkspaceInspectorPort,
|
|
34
|
+
turns: PerTurnMeteringPort,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Wire the use case to its outbound ports.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
parser: The client's status-payload parser.
|
|
40
|
+
usage: Where recorded session cost is persisted and read.
|
|
41
|
+
workspace: The inspector for the client-agnostic environment facts.
|
|
42
|
+
turns: The per-turn store, read for the job's cumulative usage footer.
|
|
43
|
+
"""
|
|
44
|
+
self._parser = parser
|
|
45
|
+
self._usage = usage
|
|
46
|
+
self._workspace = workspace
|
|
47
|
+
self._turns = turns
|
|
48
|
+
|
|
49
|
+
def execute(self, payload_json: str, job: str | None, session: str | None) -> str:
|
|
50
|
+
"""Parse the payload, record usage, and render the status line.
|
|
51
|
+
|
|
52
|
+
The live status is the first line; when a job is active, its cumulative
|
|
53
|
+
usage (turns · tokens · cost across sessions) is appended as a footer row.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
payload_json: The raw JSON the client piped to the status-line command.
|
|
57
|
+
job: The active job, or ``None`` if unknown (usage is not recorded then).
|
|
58
|
+
session: The active session, or ``None`` if unknown.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The status line (one or two lines) to print.
|
|
62
|
+
"""
|
|
63
|
+
status = self._parser.parse(_decode(payload_json))
|
|
64
|
+
if job and session and status.session_cost_usd is not None:
|
|
65
|
+
self._usage.record_session_cost(job, session, status.session_cost_usd)
|
|
66
|
+
line = render_statusline(status, self._workspace.inspect())
|
|
67
|
+
footer = self._usage_footer(job, session) if job else ""
|
|
68
|
+
if not footer:
|
|
69
|
+
return line
|
|
70
|
+
return f"{line}\n{footer}" if line else footer
|
|
71
|
+
|
|
72
|
+
def _usage_footer(self, job: str, session: str | None) -> str:
|
|
73
|
+
"""The usage rows below the live line: session, then job total across sessions.
|
|
74
|
+
|
|
75
|
+
The current session's usage comes first; the whole-job total is added only when
|
|
76
|
+
the job spans other sessions. Empty when the job has no recorded activity.
|
|
77
|
+
"""
|
|
78
|
+
turns = self._turns.turns_for_job(job)
|
|
79
|
+
costs = self._usage.session_costs(job)
|
|
80
|
+
if not turns and not costs:
|
|
81
|
+
return ""
|
|
82
|
+
rows: list[str] = []
|
|
83
|
+
if session is not None:
|
|
84
|
+
session_turns = [turn for turn in turns if turn.session_id == session]
|
|
85
|
+
rows.append(
|
|
86
|
+
render_usage_row(
|
|
87
|
+
"session",
|
|
88
|
+
session,
|
|
89
|
+
len(session_turns),
|
|
90
|
+
_tokens(session_turns),
|
|
91
|
+
costs.get(session, 0.0),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
spans_other_sessions = any(turn.session_id != session for turn in turns) or any(
|
|
95
|
+
other != session for other in costs
|
|
96
|
+
)
|
|
97
|
+
if not spans_other_sessions:
|
|
98
|
+
return rows[0]
|
|
99
|
+
rows.append(
|
|
100
|
+
render_usage_row("job", job, len(turns), _tokens(turns), round(sum(costs.values()), 2))
|
|
101
|
+
)
|
|
102
|
+
return "\n".join(rows)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tokens(turns: Sequence[TurnUsage]) -> int:
|
|
106
|
+
return sum(
|
|
107
|
+
turn.input_tokens + turn.output_tokens + turn.cache_creation_tokens + turn.cache_read_tokens
|
|
108
|
+
for turn in turns
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode(payload_json: str) -> dict[str, object]:
|
|
113
|
+
try:
|
|
114
|
+
decoded: object = json.loads(payload_json)
|
|
115
|
+
except (json.JSONDecodeError, ValueError):
|
|
116
|
+
return {}
|
|
117
|
+
return cast("dict[str, object]", decoded) if isinstance(decoded, dict) else {}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The SetCredential use case: store a workflow credential."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from generic_ml_wrapper.application.port.inbound.set_credential import (
|
|
8
|
+
SetCredential,
|
|
9
|
+
SetCredentialCommand,
|
|
10
|
+
)
|
|
11
|
+
from generic_ml_wrapper.application.port.outbound.credentials_store import CredentialsStorePort
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SetCredentialUseCase(SetCredential):
|
|
15
|
+
"""Store a workflow credential via the credentials store."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, store: CredentialsStorePort) -> None:
|
|
18
|
+
"""Wire the use case to the credentials store.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
store: Where the credential is persisted.
|
|
22
|
+
"""
|
|
23
|
+
self._store = store
|
|
24
|
+
|
|
25
|
+
def execute(self, command: SetCredentialCommand) -> None:
|
|
26
|
+
"""Store the credential described by the command."""
|
|
27
|
+
self._store.set(command.workflow, command.name, command.value)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The StartJob use case: resolve a session, record it, run the client."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
|
|
10
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
11
|
+
from generic_ml_wrapper.application.domain.model.session import Session
|
|
12
|
+
from generic_ml_wrapper.application.domain.service.session_naming import next_session_id
|
|
13
|
+
from generic_ml_wrapper.application.port.inbound.start_job import (
|
|
14
|
+
ResumeNotSupportedError,
|
|
15
|
+
StartJob,
|
|
16
|
+
StartJobCommand,
|
|
17
|
+
UnknownWorkflowError,
|
|
18
|
+
)
|
|
19
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCallerProvider
|
|
20
|
+
from generic_ml_wrapper.application.port.outbound.credentials_store import CredentialsStorePort
|
|
21
|
+
from generic_ml_wrapper.application.port.outbound.session_store import SessionStorePort
|
|
22
|
+
from generic_ml_wrapper.application.port.outbound.workflow_source import WorkflowSourcePort
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StartJobUseCase(StartJob):
|
|
26
|
+
"""Resolve a session (new or resumed), optionally attach a workflow, run it."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
store: SessionStorePort,
|
|
31
|
+
workflows: WorkflowSourcePort,
|
|
32
|
+
callers: CliCallerProvider,
|
|
33
|
+
uuid_factory: Callable[[], str],
|
|
34
|
+
credentials: CredentialsStorePort,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Wire the use case to its outbound ports.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
store: Where sessions are persisted and read.
|
|
40
|
+
workflows: Seeds, checks, and compiles workflows.
|
|
41
|
+
callers: Resolves the client caller for a run.
|
|
42
|
+
uuid_factory: Mints a client-side session uuid for new sessions.
|
|
43
|
+
credentials: Resolves a workflow's credentials to export at launch.
|
|
44
|
+
"""
|
|
45
|
+
self._store = store
|
|
46
|
+
self._workflows = workflows
|
|
47
|
+
self._callers = callers
|
|
48
|
+
self._uuid_factory = uuid_factory
|
|
49
|
+
self._credentials = credentials
|
|
50
|
+
|
|
51
|
+
def execute(self, command: StartJobCommand) -> int:
|
|
52
|
+
"""Resolve the session, optionally inject a workflow, run the client.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
command: The request describing job, client, resume, and workflow.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The client's exit code.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
UnknownWorkflowError: If a workflow was requested but does not exist.
|
|
62
|
+
ResumeNotSupportedError: If resume was requested for a client whose
|
|
63
|
+
caller cannot resume a session.
|
|
64
|
+
"""
|
|
65
|
+
run, session = self._resolve(command)
|
|
66
|
+
if command.workflow is not None and not run.resume:
|
|
67
|
+
run = self._attach_workflow(run, command.workflow)
|
|
68
|
+
caller = self._callers.for_run(run)
|
|
69
|
+
if run.resume and not caller.can_resume():
|
|
70
|
+
message = f"session resume not supported on {run.client}"
|
|
71
|
+
raise ResumeNotSupportedError(message)
|
|
72
|
+
# Persist only once every precondition (workflow, caller, resume) has passed, so
|
|
73
|
+
# a rejected start never leaves a ghost session that burns an id and could be resumed.
|
|
74
|
+
if session is not None:
|
|
75
|
+
self._store.record(session)
|
|
76
|
+
caller.start_metering()
|
|
77
|
+
try:
|
|
78
|
+
return caller.start_client()
|
|
79
|
+
finally:
|
|
80
|
+
caller.end_metering()
|
|
81
|
+
|
|
82
|
+
def _attach_workflow(self, run: RunContext, workflow: str) -> RunContext:
|
|
83
|
+
self._workflows.seed()
|
|
84
|
+
if not self._workflows.exists(workflow):
|
|
85
|
+
message = f"unknown workflow: {workflow!r}"
|
|
86
|
+
raise UnknownWorkflowError(message)
|
|
87
|
+
kickoff = (
|
|
88
|
+
f"You are running the {workflow!r} workflow for {run.job}. Orient first — "
|
|
89
|
+
"read your context, look at what already exists, report where things stand, "
|
|
90
|
+
"then stop and wait. Do not start executing steps yet."
|
|
91
|
+
)
|
|
92
|
+
return replace(
|
|
93
|
+
run,
|
|
94
|
+
context=self._workflows.compile(workflow),
|
|
95
|
+
kickoff=kickoff,
|
|
96
|
+
env=tuple(self._credentials.resolve(workflow).items()),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _resolve(self, command: StartJobCommand) -> tuple[RunContext, Session | None]:
|
|
100
|
+
"""Mint the run (and the session to record, or ``None`` on resume) -- no write yet."""
|
|
101
|
+
if command.resume_latest:
|
|
102
|
+
latest = self._store.latest_for_job(command.job)
|
|
103
|
+
if latest is not None:
|
|
104
|
+
resumed = RunContext(
|
|
105
|
+
job=latest.job,
|
|
106
|
+
session_id=latest.session_id,
|
|
107
|
+
client=latest.client,
|
|
108
|
+
uuid=latest.uuid,
|
|
109
|
+
resume=True,
|
|
110
|
+
)
|
|
111
|
+
return resumed, None
|
|
112
|
+
session = Session(
|
|
113
|
+
session_id=next_session_id(command.job, self._store.ids_for_job(command.job)),
|
|
114
|
+
job=command.job,
|
|
115
|
+
client=command.client,
|
|
116
|
+
uuid=self._uuid_factory(),
|
|
117
|
+
)
|
|
118
|
+
run = RunContext(
|
|
119
|
+
job=session.job,
|
|
120
|
+
session_id=session.session_id,
|
|
121
|
+
client=session.client,
|
|
122
|
+
uuid=session.uuid,
|
|
123
|
+
resume=False,
|
|
124
|
+
)
|
|
125
|
+
return run, session
|