okstra 0.1.0
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.
- package/README.md +36 -0
- package/bin/okstra +62 -0
- package/package.json +30 -0
- package/runtime/.gitkeep +0 -0
- package/runtime/BUILD.json +5 -0
- package/runtime/agents/SKILL.md +243 -0
- package/runtime/agents/TODO.md +168 -0
- package/runtime/agents/workers/claude-worker.md +106 -0
- package/runtime/agents/workers/codex-worker.md +179 -0
- package/runtime/agents/workers/gemini-worker.md +179 -0
- package/runtime/agents/workers/report-writer-worker.md +116 -0
- package/runtime/bin/okstra-central.sh +152 -0
- package/runtime/bin/okstra-codex-exec.sh +53 -0
- package/runtime/bin/okstra-error-log.py +295 -0
- package/runtime/bin/okstra-gemini-exec.sh +55 -0
- package/runtime/bin/okstra-token-usage.py +46 -0
- package/runtime/bin/okstra.sh +162 -0
- package/runtime/prompts/launch.template.md +52 -0
- package/runtime/prompts/profiles/error-analysis.md +43 -0
- package/runtime/prompts/profiles/final-verification.md +37 -0
- package/runtime/prompts/profiles/implementation-planning.md +85 -0
- package/runtime/prompts/profiles/implementation.md +71 -0
- package/runtime/prompts/profiles/requirements-discovery.md +43 -0
- package/runtime/python/lib/okstra/cli.sh +227 -0
- package/runtime/python/lib/okstra/globals.sh +157 -0
- package/runtime/python/lib/okstra/interactive.sh +411 -0
- package/runtime/python/lib/okstra/project-resolver.sh +57 -0
- package/runtime/python/lib/okstra/usage.sh +98 -0
- package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
- package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
- package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
- package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
- package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
- package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
- package/runtime/python/lib/okstra-ctl/main.sh +41 -0
- package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
- package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
- package/runtime/python/okstra_ctl/__init__.py +125 -0
- package/runtime/python/okstra_ctl/backfill.py +253 -0
- package/runtime/python/okstra_ctl/batch.py +62 -0
- package/runtime/python/okstra_ctl/ids.py +84 -0
- package/runtime/python/okstra_ctl/index.py +216 -0
- package/runtime/python/okstra_ctl/invocation.py +49 -0
- package/runtime/python/okstra_ctl/jsonl.py +84 -0
- package/runtime/python/okstra_ctl/listing.py +156 -0
- package/runtime/python/okstra_ctl/locks.py +42 -0
- package/runtime/python/okstra_ctl/material.py +62 -0
- package/runtime/python/okstra_ctl/models.py +63 -0
- package/runtime/python/okstra_ctl/path_resolve.py +40 -0
- package/runtime/python/okstra_ctl/paths.py +251 -0
- package/runtime/python/okstra_ctl/project_meta.py +51 -0
- package/runtime/python/okstra_ctl/reconcile.py +166 -0
- package/runtime/python/okstra_ctl/render.py +1065 -0
- package/runtime/python/okstra_ctl/resolver.py +54 -0
- package/runtime/python/okstra_ctl/run.py +674 -0
- package/runtime/python/okstra_ctl/run_context.py +166 -0
- package/runtime/python/okstra_ctl/seeding.py +97 -0
- package/runtime/python/okstra_ctl/sequence.py +53 -0
- package/runtime/python/okstra_ctl/session.py +33 -0
- package/runtime/python/okstra_ctl/tmux.py +27 -0
- package/runtime/python/okstra_ctl/workers.py +64 -0
- package/runtime/python/okstra_ctl/workflow.py +182 -0
- package/runtime/python/okstra_project/__init__.py +41 -0
- package/runtime/python/okstra_project/resolver.py +126 -0
- package/runtime/python/okstra_project/state.py +170 -0
- package/runtime/python/okstra_token_usage/__init__.py +26 -0
- package/runtime/python/okstra_token_usage/blocks.py +62 -0
- package/runtime/python/okstra_token_usage/claude.py +97 -0
- package/runtime/python/okstra_token_usage/cli.py +84 -0
- package/runtime/python/okstra_token_usage/codex.py +80 -0
- package/runtime/python/okstra_token_usage/collect.py +161 -0
- package/runtime/python/okstra_token_usage/gemini.py +77 -0
- package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
- package/runtime/python/okstra_token_usage/paths.py +22 -0
- package/runtime/python/okstra_token_usage/pricing.py +71 -0
- package/runtime/python/okstra_token_usage/report.py +64 -0
- package/runtime/templates/prd/brief.template.md +273 -0
- package/runtime/templates/project-docs/task-index.template.md +65 -0
- package/runtime/templates/reports/error-analysis-input.template.md +80 -0
- package/runtime/templates/reports/final-report.template.md +167 -0
- package/runtime/templates/reports/final-verification-input.template.md +67 -0
- package/runtime/templates/reports/implementation-input.template.md +81 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
- package/runtime/templates/reports/quick-input.template.md +64 -0
- package/runtime/templates/reports/schedule.template.md +168 -0
- package/runtime/templates/reports/settings.template.json +101 -0
- package/runtime/templates/reports/task-brief.template.md +165 -0
- package/runtime/validators/lib/common.sh +44 -0
- package/runtime/validators/lib/fixtures.sh +322 -0
- package/runtime/validators/lib/paths.sh +44 -0
- package/runtime/validators/lib/runners.sh +140 -0
- package/runtime/validators/lib/summary.sh +15 -0
- package/runtime/validators/lib/validate-assets.sh +44 -0
- package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
- package/runtime/validators/lib/validate-tasks.sh +335 -0
- package/runtime/validators/validate-run.py +568 -0
- package/runtime/validators/validate-schedule.py +665 -0
- package/runtime/validators/validate-workflow.sh +190 -0
- package/src/doctor.mjs +127 -0
- package/src/install.mjs +355 -0
- package/src/paths.mjs +132 -0
- package/src/uninstall.mjs +122 -0
- package/src/version.mjs +20 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""active/projects 인덱스 행 치환·기록."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .ids import build_run_id
|
|
9
|
+
from .invocation import invocation_path, save_invocation
|
|
10
|
+
from .jsonl import append_jsonl, read_jsonl, remove_jsonl_row, rotate_recent_if_needed
|
|
11
|
+
from .project_meta import upsert_project_meta
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _replace_or_append_active_row(home: Path, run_id: str, row: dict) -> None:
|
|
15
|
+
active = home / "active.jsonl"
|
|
16
|
+
rows = read_jsonl(active)
|
|
17
|
+
replaced = False
|
|
18
|
+
for i, r in enumerate(rows):
|
|
19
|
+
if r.get("runId") == run_id:
|
|
20
|
+
rows[i] = row
|
|
21
|
+
replaced = True
|
|
22
|
+
break
|
|
23
|
+
if not replaced:
|
|
24
|
+
append_jsonl(active, row)
|
|
25
|
+
return
|
|
26
|
+
tmp = active.with_suffix(".jsonl.tmp")
|
|
27
|
+
with tmp.open("w") as f:
|
|
28
|
+
for r in rows:
|
|
29
|
+
f.write(json.dumps(r, separators=(",", ":")) + "\n")
|
|
30
|
+
os.replace(tmp, active)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _replace_or_append_project_row(home: Path, project_id: str,
|
|
34
|
+
run_id: str, row: dict) -> None:
|
|
35
|
+
proj_index = home / "projects" / project_id / "index.jsonl"
|
|
36
|
+
rows = read_jsonl(proj_index)
|
|
37
|
+
replaced = False
|
|
38
|
+
for i, r in enumerate(rows):
|
|
39
|
+
if r.get("runId") == run_id:
|
|
40
|
+
rows[i] = row
|
|
41
|
+
replaced = True
|
|
42
|
+
break
|
|
43
|
+
if not replaced:
|
|
44
|
+
append_jsonl(proj_index, row)
|
|
45
|
+
return
|
|
46
|
+
proj_index.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
tmp = proj_index.with_suffix(".jsonl.tmp")
|
|
48
|
+
with tmp.open("w") as f:
|
|
49
|
+
for r in rows:
|
|
50
|
+
f.write(json.dumps(r, separators=(",", ":")) + "\n")
|
|
51
|
+
os.replace(tmp, proj_index)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_start(home: Path, *, project_id: str, project_root: str,
|
|
55
|
+
task_group: str, task_id: str, task_type: str,
|
|
56
|
+
run_seq: int, when: str, workers: list, lead_model: str,
|
|
57
|
+
run_dir_rel: str, final_report_rel: str,
|
|
58
|
+
argv: list, cwd: str,
|
|
59
|
+
env_overrides: dict,
|
|
60
|
+
brief_sha256: str = "",
|
|
61
|
+
initial_status: str = "running",
|
|
62
|
+
final_status_rel: str = "") -> str:
|
|
63
|
+
"""run 시작 시점의 인덱스/invocation/meta 를 1 트랜잭션으로 기록.
|
|
64
|
+
반환값: 생성된 runId.
|
|
65
|
+
"""
|
|
66
|
+
run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
|
|
67
|
+
inv_path_abs = invocation_path(home, project_id, task_group, task_id,
|
|
68
|
+
task_type, run_seq)
|
|
69
|
+
inv_path_rel = inv_path_abs.relative_to(home)
|
|
70
|
+
# 종결 상태(prepared/aborted/etc) 로 시작하는 경우 finishedAt 도 같이 채워
|
|
71
|
+
# 인덱스 위치를 active 가 아닌 recent 로 라우팅한다.
|
|
72
|
+
is_terminal = initial_status != "running"
|
|
73
|
+
row = {
|
|
74
|
+
"runId": run_id,
|
|
75
|
+
"projectId": project_id,
|
|
76
|
+
"projectRoot": project_root,
|
|
77
|
+
"taskGroup": task_group,
|
|
78
|
+
"taskId": task_id,
|
|
79
|
+
"taskKey": f"{task_group}/{task_id}",
|
|
80
|
+
"taskType": task_type,
|
|
81
|
+
"runSeq": run_seq,
|
|
82
|
+
"status": initial_status,
|
|
83
|
+
"startedAt": when,
|
|
84
|
+
"finishedAt": when if is_terminal else None,
|
|
85
|
+
"workers": workers,
|
|
86
|
+
"leadModel": lead_model,
|
|
87
|
+
"validation": "not-run",
|
|
88
|
+
"finalReportRel": final_report_rel,
|
|
89
|
+
# FINAL_STATUS_FILE 의 카운터(RUN_STATUS_SEQ) 는 RUN_MANIFESTS_SEQ
|
|
90
|
+
# 와 별개로 advance 한다(render-only/실패 prep 은 manifest 만 증가).
|
|
91
|
+
# 따라서 status 파일을 추적할 때는 manifest seq 를 추정해 glob 으로
|
|
92
|
+
# 찾지 말고 run 시작 시점의 정식 경로를 row 에 박아 두고 사용한다.
|
|
93
|
+
"finalStatusRel": final_status_rel,
|
|
94
|
+
"runDirRel": run_dir_rel,
|
|
95
|
+
"invocationFile": str(inv_path_rel),
|
|
96
|
+
}
|
|
97
|
+
# okstra-ctl 가 미리 'reserving' 예약 row 를 작성했을 수 있다(P1-2). 같은
|
|
98
|
+
# runId 의 예약 row 가 있으면 update; 없으면 append.
|
|
99
|
+
existing_rows = read_jsonl(home / "active.jsonl")
|
|
100
|
+
had_reservation = any(r.get("runId") == run_id for r in existing_rows)
|
|
101
|
+
if is_terminal:
|
|
102
|
+
# active 에 들어가지 않도록 — 이미 있던 reservation 이 있으면 제거하고 recent 로.
|
|
103
|
+
if had_reservation:
|
|
104
|
+
remove_jsonl_row(home / "active.jsonl",
|
|
105
|
+
lambda r: r.get("runId") == run_id)
|
|
106
|
+
# 동일 runId 가 이미 recent.jsonl 에 있으면(예: 매니페스트 직후 사용자가
|
|
107
|
+
# 처음 okstra-ctl 을 실행해 자동 backfill 이 같은 terminal run 을 미리
|
|
108
|
+
# ingest 한 경우) 중복 append 를 방지하기 위해 기존 row 를 제거하고 새로
|
|
109
|
+
# 쓴다(replace). 그렇지 않으면 동일 runId 가 두 줄로 남아 prefix 해석이
|
|
110
|
+
# 모호해지고 runCount 도 재증가한다.
|
|
111
|
+
had_recent = bool(remove_jsonl_row(
|
|
112
|
+
home / "recent.jsonl", lambda r: r.get("runId") == run_id))
|
|
113
|
+
append_jsonl(home / "recent.jsonl", row)
|
|
114
|
+
# --render-only 나 launch-failure 같은 terminal 시작은 reconcile 의
|
|
115
|
+
# promote 경로를 거치지 않으므로 별도 rotation hook 이 필요하다.
|
|
116
|
+
# 그렇지 않으면 그런 run 만 누적된 환경에서 recent.jsonl 이 임계를
|
|
117
|
+
# 넘어도 archive 로 이동되지 않는다.
|
|
118
|
+
rotate_recent_if_needed(home)
|
|
119
|
+
else:
|
|
120
|
+
_replace_or_append_active_row(home, run_id, row)
|
|
121
|
+
had_recent = False
|
|
122
|
+
_replace_or_append_project_row(home, project_id, run_id, row)
|
|
123
|
+
save_invocation(home, project_id, task_group, task_id, task_type, run_seq, {
|
|
124
|
+
"runId": run_id,
|
|
125
|
+
"okstraVersion": os.environ.get("OKSTRA_SCRIPT_VERSION", ""),
|
|
126
|
+
"invokedAt": when,
|
|
127
|
+
"cwd": cwd,
|
|
128
|
+
"argv": argv,
|
|
129
|
+
"envOverrides": env_overrides,
|
|
130
|
+
"briefSha256": brief_sha256,
|
|
131
|
+
"backfilled": False,
|
|
132
|
+
})
|
|
133
|
+
# 예약이 이미 있었다면 runCount/activeCount 는 reserve 단계에서 이미 증가시켰다.
|
|
134
|
+
# 종결 상태로 시작했다면(terminal) activeCount 를 즉시 되돌린다.
|
|
135
|
+
# 또한 backfill 이 같은 runId 를 recent 에 미리 ingest 했었다면(had_recent)
|
|
136
|
+
# runCount 는 _apply_backfill_meta 에서 이미 가산되었고 activeCount 는
|
|
137
|
+
# 증가된 적이 없으므로, 여기서는 started/finished 모두 건너뛴다.
|
|
138
|
+
if had_recent:
|
|
139
|
+
upsert_project_meta(home, project_id, project_root=project_root,
|
|
140
|
+
when=when, started=False, finished=False)
|
|
141
|
+
else:
|
|
142
|
+
upsert_project_meta(home, project_id, project_root=project_root,
|
|
143
|
+
when=when, started=not had_reservation,
|
|
144
|
+
finished=is_terminal)
|
|
145
|
+
return run_id
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def reserve_run_in_active(home: Path, *,
|
|
149
|
+
project_id: str, project_root: str,
|
|
150
|
+
task_group: str, task_id: str, task_type: str,
|
|
151
|
+
run_seq: int, when: str,
|
|
152
|
+
run_dir_rel: str, final_report_rel: str,
|
|
153
|
+
final_status_rel: str = "") -> None:
|
|
154
|
+
"""예약 row 를 active.jsonl 에 즉시 기록한다. 중앙 락 보호 필수.
|
|
155
|
+
record_start 가 나중에 같은 (project, group, task, task_type, seq) 를 만나면
|
|
156
|
+
이 row 를 update 한다.
|
|
157
|
+
"""
|
|
158
|
+
run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
|
|
159
|
+
inv_rel = invocation_path(home, project_id, task_group, task_id,
|
|
160
|
+
task_type, run_seq).relative_to(home)
|
|
161
|
+
row = {
|
|
162
|
+
"runId": run_id,
|
|
163
|
+
"projectId": project_id,
|
|
164
|
+
"projectRoot": project_root,
|
|
165
|
+
"taskGroup": task_group,
|
|
166
|
+
"taskId": task_id,
|
|
167
|
+
"taskKey": f"{task_group}/{task_id}",
|
|
168
|
+
"taskType": task_type,
|
|
169
|
+
"runSeq": run_seq,
|
|
170
|
+
"status": "reserving",
|
|
171
|
+
"startedAt": when,
|
|
172
|
+
"finishedAt": None,
|
|
173
|
+
"workers": [],
|
|
174
|
+
"leadModel": "",
|
|
175
|
+
"validation": "not-run",
|
|
176
|
+
"finalReportRel": final_report_rel,
|
|
177
|
+
# 예약 시점 best-effort: 정상 run 은 RUN_STATUS_SEQ == RUN_MANIFESTS_SEQ
|
|
178
|
+
# 라 일치한다. record_start 가 실제 경로로 update 한다.
|
|
179
|
+
"finalStatusRel": final_status_rel,
|
|
180
|
+
"runDirRel": run_dir_rel,
|
|
181
|
+
"invocationFile": str(inv_rel),
|
|
182
|
+
}
|
|
183
|
+
append_jsonl(home / "active.jsonl", row)
|
|
184
|
+
# project index 도 미리 한 줄 — record_start 가 나중에 update 하므로 동일 row 형태로.
|
|
185
|
+
append_jsonl(home / "projects" / project_id / "index.jsonl", row)
|
|
186
|
+
# runCount/activeCount 는 예약 시점에 1회만 증가. record_start 는 had_reservation
|
|
187
|
+
# 일 때 started=False 로 호출되므로 중복 카운트 없음.
|
|
188
|
+
upsert_project_meta(home, project_id, project_root=project_root,
|
|
189
|
+
when=when, started=True)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def remove_reservation(home: Path, *, project_id: str, task_group: str,
|
|
193
|
+
task_id: str, task_type: str, run_seq: int) -> None:
|
|
194
|
+
"""예약 row 를 제거한다(spawn 실패 시 cleanup). 중앙 락 보호 필요.
|
|
195
|
+
같은 (project, group, task, task_type, seq) 의 'reserving' 행만 지운다.
|
|
196
|
+
예약 시점에 증가시켰던 runCount/activeCount 도 되돌린다.
|
|
197
|
+
"""
|
|
198
|
+
run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
|
|
199
|
+
def _match(r):
|
|
200
|
+
return (r.get("runId") == run_id and r.get("status") == "reserving")
|
|
201
|
+
removed = remove_jsonl_row(home / "active.jsonl", _match)
|
|
202
|
+
proj_index = home / "projects" / project_id / "index.jsonl"
|
|
203
|
+
if proj_index.is_file():
|
|
204
|
+
remove_jsonl_row(proj_index, _match)
|
|
205
|
+
if removed is None:
|
|
206
|
+
return
|
|
207
|
+
# reserve 가 카운터를 1 증가시켰으므로 활성/총 카운트를 되돌린다.
|
|
208
|
+
target = home / "projects" / project_id / "meta.json"
|
|
209
|
+
if not target.is_file():
|
|
210
|
+
return
|
|
211
|
+
meta = json.loads(target.read_text())
|
|
212
|
+
meta["runCount"] = max(0, int(meta.get("runCount", 0)) - 1)
|
|
213
|
+
meta["activeCount"] = max(0, int(meta.get("activeCount", 0)) - 1)
|
|
214
|
+
tmp = target.with_suffix(".json.tmp")
|
|
215
|
+
tmp.write_text(json.dumps(meta, indent=2) + "\n")
|
|
216
|
+
os.replace(tmp, target)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""invocation JSON 경로/저장/로드."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .ids import _safe_fs_segment
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def invocation_path(home: Path, project_id: str,
|
|
13
|
+
task_group: str, task_id: str, task_type: str,
|
|
14
|
+
run_seq: int) -> Path:
|
|
15
|
+
"""invocation JSON 파일 절대경로.
|
|
16
|
+
okstra 의 run_seq 는 (group, task_id, task_type) 별로 독립이므로 같은 project 안에
|
|
17
|
+
seq=1 인 run 이 여러 개일 수 있다. 따라서 파일명은 4-tuple 전체로 유일성을 보장한다.
|
|
18
|
+
"""
|
|
19
|
+
seq = f"{run_seq:02d}" if run_seq < 100 else str(run_seq)
|
|
20
|
+
# 세그먼트를 평탄 조인하면 ('feature-8','email') 과 ('feature','8-email')
|
|
21
|
+
# 가 같은 파일로 충돌하므로, 각 세그먼트를 하위 디렉터리로 분리해 경계
|
|
22
|
+
# 정보를 손실 없이 보존한다. 또한 task_group/task_id 등은 CLI/manifest
|
|
23
|
+
# 에서 들어오는 미검증 값이므로 fs-safe 슬러그로 강제 정규화해 `/` 나
|
|
24
|
+
# `..` 가 invocations 디렉터리 밖으로 path 를 escape 시키는 것을 막는다.
|
|
25
|
+
return (home / "projects" / _safe_fs_segment(project_id) / "invocations"
|
|
26
|
+
/ _safe_fs_segment(task_group) / _safe_fs_segment(task_id)
|
|
27
|
+
/ _safe_fs_segment(task_type) / f"r{seq}.json")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_invocation(home: Path, project_id: str,
|
|
31
|
+
task_group: str, task_id: str, task_type: str,
|
|
32
|
+
run_seq: int, payload: dict) -> None:
|
|
33
|
+
"""invocation 을 원자적으로 저장(임시파일 + os.replace)."""
|
|
34
|
+
target = invocation_path(home, project_id, task_group, task_id, task_type, run_seq)
|
|
35
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
tmp = target.with_suffix(".json.tmp")
|
|
37
|
+
tmp.write_text(json.dumps(payload, indent=2) + "\n")
|
|
38
|
+
os.chmod(tmp, 0o600)
|
|
39
|
+
os.replace(tmp, target)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_invocation(home: Path, project_id: str,
|
|
43
|
+
task_group: str, task_id: str, task_type: str,
|
|
44
|
+
run_seq: int) -> Optional[dict]:
|
|
45
|
+
"""invocation JSON 을 읽는다. 없으면 None."""
|
|
46
|
+
target = invocation_path(home, project_id, task_group, task_id, task_type, run_seq)
|
|
47
|
+
if not target.is_file():
|
|
48
|
+
return None
|
|
49
|
+
return json.loads(target.read_text())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""jsonl I/O 및 회전."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def append_jsonl(path: Path, row: dict) -> None:
|
|
12
|
+
"""jsonl 파일 끝에 한 줄 append. 파일이 없으면 생성."""
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
line = json.dumps(row, separators=(",", ":")) + "\n"
|
|
15
|
+
with path.open("a") as f:
|
|
16
|
+
f.write(line)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_jsonl(path: Path) -> List[dict]:
|
|
20
|
+
"""jsonl 파일을 모두 읽어 dict 목록으로 반환. 파싱 실패 라인은 스킵."""
|
|
21
|
+
if not path.is_file():
|
|
22
|
+
return []
|
|
23
|
+
rows: List[dict] = []
|
|
24
|
+
for line in path.open():
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
rows.append(json.loads(line))
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
continue
|
|
32
|
+
return rows
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def remove_jsonl_row(path: Path, match: Callable[[dict], bool]) -> Optional[dict]:
|
|
36
|
+
"""match 가 True 인 첫 행을 제거하고 반환. 없으면 None.
|
|
37
|
+
임시 파일 + os.replace 로 원자적으로 교체한다.
|
|
38
|
+
"""
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
return None
|
|
41
|
+
removed: Optional[dict] = None
|
|
42
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
43
|
+
with path.open() as src, tmp.open("w") as dst:
|
|
44
|
+
for line in src:
|
|
45
|
+
stripped = line.strip()
|
|
46
|
+
if not stripped:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
row = json.loads(stripped)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
dst.write(line)
|
|
52
|
+
continue
|
|
53
|
+
if removed is None and match(row):
|
|
54
|
+
removed = row
|
|
55
|
+
continue
|
|
56
|
+
dst.write(line)
|
|
57
|
+
os.replace(tmp, path)
|
|
58
|
+
return removed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rotate_recent_if_needed(home: Path, max_rows: int = 2000,
|
|
62
|
+
max_bytes: int = 5 * 1024 * 1024) -> Optional[Path]:
|
|
63
|
+
"""recent.jsonl 이 임계를 넘으면 archive/YYYY/YYYY-MM.jsonl 로 이동.
|
|
64
|
+
이동 후 recent.jsonl 을 비운다. 회전이 발생했으면 archive 경로 반환.
|
|
65
|
+
"""
|
|
66
|
+
recent = home / "recent.jsonl"
|
|
67
|
+
if not recent.is_file():
|
|
68
|
+
return None
|
|
69
|
+
rows = list(recent.open())
|
|
70
|
+
size_ok = recent.stat().st_size < max_bytes
|
|
71
|
+
rows_ok = len(rows) < max_rows
|
|
72
|
+
if size_ok and rows_ok:
|
|
73
|
+
return None
|
|
74
|
+
# archive 키는 첫 행의 finishedAt 월. 없으면 현재 월.
|
|
75
|
+
first_row = json.loads(rows[0]) if rows else {}
|
|
76
|
+
finished = first_row.get("finishedAt") or datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
77
|
+
yyyy, yyyymm = finished[:4], finished[:7]
|
|
78
|
+
archive = home / "archive" / yyyy / f"{yyyymm}.jsonl"
|
|
79
|
+
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
with archive.open("a") as out:
|
|
81
|
+
for line in rows:
|
|
82
|
+
out.write(line)
|
|
83
|
+
recent.write_text("")
|
|
84
|
+
return archive
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""list/format/show."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re as _re
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .jsonl import read_jsonl
|
|
11
|
+
from .reconcile import _parse_iso
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_projects(home: Path, *, only_active: bool = False,
|
|
15
|
+
sort_by: str = "recent") -> List[dict]:
|
|
16
|
+
"""projects/<id>/meta.json 을 모두 읽어 정렬된 목록을 반환."""
|
|
17
|
+
base = home / "projects"
|
|
18
|
+
if not base.is_dir():
|
|
19
|
+
return []
|
|
20
|
+
items: List[dict] = []
|
|
21
|
+
for child in base.iterdir():
|
|
22
|
+
meta_file = child / "meta.json"
|
|
23
|
+
if not meta_file.is_file():
|
|
24
|
+
continue
|
|
25
|
+
meta = json.loads(meta_file.read_text())
|
|
26
|
+
# projectRoot 가 누락/빈 문자열이면 Path("").is_dir() 이 cwd 를 가리켜
|
|
27
|
+
# True 가 되므로(missing 으로 잡히지 않음), 빈 값은 명시적으로 missing
|
|
28
|
+
# 처리한다.
|
|
29
|
+
proj_root = meta.get("projectRoot") or ""
|
|
30
|
+
meta["missing"] = (not proj_root) or (not Path(proj_root).is_dir())
|
|
31
|
+
if only_active and int(meta.get("activeCount", 0)) <= 0:
|
|
32
|
+
continue
|
|
33
|
+
items.append(meta)
|
|
34
|
+
if sort_by == "name":
|
|
35
|
+
items.sort(key=lambda m: m.get("projectId", ""))
|
|
36
|
+
elif sort_by == "count":
|
|
37
|
+
items.sort(key=lambda m: int(m.get("runCount", 0)), reverse=True)
|
|
38
|
+
else:
|
|
39
|
+
items.sort(key=lambda m: m.get("lastRunAt", ""), reverse=True)
|
|
40
|
+
return items
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_projects_table(items: List[dict]) -> str:
|
|
44
|
+
"""projects 테이블 텍스트 포맷팅."""
|
|
45
|
+
rows = [("PROJECT-ID", "RUNS", "ACTIVE", "LAST-RUN", "ROOT")]
|
|
46
|
+
for it in items:
|
|
47
|
+
root = it.get("projectRoot", "") + (" (MISSING)" if it.get("missing") else "")
|
|
48
|
+
rows.append((it.get("projectId", ""), str(it.get("runCount", 0)),
|
|
49
|
+
str(it.get("activeCount", 0)), it.get("lastRunAt", ""),
|
|
50
|
+
root))
|
|
51
|
+
widths = [max(len(r[i]) for r in rows) for i in range(5)]
|
|
52
|
+
lines = []
|
|
53
|
+
for r in rows:
|
|
54
|
+
lines.append(" ".join(c.ljust(widths[i]) for i, c in enumerate(r)))
|
|
55
|
+
return "\n".join(lines) + "\n"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_since(value: str) -> Optional[datetime]:
|
|
59
|
+
"""--since 파싱. 빈 문자열은 None(필터 없음). 형식 위반은 ValueError 로
|
|
60
|
+
fail-fast 한다. 이전엔 잘못된 값(`7days`)이 silently None 으로 강등되어
|
|
61
|
+
필터가 무력화됐고, rerun --filter --since <typo> 가 max-spawn 까지 모든
|
|
62
|
+
historical run 을 spawn 하던 회귀가 있었다.
|
|
63
|
+
"""
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
m = _re.match(r"^(\d+)([dhm])$", value)
|
|
67
|
+
if m:
|
|
68
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
69
|
+
delta = {"d": timedelta(days=n), "h": timedelta(hours=n),
|
|
70
|
+
"m": timedelta(minutes=n)}[unit]
|
|
71
|
+
return datetime.now(timezone.utc).replace(tzinfo=None) - delta
|
|
72
|
+
try:
|
|
73
|
+
return datetime.strptime(value, "%Y-%m-%d")
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"invalid --since {value!r}: expected <N>[dhm] or YYYY-MM-DD"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_runs(home: Path, *, project: str = "all", task_group: str = "all",
|
|
81
|
+
status: str = "all", since: str = "", limit: int = 0,
|
|
82
|
+
include_archive: bool = False) -> List[dict]:
|
|
83
|
+
"""active + recent (옵션 archive) 를 합쳐 필터/정렬 후 반환."""
|
|
84
|
+
rows = read_jsonl(home / "active.jsonl") + read_jsonl(home / "recent.jsonl")
|
|
85
|
+
if include_archive:
|
|
86
|
+
archive_dir = home / "archive"
|
|
87
|
+
if archive_dir.is_dir():
|
|
88
|
+
for f in sorted(archive_dir.glob("*/*.jsonl")):
|
|
89
|
+
rows.extend(read_jsonl(f))
|
|
90
|
+
threshold = _parse_since(since)
|
|
91
|
+
out: List[dict] = []
|
|
92
|
+
for r in rows:
|
|
93
|
+
if project != "all" and r.get("projectId") != project:
|
|
94
|
+
continue
|
|
95
|
+
if task_group != "all" and r.get("taskGroup") != task_group:
|
|
96
|
+
continue
|
|
97
|
+
if status != "all" and r.get("status") != status:
|
|
98
|
+
continue
|
|
99
|
+
if threshold:
|
|
100
|
+
started = _parse_iso(r.get("startedAt", ""))
|
|
101
|
+
if started and started < threshold:
|
|
102
|
+
continue
|
|
103
|
+
out.append(r)
|
|
104
|
+
out.sort(key=lambda r: r.get("startedAt", ""), reverse=True)
|
|
105
|
+
if limit > 0:
|
|
106
|
+
out = out[:limit]
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_runs_table(rows: List[dict]) -> str:
|
|
111
|
+
header = ("RUN-ID", "STATUS", "STARTED", "TASK-TYPE")
|
|
112
|
+
body = [(r.get("runId", ""), r.get("status", ""),
|
|
113
|
+
r.get("startedAt", ""), r.get("taskType", "")) for r in rows]
|
|
114
|
+
table = [header] + body
|
|
115
|
+
widths = [max(len(t[i]) for t in table) for i in range(4)]
|
|
116
|
+
lines = []
|
|
117
|
+
for t in table:
|
|
118
|
+
lines.append(" ".join(c.ljust(widths[i]) for i, c in enumerate(t)))
|
|
119
|
+
return "\n".join(lines) + "\n"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def find_row_by_run_id(home: Path, run_id: str) -> Optional[dict]:
|
|
123
|
+
"""active+recent+archive 를 모두 뒤져 runId 가 일치하는 최신 row 반환."""
|
|
124
|
+
rows = list_runs(home, include_archive=True)
|
|
125
|
+
for r in rows:
|
|
126
|
+
if r.get("runId") == run_id:
|
|
127
|
+
return r
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_show(row: dict) -> str:
|
|
132
|
+
inv = row.get("invocationFile", "")
|
|
133
|
+
return (
|
|
134
|
+
f"runId : {row.get('runId')}\n"
|
|
135
|
+
f"projectId : {row.get('projectId')}\n"
|
|
136
|
+
f"projectRoot : {row.get('projectRoot')}\n"
|
|
137
|
+
f"taskKey : {row.get('taskGroup')}/{row.get('taskId')}\n"
|
|
138
|
+
f"taskType : {row.get('taskType')}\n"
|
|
139
|
+
f"status : {row.get('status')}\n"
|
|
140
|
+
f"startedAt : {row.get('startedAt')}\n"
|
|
141
|
+
f"finishedAt : {row.get('finishedAt')}\n"
|
|
142
|
+
f"workers : {','.join(row.get('workers', []))}\n"
|
|
143
|
+
f"leadModel : {row.get('leadModel')}\n"
|
|
144
|
+
f"validation : {row.get('validation')}\n"
|
|
145
|
+
f"runDirRel : {row.get('runDirRel')}\n"
|
|
146
|
+
f"finalReportRel : {row.get('finalReportRel')}\n"
|
|
147
|
+
f"invocationFile : {inv}\n"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def absolute_final_report_path(row: dict) -> Optional[Path]:
|
|
152
|
+
project_root = row.get("projectRoot", "")
|
|
153
|
+
rel = row.get("finalReportRel", "")
|
|
154
|
+
if not project_root or not rel:
|
|
155
|
+
return None
|
|
156
|
+
return (Path(project_root) / rel).resolve()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""중앙 락과 task-lock 파일명. 다른 okstra_ctl 모듈에 의존하지 않는다."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import fcntl
|
|
6
|
+
|
|
7
|
+
from .ids import _escape_segment_for_join, _safe_fs_segment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextlib.contextmanager
|
|
11
|
+
def central_lock(home):
|
|
12
|
+
"""~/.okstra/.lock 위에 fcntl LOCK_EX. okstra-central.sh 의
|
|
13
|
+
okstra_central_with_lock 과 같은 파일을 사용하므로 record_start 와
|
|
14
|
+
상호 직렬화된다.
|
|
15
|
+
"""
|
|
16
|
+
from pathlib import Path as _Path
|
|
17
|
+
home_p = _Path(home)
|
|
18
|
+
home_p.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
lockfile = home_p / ".lock"
|
|
20
|
+
if not lockfile.exists():
|
|
21
|
+
lockfile.touch()
|
|
22
|
+
f = lockfile.open("r+")
|
|
23
|
+
try:
|
|
24
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
25
|
+
yield
|
|
26
|
+
finally:
|
|
27
|
+
f.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def task_lock_filename(project_id: str, task_group: str, task_id: str,
|
|
31
|
+
task_type: str) -> str:
|
|
32
|
+
"""task-key + task-type 단위 mutex 파일명. ~/.okstra/.locks/ 아래에 위치한다.
|
|
33
|
+
okstra 의 RUN_DIR 가 task-type 별로 분리되므로 mutex 도 같은 입자에 둔다.
|
|
34
|
+
각 세그먼트는 먼저 fs-safe 슬러그로 정규화해 `/` 나 `..` 가 .locks 디렉터리
|
|
35
|
+
밖으로 mutex 를 escape 시키는 것을 막고, 이어서 `-` 를 `--` 로 escape 한
|
|
36
|
+
뒤 `-` 로 조인해 슬래시 경계를 보존한다(('p','feature-8','email','x') 와
|
|
37
|
+
('p','feature','8-email','x') 가 같은 파일명으로 매핑되어 mutex 가 공유
|
|
38
|
+
되는 문제 방지).
|
|
39
|
+
"""
|
|
40
|
+
parts = [_escape_segment_for_join(_safe_fs_segment(s))
|
|
41
|
+
for s in (project_id, task_group, task_id, task_type)]
|
|
42
|
+
return "-".join(parts) + ".lock"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Analysis material body + related-tasks builders.
|
|
2
|
+
|
|
3
|
+
bash `build_review_material`, `resolve_related_tasks_json`,
|
|
4
|
+
`build_related_tasks_bullets`, `build_related_tasks_inline` 의 python 구현.
|
|
5
|
+
brief 본문과 directive 를 합쳐 lead 가 읽을 `analysis-material.md` 본문을
|
|
6
|
+
만들고, 이미 manifest 에 기록된 related-tasks 와 새 입력을 merge 한다.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_analysis_material(brief_path: Path, directive: str = "") -> str:
|
|
16
|
+
"""`analysis-material.md` 본문 문자열을 돌려준다."""
|
|
17
|
+
parts = ["# OKSTRA Analysis Material", "", "## Task Brief", ""]
|
|
18
|
+
parts.append(Path(brief_path).read_text(encoding="utf-8").rstrip())
|
|
19
|
+
if directive:
|
|
20
|
+
parts.append("")
|
|
21
|
+
parts.append("## Directive")
|
|
22
|
+
parts.append("")
|
|
23
|
+
parts.append(
|
|
24
|
+
"> Free-form directive supplied via `--directive`. Treat as a hard hint that may override default heuristics in lead, workers, and downstream skills (e.g. okstra-schedule's Gantt skip gate)."
|
|
25
|
+
)
|
|
26
|
+
parts.append("")
|
|
27
|
+
parts.append(directive)
|
|
28
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_related_tasks(
|
|
32
|
+
*, task_manifest_path: Optional[Path], raw_related: str
|
|
33
|
+
) -> list[str]:
|
|
34
|
+
"""기존 manifest 의 relatedTasks + 새 CSV 입력을 dedupe-merge."""
|
|
35
|
+
existing: list[str] = []
|
|
36
|
+
if task_manifest_path and Path(task_manifest_path).exists():
|
|
37
|
+
try:
|
|
38
|
+
manifest = json.loads(Path(task_manifest_path).read_text(encoding="utf-8"))
|
|
39
|
+
existing = manifest.get("relatedTasks") or []
|
|
40
|
+
if not isinstance(existing, list):
|
|
41
|
+
existing = []
|
|
42
|
+
except Exception:
|
|
43
|
+
existing = []
|
|
44
|
+
provided = [v.strip() for v in (raw_related or "").split(",") if v.strip()]
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
out: list[str] = []
|
|
47
|
+
for v in [*existing, *provided]:
|
|
48
|
+
if not v or v in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(v)
|
|
51
|
+
out.append(v)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def related_tasks_bullets(items: list[str]) -> str:
|
|
56
|
+
if not items:
|
|
57
|
+
return "- None recorded"
|
|
58
|
+
return "\n".join(f"- `{v}`" for v in items)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def related_tasks_inline(items: list[str]) -> str:
|
|
62
|
+
return ", ".join(items) if items else "None"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Model display / execution-value resolution for okstra workers.
|
|
2
|
+
|
|
3
|
+
bash `resolve_model_metadata` 의 동등한 python 구현. 사용자가 준 모델 alias
|
|
4
|
+
또는 정식 이름을 `(display, execution_value)` 쌍으로 정규화한다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
CLAUDE_MAPPING = {
|
|
11
|
+
"opus": ("opus", "opus"),
|
|
12
|
+
"opus-4-7": ("opus-4-7", "claude-opus-4-7"),
|
|
13
|
+
"claude-opus-4-7": ("opus-4-7", "claude-opus-4-7"),
|
|
14
|
+
"sonnet": ("sonnet", "sonnet"),
|
|
15
|
+
"sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
|
|
16
|
+
"claude-sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
|
|
17
|
+
"haiku": ("haiku", "haiku"),
|
|
18
|
+
"haiku-4-5": ("haiku-4-5", "claude-haiku-4-5"),
|
|
19
|
+
"claude-haiku-4-5": ("haiku-4-5", "claude-haiku-4-5"),
|
|
20
|
+
"claude-haiku-4-5-20251001": ("haiku-4-5", "claude-haiku-4-5-20251001"),
|
|
21
|
+
}
|
|
22
|
+
GEMINI_MAPPING = {
|
|
23
|
+
"auto": ("auto", "auto"),
|
|
24
|
+
"gemini auto": ("auto", "auto"),
|
|
25
|
+
"pro": ("pro", "pro"),
|
|
26
|
+
"gemini pro": ("pro", "pro"),
|
|
27
|
+
"gemini 3 flash preview": ("Gemini 3 Flash Preview", "gemini-3-flash-preview"),
|
|
28
|
+
"gemini-3-flash-preview": ("Gemini 3 Flash Preview", "gemini-3-flash-preview"),
|
|
29
|
+
"gemini-3-pro-preview": ("Gemini 3 Pro Preview", "gemini-3-pro-preview"),
|
|
30
|
+
}
|
|
31
|
+
CODEX_MAPPING = {
|
|
32
|
+
"gpt-5.5": ("gpt-5.5", "gpt-5.5"),
|
|
33
|
+
"gpt-5.4": ("gpt-5.4", "gpt-5.4"),
|
|
34
|
+
"gpt-5.4-mini": ("gpt-5.4-mini", "gpt-5.4-mini"),
|
|
35
|
+
"gpt-5.3-codex": ("gpt-5.4", "gpt-5.4"),
|
|
36
|
+
"gpt-5.2": ("gpt-5.2", "gpt-5.2"),
|
|
37
|
+
"codex-auto-review": ("codex-auto-review", "codex-auto-review"),
|
|
38
|
+
}
|
|
39
|
+
PROVIDER_MAPPINGS = {"claude": CLAUDE_MAPPING, "gemini": GEMINI_MAPPING, "codex": CODEX_MAPPING}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ModelAssignment:
|
|
44
|
+
display: str
|
|
45
|
+
execution: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_model_metadata(
|
|
49
|
+
*, provider: str, raw_value: str, default_display: str, default_execution: str,
|
|
50
|
+
) -> ModelAssignment:
|
|
51
|
+
"""alias → (display, execution_value). 알 수 없는 값은 그대로 통과.
|
|
52
|
+
|
|
53
|
+
provider: "claude" | "codex" | "gemini" (그 외는 mapping 미적용)
|
|
54
|
+
"""
|
|
55
|
+
raw_value = (raw_value or "").strip()
|
|
56
|
+
value = raw_value or default_display
|
|
57
|
+
display = value
|
|
58
|
+
execution = raw_value or default_execution
|
|
59
|
+
normalized = value.strip().lower()
|
|
60
|
+
mapping = PROVIDER_MAPPINGS.get(provider)
|
|
61
|
+
if mapping and normalized in mapping:
|
|
62
|
+
display, execution = mapping[normalized]
|
|
63
|
+
return ModelAssignment(display=display, execution=execution)
|