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,125 @@
|
|
|
1
|
+
"""okstra-ctl 와 okstra-central 가 공유하는 순수 헬퍼 패키지.
|
|
2
|
+
|
|
3
|
+
기존 단일 모듈 `okstra_ctl_lib` 를 도메인 단위 서브모듈로 분해한 결과이며,
|
|
4
|
+
모든 public 심볼을 패키지 루트에서 재노출한다. 호출자는
|
|
5
|
+
`from okstra_ctl import X` 형태로 직접 import 한다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# Public API — keep import order topological (leaves first) to avoid surprises.
|
|
10
|
+
from .ids import (
|
|
11
|
+
build_run_id,
|
|
12
|
+
parse_run_id,
|
|
13
|
+
run_id_to_session_name,
|
|
14
|
+
slugify_task_segment,
|
|
15
|
+
_escape_segment_for_join,
|
|
16
|
+
_safe_fs_segment,
|
|
17
|
+
)
|
|
18
|
+
from .locks import central_lock, task_lock_filename
|
|
19
|
+
from .invocation import invocation_path, save_invocation, load_invocation
|
|
20
|
+
from .jsonl import (
|
|
21
|
+
append_jsonl,
|
|
22
|
+
read_jsonl,
|
|
23
|
+
remove_jsonl_row,
|
|
24
|
+
rotate_recent_if_needed,
|
|
25
|
+
)
|
|
26
|
+
from .project_meta import (
|
|
27
|
+
_project_meta_path,
|
|
28
|
+
load_project_meta,
|
|
29
|
+
upsert_project_meta,
|
|
30
|
+
)
|
|
31
|
+
from .index import (
|
|
32
|
+
_replace_or_append_active_row,
|
|
33
|
+
_replace_or_append_project_row,
|
|
34
|
+
record_start,
|
|
35
|
+
reserve_run_in_active,
|
|
36
|
+
remove_reservation,
|
|
37
|
+
)
|
|
38
|
+
from .reconcile import (
|
|
39
|
+
DEFAULT_ABORT_AFTER_SECONDS,
|
|
40
|
+
_now_iso,
|
|
41
|
+
_parse_iso,
|
|
42
|
+
_read_run_manifest_validation,
|
|
43
|
+
_reconcile_active_locked,
|
|
44
|
+
normalize_central_status,
|
|
45
|
+
normalize_reconciled_report_status,
|
|
46
|
+
reconcile_active,
|
|
47
|
+
)
|
|
48
|
+
from .listing import (
|
|
49
|
+
_parse_since,
|
|
50
|
+
absolute_final_report_path,
|
|
51
|
+
find_row_by_run_id,
|
|
52
|
+
format_projects_table,
|
|
53
|
+
format_runs_table,
|
|
54
|
+
format_show,
|
|
55
|
+
list_projects,
|
|
56
|
+
list_runs,
|
|
57
|
+
)
|
|
58
|
+
from .resolver import ResolveError, _all_run_ids, resolve_last, resolve_run_id
|
|
59
|
+
from .sequence import predict_next_run_seq
|
|
60
|
+
from .tmux import _shell_quote, build_tmux_command
|
|
61
|
+
from .batch import expand_selectors, make_batch_id, write_batch_meta
|
|
62
|
+
from .backfill import (
|
|
63
|
+
_apply_backfill_meta,
|
|
64
|
+
backfill_project,
|
|
65
|
+
discover_project_roots,
|
|
66
|
+
mark_backfilled,
|
|
67
|
+
)
|
|
68
|
+
from .paths import compute_run_paths, next_run_seq
|
|
69
|
+
from .run_context import (
|
|
70
|
+
compute_and_write_run_context,
|
|
71
|
+
read_run_context,
|
|
72
|
+
read_run_inputs,
|
|
73
|
+
task_mutex,
|
|
74
|
+
write_run_inputs,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"DEFAULT_ABORT_AFTER_SECONDS",
|
|
79
|
+
"ResolveError",
|
|
80
|
+
"absolute_final_report_path",
|
|
81
|
+
"append_jsonl",
|
|
82
|
+
"backfill_project",
|
|
83
|
+
"build_run_id",
|
|
84
|
+
"build_tmux_command",
|
|
85
|
+
"central_lock",
|
|
86
|
+
"compute_and_write_run_context",
|
|
87
|
+
"compute_run_paths",
|
|
88
|
+
"discover_project_roots",
|
|
89
|
+
"expand_selectors",
|
|
90
|
+
"find_row_by_run_id",
|
|
91
|
+
"format_projects_table",
|
|
92
|
+
"format_runs_table",
|
|
93
|
+
"format_show",
|
|
94
|
+
"invocation_path",
|
|
95
|
+
"list_projects",
|
|
96
|
+
"list_runs",
|
|
97
|
+
"load_invocation",
|
|
98
|
+
"load_project_meta",
|
|
99
|
+
"make_batch_id",
|
|
100
|
+
"mark_backfilled",
|
|
101
|
+
"next_run_seq",
|
|
102
|
+
"normalize_central_status",
|
|
103
|
+
"normalize_reconciled_report_status",
|
|
104
|
+
"parse_run_id",
|
|
105
|
+
"predict_next_run_seq",
|
|
106
|
+
"read_jsonl",
|
|
107
|
+
"read_run_context",
|
|
108
|
+
"read_run_inputs",
|
|
109
|
+
"reconcile_active",
|
|
110
|
+
"record_start",
|
|
111
|
+
"remove_jsonl_row",
|
|
112
|
+
"remove_reservation",
|
|
113
|
+
"reserve_run_in_active",
|
|
114
|
+
"resolve_last",
|
|
115
|
+
"resolve_run_id",
|
|
116
|
+
"rotate_recent_if_needed",
|
|
117
|
+
"run_id_to_session_name",
|
|
118
|
+
"save_invocation",
|
|
119
|
+
"slugify_task_segment",
|
|
120
|
+
"task_lock_filename",
|
|
121
|
+
"task_mutex",
|
|
122
|
+
"upsert_project_meta",
|
|
123
|
+
"write_batch_meta",
|
|
124
|
+
"write_run_inputs",
|
|
125
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""프로젝트 backfill / discover."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re as _re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from .ids import build_run_id
|
|
11
|
+
from .invocation import invocation_path, save_invocation
|
|
12
|
+
from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
|
|
13
|
+
from .project_meta import _project_meta_path
|
|
14
|
+
from .reconcile import _now_iso, normalize_central_status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discover_project_roots(home: Path) -> List[tuple]:
|
|
18
|
+
"""`~/.okstra/projects/<projectId>/meta.json` 을 권위 소스로 (project_id,
|
|
19
|
+
project_root) 목록을 반환한다.
|
|
20
|
+
|
|
21
|
+
신규 모델에서는 okstra.sh 가 첫 실행 시 PROJECT_ROOT 를 해석해
|
|
22
|
+
`<PROJECT_ROOT>/.project-docs/okstra/project.json` 에 자기 등록하고,
|
|
23
|
+
record_start 가 그 PROJECT_ROOT 를 meta.json 에 mirror 한다. ctl 입장
|
|
24
|
+
에서는 한 번이라도 record_start 를 거친 프로젝트는 meta.json 에 등재
|
|
25
|
+
되어 있으므로, examples/projects 같은 외부 등록 디렉토리가 필요하지
|
|
26
|
+
않다.
|
|
27
|
+
|
|
28
|
+
meta.json 의 projectRoot 가 더 이상 디스크에 존재하지 않으면 스킵한다
|
|
29
|
+
(프로젝트가 이동/삭제된 경우). 기록 자체는 유지된다 — 사용자가 향후
|
|
30
|
+
이동된 위치에서 다시 record_start 를 수행하면 meta.json 이 갱신된다.
|
|
31
|
+
"""
|
|
32
|
+
out: List[tuple] = []
|
|
33
|
+
projects_dir = home / "projects"
|
|
34
|
+
if not projects_dir.is_dir():
|
|
35
|
+
return out
|
|
36
|
+
for project_dir in sorted(p for p in projects_dir.iterdir() if p.is_dir()):
|
|
37
|
+
meta_file = project_dir / "meta.json"
|
|
38
|
+
if not meta_file.is_file():
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
meta = json.loads(meta_file.read_text())
|
|
42
|
+
except (OSError, json.JSONDecodeError):
|
|
43
|
+
continue
|
|
44
|
+
pid = str(meta.get("projectId") or "")
|
|
45
|
+
root = str(meta.get("projectRoot") or "")
|
|
46
|
+
if not pid or not root:
|
|
47
|
+
continue
|
|
48
|
+
if not Path(root).is_dir():
|
|
49
|
+
continue
|
|
50
|
+
out.append((pid, root))
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
|
|
55
|
+
started_times: List[str], finished_times: List[str],
|
|
56
|
+
run_count_inc: int, active_count_inc: int) -> None:
|
|
57
|
+
"""Backfill 전용 monotonic meta 갱신.
|
|
58
|
+
upsert_project_meta 는 lastRunAt 을 무조건 인자값으로 덮어쓰므로,
|
|
59
|
+
backfill 처럼 lexicographic 순서로 N 행을 적용하면 마지막 방문 행의
|
|
60
|
+
시각이 lastRunAt 이 되어 시계열이 깨진다. 여기서는 기존 meta 와 새
|
|
61
|
+
행들의 min/max 로 firstRunAt/lastRunAt 을 단조 갱신한다.
|
|
62
|
+
"""
|
|
63
|
+
target = _project_meta_path(home, project_id)
|
|
64
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
if target.is_file():
|
|
66
|
+
meta = json.loads(target.read_text())
|
|
67
|
+
else:
|
|
68
|
+
meta = {"projectId": project_id, "projectRoot": str(project_root),
|
|
69
|
+
"firstRunAt": "", "lastRunAt": "",
|
|
70
|
+
"runCount": 0, "activeCount": 0}
|
|
71
|
+
meta["projectRoot"] = str(project_root)
|
|
72
|
+
first_pool = [t for t in started_times if t]
|
|
73
|
+
if meta.get("firstRunAt"):
|
|
74
|
+
first_pool.append(meta["firstRunAt"])
|
|
75
|
+
if first_pool:
|
|
76
|
+
meta["firstRunAt"] = min(first_pool)
|
|
77
|
+
last_pool = [t for t in (started_times + finished_times) if t]
|
|
78
|
+
if meta.get("lastRunAt"):
|
|
79
|
+
last_pool.append(meta["lastRunAt"])
|
|
80
|
+
if last_pool:
|
|
81
|
+
meta["lastRunAt"] = max(last_pool)
|
|
82
|
+
meta["runCount"] = int(meta.get("runCount", 0)) + run_count_inc
|
|
83
|
+
meta["activeCount"] = max(
|
|
84
|
+
0, int(meta.get("activeCount", 0)) + active_count_inc)
|
|
85
|
+
tmp = target.with_suffix(".json.tmp")
|
|
86
|
+
tmp.write_text(json.dumps(meta, indent=2) + "\n")
|
|
87
|
+
os.replace(tmp, target)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
|
|
91
|
+
"""타깃 프로젝트의 .project-docs/okstra/tasks 를 스캔해 누락된 run 을 인덱스에 채움.
|
|
92
|
+
실제 okstra 레이아웃: runs/<task_type>/manifests/run-manifest-<task_type>-<seq:03d>.json
|
|
93
|
+
이미 존재하는 runId 는 스킵. 새로 추가된 run 수를 반환.
|
|
94
|
+
"""
|
|
95
|
+
base = project_root / ".project-docs" / "okstra" / "tasks"
|
|
96
|
+
if not base.is_dir():
|
|
97
|
+
return 0
|
|
98
|
+
existing_index = home / "projects" / project_id / "index.jsonl"
|
|
99
|
+
existing_ids = {r["runId"] for r in read_jsonl(existing_index)}
|
|
100
|
+
added = 0
|
|
101
|
+
# meta 는 행 단위가 아니라 배치 단위로 한 번만 갱신해 시계열을 보존한다.
|
|
102
|
+
started_times: List[str] = []
|
|
103
|
+
finished_times: List[str] = []
|
|
104
|
+
active_inc = 0
|
|
105
|
+
manifest_re = _re.compile(r"^run-manifest-(?P<tt>.+)-(?P<seq>\d+)\.json$")
|
|
106
|
+
for group_dir in sorted(p for p in base.iterdir() if p.is_dir()):
|
|
107
|
+
for task_dir in sorted(p for p in group_dir.iterdir() if p.is_dir()):
|
|
108
|
+
runs = task_dir / "runs"
|
|
109
|
+
if not runs.is_dir():
|
|
110
|
+
continue
|
|
111
|
+
for type_dir in sorted(p for p in runs.iterdir() if p.is_dir()):
|
|
112
|
+
manifests = type_dir / "manifests"
|
|
113
|
+
if not manifests.is_dir():
|
|
114
|
+
continue
|
|
115
|
+
for mf in sorted(manifests.iterdir()):
|
|
116
|
+
m = manifest_re.match(mf.name)
|
|
117
|
+
if not m:
|
|
118
|
+
continue
|
|
119
|
+
task_type = m.group("tt")
|
|
120
|
+
seq = int(m.group("seq"))
|
|
121
|
+
try:
|
|
122
|
+
manifest = json.loads(mf.read_text())
|
|
123
|
+
except (OSError, json.JSONDecodeError):
|
|
124
|
+
# 부분 기록 / 손상된 매니페스트를 빈 dict 로 강등해 row 를
|
|
125
|
+
# 만들면, 빈 status 가 'completed' 로 normalize 되어
|
|
126
|
+
# recent 에 박히고, 같은 runId 가 existing_ids 로 인덱싱
|
|
127
|
+
# 되어 후속 reindex 가 정상화된 매니페스트를 다시 읽을
|
|
128
|
+
# 기회를 잃는다. 이번 backfill 에서는 건너뛰어 다음
|
|
129
|
+
# 스캔이 정상 데이터를 ingest 할 수 있도록 한다.
|
|
130
|
+
continue
|
|
131
|
+
# manifest 의 logical task key 가 디렉터리 슬러그와 다를 수
|
|
132
|
+
# 있으므로 manifest 값을 우선 사용한다(디렉터리는 fallback).
|
|
133
|
+
actual_group = manifest.get("taskGroup") or group_dir.name
|
|
134
|
+
actual_task = manifest.get("taskId") or task_dir.name
|
|
135
|
+
actual_type = manifest.get("taskType") or task_type
|
|
136
|
+
run_id = build_run_id(project_id, actual_group,
|
|
137
|
+
actual_task, actual_type, seq)
|
|
138
|
+
if run_id in existing_ids:
|
|
139
|
+
continue
|
|
140
|
+
# 실제 okstra.sh 가 쓰는 manifest 키:
|
|
141
|
+
# - runDirectoryPath (디렉터리)
|
|
142
|
+
# - expectedReportPath (보고서, 호환: reportPath)
|
|
143
|
+
# - validation: { status, passed, ... } 중첩 dict
|
|
144
|
+
run_dir_rel = (manifest.get("runDirectoryPath")
|
|
145
|
+
or manifest.get("runDirPath", ""))
|
|
146
|
+
final_rel = (manifest.get("expectedReportPath")
|
|
147
|
+
or manifest.get("reportPath")
|
|
148
|
+
or manifest.get("finalReportPath", ""))
|
|
149
|
+
# status 파일 경로는 RUN_STATUS_SEQ 기반이라 manifest seq
|
|
150
|
+
# 와 어긋날 수 있다. tail 이 정확한 파일을 추적하도록
|
|
151
|
+
# 매니페스트가 기록한 expectedStatusPath /
|
|
152
|
+
# finalStatusPath 를 우선 보존한다.
|
|
153
|
+
final_status_rel = (
|
|
154
|
+
manifest.get("expectedStatusPath")
|
|
155
|
+
or manifest.get("finalStatusPath", ""))
|
|
156
|
+
# 실제 okstra.sh 매니페스트는 worker 목록을
|
|
157
|
+
# `recommendedWorkers`, lead model 을 `teamContract.leadModel`
|
|
158
|
+
# 또는 `resultContract.leadModel` 에 저장한다. 평탄
|
|
159
|
+
# `workers`/`leadModel` 키는 호환을 위한 fallback 으로만
|
|
160
|
+
# 사용한다(없으면 backfill 시 메타데이터 손실).
|
|
161
|
+
raw_workers = (manifest.get("recommendedWorkers")
|
|
162
|
+
or manifest.get("workers") or [])
|
|
163
|
+
if not isinstance(raw_workers, list):
|
|
164
|
+
raw_workers = []
|
|
165
|
+
team_contract = manifest.get("teamContract") or {}
|
|
166
|
+
result_contract = manifest.get("resultContract") or {}
|
|
167
|
+
raw_lead_model = (
|
|
168
|
+
(team_contract.get("leadModel") if isinstance(team_contract, dict) else "")
|
|
169
|
+
or (result_contract.get("leadModel") if isinstance(result_contract, dict) else "")
|
|
170
|
+
or manifest.get("leadModel", ""))
|
|
171
|
+
raw_validation = manifest.get("validation")
|
|
172
|
+
if isinstance(raw_validation, dict):
|
|
173
|
+
validation_status = str(raw_validation.get("status") or "not-run")
|
|
174
|
+
elif isinstance(raw_validation, str):
|
|
175
|
+
validation_status = raw_validation
|
|
176
|
+
else:
|
|
177
|
+
validation_status = "not-run"
|
|
178
|
+
inv_rel = invocation_path(
|
|
179
|
+
home, project_id, actual_group, actual_task,
|
|
180
|
+
actual_type, seq).relative_to(home)
|
|
181
|
+
# okstra.sh manifest 는 createdAt/updatedAt 만 쓴다(P2 회귀).
|
|
182
|
+
# 별도 startedAt/finishedAt 가 없으면 createdAt/updatedAt 으로 보강한다.
|
|
183
|
+
started_at = (manifest.get("startedAt")
|
|
184
|
+
or manifest.get("createdAt", ""))
|
|
185
|
+
finished_at = (manifest.get("finishedAt")
|
|
186
|
+
or manifest.get("updatedAt", "")
|
|
187
|
+
or started_at)
|
|
188
|
+
normalized_status = normalize_central_status(
|
|
189
|
+
manifest.get("status", ""), validation_status)
|
|
190
|
+
# 비-터미널(running/in-progress) 매니페스트만 active 로 보내
|
|
191
|
+
# lazy reconcile 의 정상 종결 경로에 합류시킨다.
|
|
192
|
+
# prepared 는 record_start 가 terminal 로 취급하는 상태이고
|
|
193
|
+
# (--render-only 실행 산출물), final-report 가 없어 12h 후
|
|
194
|
+
# reconcile 이 aborted 로 오인 마킹할 위험이 있으므로 backfill
|
|
195
|
+
# 단계에서 recent 로 직접 보낸다.
|
|
196
|
+
is_non_terminal = normalized_status in {
|
|
197
|
+
"running", "in-progress"}
|
|
198
|
+
row = {
|
|
199
|
+
"runId": run_id, "projectId": project_id,
|
|
200
|
+
"projectRoot": str(project_root),
|
|
201
|
+
"taskGroup": actual_group, "taskId": actual_task,
|
|
202
|
+
"taskKey": f"{actual_group}/{actual_task}",
|
|
203
|
+
"taskType": actual_type, "runSeq": seq,
|
|
204
|
+
"status": normalized_status,
|
|
205
|
+
"startedAt": started_at,
|
|
206
|
+
"finishedAt": None if is_non_terminal else finished_at,
|
|
207
|
+
"workers": raw_workers,
|
|
208
|
+
"leadModel": raw_lead_model,
|
|
209
|
+
"validation": validation_status,
|
|
210
|
+
"runDirRel": run_dir_rel,
|
|
211
|
+
"finalReportRel": final_rel,
|
|
212
|
+
"finalStatusRel": final_status_rel,
|
|
213
|
+
"invocationFile": str(inv_rel),
|
|
214
|
+
}
|
|
215
|
+
if is_non_terminal:
|
|
216
|
+
append_jsonl(home / "active.jsonl", row)
|
|
217
|
+
else:
|
|
218
|
+
append_jsonl(home / "recent.jsonl", row)
|
|
219
|
+
append_jsonl(existing_index, row)
|
|
220
|
+
save_invocation(home, project_id, actual_group,
|
|
221
|
+
actual_task, actual_type, seq, {
|
|
222
|
+
"runId": run_id, "okstraVersion": "",
|
|
223
|
+
"invokedAt": row["startedAt"], "cwd": str(project_root),
|
|
224
|
+
"argv": [], "envOverrides": {},
|
|
225
|
+
"briefSha256": "",
|
|
226
|
+
"backfilled": True,
|
|
227
|
+
})
|
|
228
|
+
if row["startedAt"]:
|
|
229
|
+
started_times.append(row["startedAt"])
|
|
230
|
+
if row["finishedAt"]:
|
|
231
|
+
finished_times.append(row["finishedAt"])
|
|
232
|
+
if is_non_terminal:
|
|
233
|
+
active_inc += 1
|
|
234
|
+
added += 1
|
|
235
|
+
existing_ids.add(run_id)
|
|
236
|
+
if added:
|
|
237
|
+
# backfill 의 terminal 행도 reconcile 의 promote 경로를 거치지 않으므로
|
|
238
|
+
# 직접 rotation 을 트리거한다(과거 run 이 많은 프로젝트의 첫 backfill
|
|
239
|
+
# 에서 recent.jsonl 이 임계를 넘는 회귀 차단).
|
|
240
|
+
rotate_recent_if_needed(home)
|
|
241
|
+
_apply_backfill_meta(home, project_id, project_root,
|
|
242
|
+
started_times=started_times,
|
|
243
|
+
finished_times=finished_times,
|
|
244
|
+
run_count_inc=added,
|
|
245
|
+
active_count_inc=active_inc)
|
|
246
|
+
return added
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def mark_backfilled(home: Path) -> None:
|
|
250
|
+
state_file = home / "state.json"
|
|
251
|
+
state = json.loads(state_file.read_text()) if state_file.is_file() else {}
|
|
252
|
+
state["backfilledAt"] = _now_iso()
|
|
253
|
+
state_file.write_text(json.dumps(state, indent=2) + "\n")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""배치 메타·셀렉터 확장."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .listing import list_runs
|
|
12
|
+
from .resolver import resolve_last, resolve_run_id
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_batch_id() -> str:
|
|
16
|
+
"""20260505T143012-a1b2c3 형식 배치 ID."""
|
|
17
|
+
ts = datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y%m%dT%H%M%S")
|
|
18
|
+
return f"{ts}-{secrets.token_hex(3)}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_batch_meta(home: Path, batch_id: str, payload: dict) -> Path:
|
|
22
|
+
target = home / "batches" / f"{batch_id}.json"
|
|
23
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
tmp = target.with_suffix(".json.tmp")
|
|
25
|
+
tmp.write_text(json.dumps(payload, indent=2) + "\n")
|
|
26
|
+
os.replace(tmp, target)
|
|
27
|
+
return target
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def expand_selectors(home: Path, *, explicit: List[str], use_filter: bool,
|
|
31
|
+
project: str, task_group: str, status: str, since: str,
|
|
32
|
+
last: bool, from_stdin: bool,
|
|
33
|
+
stdin_data: Optional[str] = None) -> List[str]:
|
|
34
|
+
"""selector 들을 평탄화해 runId 목록을 반환. 중복 제거. 결정적 순서.
|
|
35
|
+
stdin_data: 호출자가 미리 읽어 전달한 stdin 페이로드. 자식 프로세스의
|
|
36
|
+
sys.stdin 이 heredoc 등으로 가려진 경우 caller 가 채워준다.
|
|
37
|
+
"""
|
|
38
|
+
import sys as _sys
|
|
39
|
+
ids: List[str] = []
|
|
40
|
+
seen = set()
|
|
41
|
+
|
|
42
|
+
def add(rid: str) -> None:
|
|
43
|
+
if rid not in seen:
|
|
44
|
+
seen.add(rid); ids.append(rid)
|
|
45
|
+
|
|
46
|
+
if last:
|
|
47
|
+
add(resolve_last(home, project=project, task_group=task_group))
|
|
48
|
+
if use_filter:
|
|
49
|
+
rows = list_runs(home, project=project, task_group=task_group,
|
|
50
|
+
status=status, since=since, include_archive=True)
|
|
51
|
+
for r in rows:
|
|
52
|
+
add(r["runId"])
|
|
53
|
+
if from_stdin:
|
|
54
|
+
source = (stdin_data.splitlines() if stdin_data is not None
|
|
55
|
+
else _sys.stdin)
|
|
56
|
+
for line in source:
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line:
|
|
59
|
+
add(resolve_run_id(home, line))
|
|
60
|
+
for q in explicit:
|
|
61
|
+
add(resolve_run_id(home, q))
|
|
62
|
+
return ids
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""runId 빌드/파싱/세그먼트 escape, 세션명. 외부 모듈 의존 없음."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re as _re
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# runId 형식: "<project-id>/<task-group>/<task-id>/<task-type>/r<run-seq>"
|
|
9
|
+
# task-type 이 포함되는 이유: okstra.sh 의 RUN_DIR 은 task-type 단위 하위 디렉터리이고
|
|
10
|
+
# 카테고리별 카운터(RUN_*_SEQ) 도 task-type 별로 독립이라, 같은 (group, task-id) 라도
|
|
11
|
+
# task-type 이 다르면 별개의 run 으로 취급해야 한다.
|
|
12
|
+
# run-seq 는 최소 2자리 zero-pad.
|
|
13
|
+
_RUN_ID_PATTERN = _re.compile(
|
|
14
|
+
r"^(?P<projectId>[^/]+)/(?P<taskGroup>[^/]+)/(?P<taskId>[^/]+)"
|
|
15
|
+
r"/(?P<taskType>[^/]+)/r(?P<runSeq>\d+)$"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_run_id(project_id: str, task_group: str, task_id: str,
|
|
20
|
+
task_type: str, run_seq: int) -> str:
|
|
21
|
+
"""runId 5-tuple 을 단일 슬러그 문자열로 합친다.
|
|
22
|
+
각 세그먼트는 fs-safe 슬러그로 정규화한다 — 그렇지 않으면 task 식별자에
|
|
23
|
+
`/` 가 섞였을 때 (group='a/b', task='c') 와 (group='a', task='b/c') 가
|
|
24
|
+
동일 runId 'p/a/b/c/type/r01' 로 충돌해 중앙 인덱스에서 다른 run 을
|
|
25
|
+
가리키거나 show/rerun 이 잘못된 run 을 대상으로 동작한다. okstra.sh 가
|
|
26
|
+
프로젝트 로컬 디렉터리에서 사용하는 slugify_value 와 동일 규칙이라
|
|
27
|
+
raw 매니페스트 값과 일관된 키를 얻는다.
|
|
28
|
+
"""
|
|
29
|
+
seq = f"{run_seq:02d}" if run_seq < 100 else str(run_seq)
|
|
30
|
+
return (f"{_safe_fs_segment(project_id)}/{_safe_fs_segment(task_group)}"
|
|
31
|
+
f"/{_safe_fs_segment(task_id)}/{_safe_fs_segment(task_type)}/r{seq}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_run_id(run_id: str) -> Dict[str, object]:
|
|
35
|
+
"""runId 슬러그를 5-tuple dict 로 분해한다. 형식이 틀리면 ValueError."""
|
|
36
|
+
match = _RUN_ID_PATTERN.match(run_id)
|
|
37
|
+
if not match:
|
|
38
|
+
raise ValueError(f"invalid runId: {run_id!r}")
|
|
39
|
+
return {
|
|
40
|
+
"projectId": match.group("projectId"),
|
|
41
|
+
"taskGroup": match.group("taskGroup"),
|
|
42
|
+
"taskId": match.group("taskId"),
|
|
43
|
+
"taskType": match.group("taskType"),
|
|
44
|
+
"runSeq": int(match.group("runSeq")),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _safe_fs_segment(value: str) -> str:
|
|
49
|
+
"""파일시스템 경로 컴포넌트로 사용하기 전에 정규화한다.
|
|
50
|
+
task_group/task_id 등은 CLI 와 manifest 에서 들어오므로 검증되지 않은
|
|
51
|
+
상태이며, `/` 나 `..` 가 들어 있으면 invocations 디렉터리 밖으로
|
|
52
|
+
탈출해 다른 프로젝트의 central 트리를 오염시킬 수 있다. okstra.sh
|
|
53
|
+
의 slugify_value 와 동일한 규칙(`[a-z0-9-]+`) 으로 강제 정규화해
|
|
54
|
+
위험 문자를 모두 제거한다. 정규화 결과가 비어 있으면 `_` 로
|
|
55
|
+
대체해 빈 path 컴포넌트가 만들어지는 것도 차단한다.
|
|
56
|
+
"""
|
|
57
|
+
slug = slugify_task_segment(value)
|
|
58
|
+
return slug if slug else "_"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _escape_segment_for_join(segment: str) -> str:
|
|
62
|
+
"""세그먼트 내부의 `-` 를 `--` 로 escape 해 단일 `-` 조인 시에도
|
|
63
|
+
원래 슬래시 경계를 손실 없이 복원할 수 있게 한다.
|
|
64
|
+
슬러그 정규화 결과(`[a-z0-9-]+`) 와 호환되며, 디코더는 단일 `-` 가
|
|
65
|
+
경계, 이중 `-` 가 세그먼트 내부 하이픈임을 식별할 수 있다.
|
|
66
|
+
"""
|
|
67
|
+
return segment.replace("-", "--")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_id_to_session_name(run_id: str) -> str:
|
|
71
|
+
"""tmux 세션명으로 변환한다. 'okstra-' 접두사 + 세그먼트 escape 후 `-` 조인.
|
|
72
|
+
예: 'a/b-c/r01' → 'okstra-a-b--c-r01'. 세그먼트 내부 `-` 는 `--` 로
|
|
73
|
+
이스케이프되어, 슬래시 위치가 다른 두 runId 가 동일 세션명으로
|
|
74
|
+
충돌하지 않는다. 세그먼트는 fs-safe 슬러그로 강제 정규화해
|
|
75
|
+
`..` 같은 traversal 토큰이 세션명에 그대로 노출되지 않게 한다.
|
|
76
|
+
"""
|
|
77
|
+
parts = [_escape_segment_for_join(_safe_fs_segment(seg))
|
|
78
|
+
for seg in run_id.split("/")]
|
|
79
|
+
return "okstra-" + "-".join(parts)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def slugify_task_segment(value: str) -> str:
|
|
83
|
+
"""Match okstra.sh slugify_value for task directory segments."""
|
|
84
|
+
return _re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|