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.
Files changed (107) hide show
  1. generic_ml_wrapper/__init__.py +13 -0
  2. generic_ml_wrapper/adapter/__init__.py +3 -0
  3. generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
  4. generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
  5. generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
  6. generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
  7. generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
  8. generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
  9. generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
  10. generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
  11. generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
  12. generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
  13. generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
  14. generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
  15. generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
  16. generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
  17. generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
  18. generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
  19. generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
  20. generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
  21. generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
  22. generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
  23. generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
  24. generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
  25. generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
  26. generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
  27. generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
  28. generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
  29. generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
  30. generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
  31. generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
  32. generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
  33. generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
  34. generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
  35. generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
  36. generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
  37. generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
  38. generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
  39. generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
  40. generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
  41. generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
  42. generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
  43. generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
  44. generic_ml_wrapper/application/__init__.py +3 -0
  45. generic_ml_wrapper/application/domain/__init__.py +3 -0
  46. generic_ml_wrapper/application/domain/model/__init__.py +3 -0
  47. generic_ml_wrapper/application/domain/model/client_status.py +44 -0
  48. generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
  49. generic_ml_wrapper/application/domain/model/run.py +35 -0
  50. generic_ml_wrapper/application/domain/model/session.py +24 -0
  51. generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
  52. generic_ml_wrapper/application/domain/model/workspace.py +31 -0
  53. generic_ml_wrapper/application/domain/service/__init__.py +3 -0
  54. generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
  55. generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
  56. generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
  57. generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
  58. generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
  59. generic_ml_wrapper/application/port/__init__.py +3 -0
  60. generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
  61. generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
  62. generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
  63. generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
  64. generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
  65. generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
  66. generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
  67. generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
  68. generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
  69. generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
  70. generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
  71. generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
  72. generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
  73. generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
  74. generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
  75. generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
  76. generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
  77. generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
  78. generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
  79. generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
  80. generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
  81. generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
  82. generic_ml_wrapper/application/usecase/__init__.py +3 -0
  83. generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
  84. generic_ml_wrapper/application/usecase/export_usage.py +93 -0
  85. generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
  86. generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
  87. generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
  88. generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
  89. generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
  90. generic_ml_wrapper/application/usecase/set_credential.py +27 -0
  91. generic_ml_wrapper/application/usecase/start_job.py +125 -0
  92. generic_ml_wrapper/application/wiring/__init__.py +3 -0
  93. generic_ml_wrapper/application/wiring/composition.py +230 -0
  94. generic_ml_wrapper/common/__init__.py +3 -0
  95. generic_ml_wrapper/common/config.py +179 -0
  96. generic_ml_wrapper/common/log.py +83 -0
  97. generic_ml_wrapper/common/paths.py +25 -0
  98. generic_ml_wrapper/common/spec_loader.py +67 -0
  99. generic_ml_wrapper/py.typed +0 -0
  100. generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
  101. generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
  102. generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
  103. generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
  104. generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
  105. generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
  106. generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
  107. 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,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Use-case implementations; depend only on ports."""
@@ -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