caudate-cli 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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
api/forge_routes.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""Forge HTTP routes — REST API for the autonomous coding harness.
|
|
2
|
+
|
|
3
|
+
Mirrors LocalForge's /api/projects, /api/features, /api/agent-sessions
|
|
4
|
+
surface, ported to Cognos's FastAPI server. All state lives in
|
|
5
|
+
data/cognos.db (planning/forge_models.py); execution is driven by
|
|
6
|
+
planning/orchestrator.py.
|
|
7
|
+
|
|
8
|
+
Endpoints:
|
|
9
|
+
GET /forge/projects list projects (with feature counts)
|
|
10
|
+
POST /forge/projects create project
|
|
11
|
+
GET /forge/projects/{id} kanban state (project + features + running)
|
|
12
|
+
DELETE /forge/projects/{id} delete project
|
|
13
|
+
POST /forge/projects/{id}/features create feature
|
|
14
|
+
POST /forge/projects/{id}/bootstrap NL → backlog (calls bootstrapper)
|
|
15
|
+
POST /forge/projects/{id}/start start orchestrator (next ready feature)
|
|
16
|
+
POST /forge/projects/{id}/stop stop all sessions for project
|
|
17
|
+
GET /forge/features/{id} one feature
|
|
18
|
+
PATCH /forge/features/{id} update feature fields
|
|
19
|
+
DELETE /forge/features/{id} delete feature
|
|
20
|
+
POST /forge/features/{id}/dependencies bulk-replace deps (LocalForge semantics)
|
|
21
|
+
POST /forge/sessions/{id}/stop stop one session
|
|
22
|
+
GET /forge/sessions/{id}/events SSE stream of one session's events
|
|
23
|
+
GET /forge/events SSE stream of all events
|
|
24
|
+
GET /forge/sessions/{id}/messages chat messages for a bootstrapper session
|
|
25
|
+
|
|
26
|
+
Same SSE format LocalForge uses (`data: <json>\n\n`); the kanban UI
|
|
27
|
+
in ui/web/app.js consumes both endpoints.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from fastapi import APIRouter, HTTPException
|
|
40
|
+
from fastapi.responses import StreamingResponse
|
|
41
|
+
from pydantic import BaseModel, Field
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ───────────────────────── Pydantic schemas ───────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CreateProjectIn(BaseModel):
|
|
50
|
+
name: str
|
|
51
|
+
description: str | None = None
|
|
52
|
+
folder_path: str | None = None # default: sandbox/projects/<slug>
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CreateFeatureIn(BaseModel):
|
|
56
|
+
title: str
|
|
57
|
+
description: str | None = None
|
|
58
|
+
acceptance_criteria: str | None = None
|
|
59
|
+
priority: int = 0
|
|
60
|
+
category: str = "functional"
|
|
61
|
+
verify_command: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UpdateFeatureIn(BaseModel):
|
|
65
|
+
title: str | None = None
|
|
66
|
+
description: str | None = None
|
|
67
|
+
acceptance_criteria: str | None = None
|
|
68
|
+
status: str | None = None
|
|
69
|
+
priority: int | None = None
|
|
70
|
+
category: str | None = None
|
|
71
|
+
verify_command: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BootstrapIn(BaseModel):
|
|
75
|
+
goal: str
|
|
76
|
+
stack_hints: list[str] = Field(default_factory=list)
|
|
77
|
+
max_features: int = 15
|
|
78
|
+
dry_run: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DependenciesIn(BaseModel):
|
|
82
|
+
depends_on: list[int] = Field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ProjectSettingsIn(BaseModel):
|
|
86
|
+
"""Per-project setting overrides. Empty string / None clears."""
|
|
87
|
+
provider: str | None = None
|
|
88
|
+
ollama_url: str | None = None
|
|
89
|
+
lm_studio_url: str | None = None
|
|
90
|
+
model: str | None = None
|
|
91
|
+
coder_prompt: str | None = None
|
|
92
|
+
dev_server_command: str | None = None
|
|
93
|
+
dev_server_port: int | None = None
|
|
94
|
+
max_concurrent_agents: int | None = None
|
|
95
|
+
playwright_enabled: bool | None = None
|
|
96
|
+
playwright_headed: bool | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class LoadExampleIn(BaseModel):
|
|
100
|
+
example: str
|
|
101
|
+
name: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ──────────────────────────── Router ─────────────────────────────────
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_router() -> APIRouter:
|
|
108
|
+
router = APIRouter(prefix="/forge", tags=["forge"])
|
|
109
|
+
|
|
110
|
+
# Lazy imports — keep server import time tight if forge isn't used.
|
|
111
|
+
def _lazy():
|
|
112
|
+
from planning import forge_models as fm
|
|
113
|
+
from planning import orchestrator as orch
|
|
114
|
+
return fm, orch
|
|
115
|
+
|
|
116
|
+
# ── Projects ───────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
@router.get("/projects")
|
|
119
|
+
async def list_projects() -> list[dict[str, Any]]:
|
|
120
|
+
fm, _ = _lazy()
|
|
121
|
+
fm.init()
|
|
122
|
+
projects = fm.list_projects()
|
|
123
|
+
# decorate with feature counts for the sidebar
|
|
124
|
+
for p in projects:
|
|
125
|
+
with fm.session_scope() as sess:
|
|
126
|
+
feats = (
|
|
127
|
+
sess.query(fm.ForgeFeature)
|
|
128
|
+
.filter_by(project_id=p["id"])
|
|
129
|
+
.all()
|
|
130
|
+
)
|
|
131
|
+
counts = {"backlog": 0, "in_progress": 0, "completed": 0}
|
|
132
|
+
for f in feats:
|
|
133
|
+
counts[f.status] = counts.get(f.status, 0) + 1
|
|
134
|
+
p["feature_counts"] = counts
|
|
135
|
+
return projects
|
|
136
|
+
|
|
137
|
+
@router.post("/projects")
|
|
138
|
+
async def create_project(body: CreateProjectIn) -> dict[str, Any]:
|
|
139
|
+
fm, _ = _lazy()
|
|
140
|
+
fm.init()
|
|
141
|
+
import re
|
|
142
|
+
folder = body.folder_path or str(
|
|
143
|
+
Path("sandbox/projects") /
|
|
144
|
+
re.sub(r"[^a-zA-Z0-9_-]+", "-", body.name.lower()).strip("-")
|
|
145
|
+
)
|
|
146
|
+
Path(folder).mkdir(parents=True, exist_ok=True)
|
|
147
|
+
try:
|
|
148
|
+
pid = fm.create_project(body.name, body.description, folder)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise HTTPException(409, str(e))
|
|
151
|
+
return fm.get_project(pid) or {}
|
|
152
|
+
|
|
153
|
+
@router.get("/projects/{project_id}")
|
|
154
|
+
async def get_kanban(project_id: int) -> dict[str, Any]:
|
|
155
|
+
_, orch = _lazy()
|
|
156
|
+
try:
|
|
157
|
+
return orch.get_kanban_state(project_id)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
status = getattr(e, "status", 500)
|
|
160
|
+
raise HTTPException(status, str(e))
|
|
161
|
+
|
|
162
|
+
@router.delete("/projects/{project_id}")
|
|
163
|
+
async def delete_project(project_id: int) -> dict[str, str]:
|
|
164
|
+
fm, orch = _lazy()
|
|
165
|
+
# Stop any running sessions first
|
|
166
|
+
orch.stop_all_for_project(project_id)
|
|
167
|
+
with fm.session_scope() as sess:
|
|
168
|
+
row = sess.get(fm.ForgeProject, project_id)
|
|
169
|
+
if row is None:
|
|
170
|
+
raise HTTPException(404, "project not found")
|
|
171
|
+
sess.delete(row)
|
|
172
|
+
return {"status": "deleted"}
|
|
173
|
+
|
|
174
|
+
@router.post("/projects/{project_id}/features")
|
|
175
|
+
async def add_feature(
|
|
176
|
+
project_id: int, body: CreateFeatureIn,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
fm, _ = _lazy()
|
|
179
|
+
try:
|
|
180
|
+
fid = fm.create_feature(
|
|
181
|
+
project_id=project_id,
|
|
182
|
+
title=body.title,
|
|
183
|
+
description=body.description,
|
|
184
|
+
acceptance_criteria=body.acceptance_criteria,
|
|
185
|
+
priority=body.priority,
|
|
186
|
+
category=body.category,
|
|
187
|
+
verify_command=body.verify_command,
|
|
188
|
+
)
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
raise HTTPException(400, str(e))
|
|
191
|
+
return {"id": fid}
|
|
192
|
+
|
|
193
|
+
@router.post("/projects/{project_id}/bootstrap")
|
|
194
|
+
async def bootstrap(project_id: int, body: BootstrapIn) -> dict[str, Any]:
|
|
195
|
+
"""Decompose a goal into features. Writes to DB unless dry_run."""
|
|
196
|
+
fm, _ = _lazy()
|
|
197
|
+
from planning.planner import Planner
|
|
198
|
+
from llm.provider import LLMProvider
|
|
199
|
+
from core.settings import Settings
|
|
200
|
+
from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL, LLM_MODEL
|
|
201
|
+
|
|
202
|
+
proj = fm.get_project(project_id)
|
|
203
|
+
if not proj:
|
|
204
|
+
raise HTTPException(404, "project not found")
|
|
205
|
+
|
|
206
|
+
# Local-first: prefer the user's System-1 Ollama model for the
|
|
207
|
+
# bootstrap (a structured-output task GLM-class models handle
|
|
208
|
+
# well). Falls back to System-2 only if no System-1 is set.
|
|
209
|
+
settings = Settings.load()
|
|
210
|
+
chosen = (
|
|
211
|
+
settings.get("system1") or SYSTEM_1_MODEL
|
|
212
|
+
or settings.get("system2") or SYSTEM_2_MODEL
|
|
213
|
+
or LLM_MODEL
|
|
214
|
+
)
|
|
215
|
+
logger.info(f"forge bootstrap model: {chosen}")
|
|
216
|
+
llm = LLMProvider(model=chosen)
|
|
217
|
+
planner = Planner(llm)
|
|
218
|
+
# Anthropic models authenticate via the user's Claude Code OAuth
|
|
219
|
+
# token (~/.claude/.credentials.json) instead of ANTHROPIC_API_KEY.
|
|
220
|
+
# subscription_auth_scope() flips a ContextVar that LLMProvider
|
|
221
|
+
# reads on every call.
|
|
222
|
+
from core.anthropic_auth import subscription_auth_scope
|
|
223
|
+
try:
|
|
224
|
+
with subscription_auth_scope():
|
|
225
|
+
features = await planner.decompose_to_features(
|
|
226
|
+
goal=body.goal,
|
|
227
|
+
stack_hints=list(body.stack_hints),
|
|
228
|
+
max_features=body.max_features,
|
|
229
|
+
)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
raise HTTPException(500, f"bootstrapper failed: {e}")
|
|
232
|
+
|
|
233
|
+
out = [f.model_dump() for f in features]
|
|
234
|
+
if body.dry_run:
|
|
235
|
+
return {"features": out, "written": False}
|
|
236
|
+
|
|
237
|
+
idx_to_id: dict[int, int] = {}
|
|
238
|
+
for idx, f in enumerate(features):
|
|
239
|
+
cat = f.category if f.category in ("functional", "style") \
|
|
240
|
+
else "functional"
|
|
241
|
+
fid = fm.create_feature(
|
|
242
|
+
project_id=project_id,
|
|
243
|
+
title=f.title,
|
|
244
|
+
description=f.description,
|
|
245
|
+
acceptance_criteria=f.acceptance_criteria,
|
|
246
|
+
priority=f.priority,
|
|
247
|
+
category=cat,
|
|
248
|
+
verify_command=f.verify_command,
|
|
249
|
+
)
|
|
250
|
+
idx_to_id[idx] = fid
|
|
251
|
+
for idx, f in enumerate(features):
|
|
252
|
+
for dep_idx in f.depends_on:
|
|
253
|
+
if dep_idx in idx_to_id and dep_idx != idx:
|
|
254
|
+
try:
|
|
255
|
+
fm.add_dependency(idx_to_id[idx], idx_to_id[dep_idx])
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.warning(f"dep wire failed: {e}")
|
|
258
|
+
return {"features": out, "written": True, "ids": list(idx_to_id.values())}
|
|
259
|
+
|
|
260
|
+
# ── Features ───────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
@router.get("/features/{feature_id}")
|
|
263
|
+
async def get_feature(feature_id: int) -> dict[str, Any]:
|
|
264
|
+
fm, _ = _lazy()
|
|
265
|
+
with fm.session_scope() as sess:
|
|
266
|
+
row = sess.get(fm.ForgeFeature, feature_id)
|
|
267
|
+
if row is None:
|
|
268
|
+
raise HTTPException(404, "feature not found")
|
|
269
|
+
deps = (
|
|
270
|
+
sess.query(fm.ForgeFeatureDep.depends_on_feature_id)
|
|
271
|
+
.filter_by(feature_id=feature_id)
|
|
272
|
+
.all()
|
|
273
|
+
)
|
|
274
|
+
return {
|
|
275
|
+
"id": row.id, "project_id": row.project_id,
|
|
276
|
+
"title": row.title, "description": row.description,
|
|
277
|
+
"acceptance_criteria": row.acceptance_criteria,
|
|
278
|
+
"status": row.status, "priority": row.priority,
|
|
279
|
+
"category": row.category,
|
|
280
|
+
"verify_command": row.verify_command,
|
|
281
|
+
"depends_on": [d[0] for d in deps],
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@router.patch("/features/{feature_id}")
|
|
285
|
+
async def update_feature(
|
|
286
|
+
feature_id: int, body: UpdateFeatureIn,
|
|
287
|
+
) -> dict[str, Any]:
|
|
288
|
+
"""Patch a feature. Routes through forge_models.update_feature
|
|
289
|
+
so the project-completion side effect (auto-promote
|
|
290
|
+
project.status when every feature lands at 'completed') fires."""
|
|
291
|
+
fm, _ = _lazy()
|
|
292
|
+
updates = body.model_dump(exclude_unset=True)
|
|
293
|
+
try:
|
|
294
|
+
row = fm.update_feature(feature_id, **updates)
|
|
295
|
+
except ValueError as e:
|
|
296
|
+
raise HTTPException(400, str(e))
|
|
297
|
+
if row is None:
|
|
298
|
+
raise HTTPException(404, "feature not found")
|
|
299
|
+
return await get_feature(feature_id)
|
|
300
|
+
|
|
301
|
+
@router.delete("/features/{feature_id}")
|
|
302
|
+
async def delete_feature(feature_id: int) -> dict[str, str]:
|
|
303
|
+
fm, _ = _lazy()
|
|
304
|
+
with fm.session_scope() as sess:
|
|
305
|
+
row = sess.get(fm.ForgeFeature, feature_id)
|
|
306
|
+
if row is None:
|
|
307
|
+
raise HTTPException(404, "feature not found")
|
|
308
|
+
sess.delete(row)
|
|
309
|
+
return {"status": "deleted"}
|
|
310
|
+
|
|
311
|
+
@router.post("/features/{feature_id}/dependencies")
|
|
312
|
+
async def set_deps(
|
|
313
|
+
feature_id: int, body: DependenciesIn,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
"""Bulk-replace deps for a feature (matches LocalForge semantics).
|
|
316
|
+
Routes through ``forge_models.set_dependencies`` so cycle
|
|
317
|
+
detection is enforced — a request that would create a cycle is
|
|
318
|
+
rejected wholesale (400)."""
|
|
319
|
+
fm, _ = _lazy()
|
|
320
|
+
if fm.get_feature(feature_id) is None:
|
|
321
|
+
raise HTTPException(404, "feature not found")
|
|
322
|
+
try:
|
|
323
|
+
fm.set_dependencies(feature_id, list(body.depends_on))
|
|
324
|
+
except ValueError as e:
|
|
325
|
+
raise HTTPException(400, str(e))
|
|
326
|
+
return {"status": "ok", "depends_on": body.depends_on}
|
|
327
|
+
|
|
328
|
+
# ── Orchestrator control ───────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
@router.post("/projects/{project_id}/start")
|
|
331
|
+
async def start_project(project_id: int) -> dict[str, Any]:
|
|
332
|
+
_, orch = _lazy()
|
|
333
|
+
try:
|
|
334
|
+
return await orch.start_orchestrator(project_id)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
status = getattr(e, "status", 500)
|
|
337
|
+
raise HTTPException(status, str(e))
|
|
338
|
+
|
|
339
|
+
@router.post("/projects/{project_id}/stop")
|
|
340
|
+
async def stop_project(project_id: int) -> dict[str, int]:
|
|
341
|
+
_, orch = _lazy()
|
|
342
|
+
n = orch.stop_all_for_project(project_id)
|
|
343
|
+
return {"stopped": n}
|
|
344
|
+
|
|
345
|
+
@router.post("/sessions/{session_id}/stop")
|
|
346
|
+
async def stop_session_endpoint(session_id: int) -> dict[str, bool]:
|
|
347
|
+
_, orch = _lazy()
|
|
348
|
+
return {"stopped": orch.stop_session(session_id)}
|
|
349
|
+
|
|
350
|
+
# ── Per-project settings (forge_settings table) ────────────────────
|
|
351
|
+
|
|
352
|
+
_INT_KEYS = {"dev_server_port", "max_concurrent_agents"}
|
|
353
|
+
_BOOL_KEYS = {"playwright_enabled", "playwright_headed"}
|
|
354
|
+
|
|
355
|
+
def _coerce(key: str, raw: str) -> Any:
|
|
356
|
+
if key in _INT_KEYS:
|
|
357
|
+
try:
|
|
358
|
+
return int(raw)
|
|
359
|
+
except ValueError:
|
|
360
|
+
return raw
|
|
361
|
+
if key in _BOOL_KEYS:
|
|
362
|
+
return raw.lower() in ("true", "1", "yes")
|
|
363
|
+
return raw
|
|
364
|
+
|
|
365
|
+
def _read_overrides(project_id: int) -> dict[str, Any]:
|
|
366
|
+
fm, _ = _lazy()
|
|
367
|
+
with fm.session_scope() as sess:
|
|
368
|
+
rows = (
|
|
369
|
+
sess.query(fm.ForgeSetting)
|
|
370
|
+
.filter_by(project_id=project_id)
|
|
371
|
+
.all()
|
|
372
|
+
)
|
|
373
|
+
return {r.key: _coerce(r.key, r.value) for r in rows}
|
|
374
|
+
|
|
375
|
+
def _global_defaults() -> dict[str, Any]:
|
|
376
|
+
from core.settings import Settings
|
|
377
|
+
from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL, LLM_MODEL
|
|
378
|
+
s = Settings.load()
|
|
379
|
+
return {
|
|
380
|
+
"provider": "ollama",
|
|
381
|
+
"ollama_url": "http://127.0.0.1:11434",
|
|
382
|
+
"lm_studio_url": "http://127.0.0.1:1234",
|
|
383
|
+
"model": (s.get("system1") or SYSTEM_1_MODEL or LLM_MODEL),
|
|
384
|
+
"coder_prompt": "",
|
|
385
|
+
"dev_server_command": "",
|
|
386
|
+
"dev_server_port": 3000,
|
|
387
|
+
"max_concurrent_agents": 3,
|
|
388
|
+
"playwright_enabled": False,
|
|
389
|
+
"playwright_headed": False,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@router.get("/projects/{project_id}/settings")
|
|
393
|
+
async def get_project_settings(project_id: int) -> dict[str, Any]:
|
|
394
|
+
fm, _ = _lazy()
|
|
395
|
+
if fm.get_project(project_id) is None:
|
|
396
|
+
raise HTTPException(404, "project not found")
|
|
397
|
+
overrides = _read_overrides(project_id)
|
|
398
|
+
defaults = _global_defaults()
|
|
399
|
+
effective = {**defaults, **{k: v for k, v in overrides.items() if v not in (None, "")}}
|
|
400
|
+
return {
|
|
401
|
+
"overrides": overrides,
|
|
402
|
+
"effective": effective,
|
|
403
|
+
"defaults": defaults,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@router.put("/projects/{project_id}/settings")
|
|
407
|
+
async def put_project_settings(
|
|
408
|
+
project_id: int, body: ProjectSettingsIn,
|
|
409
|
+
) -> dict[str, Any]:
|
|
410
|
+
fm, _ = _lazy()
|
|
411
|
+
if fm.get_project(project_id) is None:
|
|
412
|
+
raise HTTPException(404, "project not found")
|
|
413
|
+
updates = body.model_dump(exclude_unset=True)
|
|
414
|
+
with fm.session_scope() as sess:
|
|
415
|
+
for k, v in updates.items():
|
|
416
|
+
# Empty string / None clears the override; non-empty sets it.
|
|
417
|
+
row = (
|
|
418
|
+
sess.query(fm.ForgeSetting)
|
|
419
|
+
.filter_by(project_id=project_id, key=k)
|
|
420
|
+
.first()
|
|
421
|
+
)
|
|
422
|
+
if v in (None, ""):
|
|
423
|
+
if row is not None:
|
|
424
|
+
sess.delete(row)
|
|
425
|
+
else:
|
|
426
|
+
if row is None:
|
|
427
|
+
sess.add(fm.ForgeSetting(
|
|
428
|
+
project_id=project_id, key=k,
|
|
429
|
+
value=str(v),
|
|
430
|
+
))
|
|
431
|
+
else:
|
|
432
|
+
row.value = str(v)
|
|
433
|
+
return await get_project_settings(project_id)
|
|
434
|
+
|
|
435
|
+
# ── Completion stats (celebration screen) ──────────────────────────
|
|
436
|
+
|
|
437
|
+
@router.get("/projects/{project_id}/completion")
|
|
438
|
+
async def get_completion(project_id: int) -> dict[str, Any]:
|
|
439
|
+
fm, orch = _lazy()
|
|
440
|
+
proj = fm.get_project(project_id)
|
|
441
|
+
if proj is None:
|
|
442
|
+
raise HTTPException(404, "project not found")
|
|
443
|
+
with fm.session_scope() as sess:
|
|
444
|
+
feats = (
|
|
445
|
+
sess.query(fm.ForgeFeature)
|
|
446
|
+
.filter_by(project_id=project_id)
|
|
447
|
+
.all()
|
|
448
|
+
)
|
|
449
|
+
total = len(feats)
|
|
450
|
+
done = sum(1 for f in feats if f.status == "completed")
|
|
451
|
+
sessions = (
|
|
452
|
+
sess.query(fm.ForgeSession)
|
|
453
|
+
.filter_by(project_id=project_id, session_type="coding")
|
|
454
|
+
.all()
|
|
455
|
+
)
|
|
456
|
+
tests_passed = 0
|
|
457
|
+
total_runtime_s = 0.0
|
|
458
|
+
for s in sessions:
|
|
459
|
+
if s.started_at and s.ended_at:
|
|
460
|
+
total_runtime_s += (s.ended_at - s.started_at).total_seconds()
|
|
461
|
+
# Tests passed: count log rows with message_type='test_result' and "verify" in message
|
|
462
|
+
test_logs = (
|
|
463
|
+
sess.query(fm.ForgeLog)
|
|
464
|
+
.filter(
|
|
465
|
+
fm.ForgeLog.session_id.in_([s.id for s in sessions]),
|
|
466
|
+
fm.ForgeLog.message_type == "test_result",
|
|
467
|
+
)
|
|
468
|
+
.all()
|
|
469
|
+
)
|
|
470
|
+
tests_passed = len(test_logs)
|
|
471
|
+
project_started = (
|
|
472
|
+
sess.query(fm.ForgeProject.created_at)
|
|
473
|
+
.filter_by(id=project_id)
|
|
474
|
+
.scalar()
|
|
475
|
+
)
|
|
476
|
+
project_completed_at = max(
|
|
477
|
+
(f.updated_at for f in feats
|
|
478
|
+
if f.status == "completed" and f.updated_at),
|
|
479
|
+
default=None,
|
|
480
|
+
)
|
|
481
|
+
wallclock_s = (
|
|
482
|
+
(project_completed_at - project_started).total_seconds()
|
|
483
|
+
if project_completed_at and project_started else None
|
|
484
|
+
)
|
|
485
|
+
return {
|
|
486
|
+
"completion": {
|
|
487
|
+
"project_id": project_id,
|
|
488
|
+
"name": proj["name"],
|
|
489
|
+
"status": proj["status"],
|
|
490
|
+
"features_total": total,
|
|
491
|
+
"features_completed": done,
|
|
492
|
+
"tests_passed": tests_passed,
|
|
493
|
+
"agent_runtime_s": total_runtime_s,
|
|
494
|
+
"wallclock_s": wallclock_s,
|
|
495
|
+
"completed_at": (
|
|
496
|
+
project_completed_at.isoformat()
|
|
497
|
+
if project_completed_at else None
|
|
498
|
+
),
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# ── Load example project (seed) ────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
@router.post("/projects/load-example")
|
|
505
|
+
async def load_example(body: LoadExampleIn) -> dict[str, Any]:
|
|
506
|
+
from pathlib import Path as _Path
|
|
507
|
+
import json as _json
|
|
508
|
+
slug = (body.example or "").strip()
|
|
509
|
+
if not slug:
|
|
510
|
+
raise HTTPException(400, "field 'example' is required")
|
|
511
|
+
example_path = (_Path(__file__).resolve().parents[1]
|
|
512
|
+
/ "data" / "examples" / f"{slug}.json")
|
|
513
|
+
if not example_path.exists():
|
|
514
|
+
raise HTTPException(404, f"example {slug!r} not found")
|
|
515
|
+
try:
|
|
516
|
+
data = _json.loads(example_path.read_text())
|
|
517
|
+
except Exception as e:
|
|
518
|
+
raise HTTPException(500, f"failed to load example: {e}")
|
|
519
|
+
|
|
520
|
+
fm, _ = _lazy()
|
|
521
|
+
fm.init()
|
|
522
|
+
import re as _re
|
|
523
|
+
proj_name = (body.name or data.get("name") or "Example").strip()
|
|
524
|
+
folder = str(
|
|
525
|
+
_Path("sandbox/projects")
|
|
526
|
+
/ _re.sub(r"[^a-zA-Z0-9_-]+", "-", proj_name.lower()).strip("-")
|
|
527
|
+
)
|
|
528
|
+
_Path(folder).mkdir(parents=True, exist_ok=True)
|
|
529
|
+
pid = fm.create_project(
|
|
530
|
+
proj_name, data.get("description"), folder,
|
|
531
|
+
)
|
|
532
|
+
feats = data.get("features") or []
|
|
533
|
+
# Pass 1: create rows, remember per-index id
|
|
534
|
+
idx_to_id: list[int] = []
|
|
535
|
+
for f in feats:
|
|
536
|
+
cat = f.get("category") or "functional"
|
|
537
|
+
if cat not in ("functional", "style"):
|
|
538
|
+
cat = "functional"
|
|
539
|
+
fid = fm.create_feature(
|
|
540
|
+
project_id=pid,
|
|
541
|
+
title=f.get("title") or "(untitled)",
|
|
542
|
+
description=f.get("description"),
|
|
543
|
+
acceptance_criteria=f.get("acceptanceCriteria"),
|
|
544
|
+
category=cat,
|
|
545
|
+
priority=int(f.get("priority", 0)),
|
|
546
|
+
)
|
|
547
|
+
idx_to_id.append(fid)
|
|
548
|
+
# Pass 2: wire deps
|
|
549
|
+
deps_wired = 0
|
|
550
|
+
for i, f in enumerate(feats):
|
|
551
|
+
d_idx = f.get("dependsOnIndices") or []
|
|
552
|
+
d_ids = [
|
|
553
|
+
idx_to_id[j] for j in d_idx
|
|
554
|
+
if 0 <= int(j) < len(idx_to_id)
|
|
555
|
+
]
|
|
556
|
+
if d_ids:
|
|
557
|
+
try:
|
|
558
|
+
fm.set_dependencies(idx_to_id[i], d_ids)
|
|
559
|
+
deps_wired += 1
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.warning(f"dep wire failed for feature {i}: {e}")
|
|
562
|
+
return {
|
|
563
|
+
"project": fm.get_project(pid),
|
|
564
|
+
"features_created": len(idx_to_id),
|
|
565
|
+
"dependencies_wired": deps_wired,
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# ── Dev-server panel ───────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
@router.get("/projects/{project_id}/dev-server")
|
|
571
|
+
async def get_dev_server(project_id: int) -> dict[str, Any]:
|
|
572
|
+
from planning import dev_server as _ds
|
|
573
|
+
return _ds.status(project_id)
|
|
574
|
+
|
|
575
|
+
@router.post("/projects/{project_id}/dev-server")
|
|
576
|
+
async def start_dev_server(project_id: int) -> dict[str, Any]:
|
|
577
|
+
fm, _ = _lazy()
|
|
578
|
+
proj = fm.get_project(project_id)
|
|
579
|
+
if proj is None:
|
|
580
|
+
raise HTTPException(404, "project not found")
|
|
581
|
+
# Pull command + port from per-project settings, with fallbacks.
|
|
582
|
+
overrides = _read_overrides(project_id)
|
|
583
|
+
defaults = _global_defaults()
|
|
584
|
+
cmd = overrides.get("dev_server_command") or defaults.get("dev_server_command") or ""
|
|
585
|
+
port_raw = overrides.get("dev_server_port") or defaults.get("dev_server_port") or 3000
|
|
586
|
+
try:
|
|
587
|
+
port = int(port_raw)
|
|
588
|
+
except (TypeError, ValueError):
|
|
589
|
+
port = 3000
|
|
590
|
+
from planning import dev_server as _ds
|
|
591
|
+
result = _ds.start(
|
|
592
|
+
project_id, proj["folder_path"],
|
|
593
|
+
command=cmd or None, port=port,
|
|
594
|
+
)
|
|
595
|
+
if not result.get("running"):
|
|
596
|
+
raise HTTPException(400, result.get("error", "failed to start"))
|
|
597
|
+
return result
|
|
598
|
+
|
|
599
|
+
@router.delete("/projects/{project_id}/dev-server")
|
|
600
|
+
async def stop_dev_server(project_id: int) -> dict[str, Any]:
|
|
601
|
+
from planning import dev_server as _ds
|
|
602
|
+
return {"stopped": _ds.stop(project_id)}
|
|
603
|
+
|
|
604
|
+
# ── SSE streams ────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
async def _sse_iter(gen):
|
|
607
|
+
"""Convert an async-iter of dicts to text/event-stream chunks."""
|
|
608
|
+
try:
|
|
609
|
+
async for ev in gen:
|
|
610
|
+
yield f"data: {json.dumps(ev)}\n\n"
|
|
611
|
+
except asyncio.CancelledError:
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
@router.get("/sessions/{session_id}/events")
|
|
615
|
+
async def session_events(session_id: int) -> StreamingResponse:
|
|
616
|
+
_, orch = _lazy()
|
|
617
|
+
return StreamingResponse(
|
|
618
|
+
_sse_iter(orch.subscribe_session(session_id)),
|
|
619
|
+
media_type="text/event-stream",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
@router.get("/events")
|
|
623
|
+
async def all_events() -> StreamingResponse:
|
|
624
|
+
_, orch = _lazy()
|
|
625
|
+
return StreamingResponse(
|
|
626
|
+
_sse_iter(orch.subscribe_global()),
|
|
627
|
+
media_type="text/event-stream",
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
return router
|