ymai-codeflow 0.0.0-stage → 0.3.30

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.
@@ -0,0 +1,106 @@
1
+ """文本型 CLI provider:Gemini / Codex / Cursor CLI。
2
+
3
+ 与 claude 的 stream-json 协议不同,这三家 CLI 以纯文本 stdout 返回结果:
4
+ gemini gemini -p "<prompt>"
5
+ codex codex exec "<prompt>"
6
+ cursor cursor-agent -p "<prompt>"
7
+ stdout 全文作为该步骤摘要;exit 0 即成功。凭据走各家 CLI 的环境变量/本机配置
8
+ (GEMINI_API_KEY / OPENAI_API_KEY 等),不经 runtime 转手;HOME 保持调用方环境,
9
+ 允许读本机已有的 CLI 登录态。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import signal
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from .base import RunContext, RunResult, RuntimeEvent, SdlcStep
21
+
22
+ KIND_DEFAULT_CMD = {"gemini": "gemini", "codex": "codex", "cursor": "cursor-agent"}
23
+
24
+
25
+ class TextCliProvider:
26
+ name = "text-cli"
27
+
28
+ def __init__(self, kind: str, cli_binary: str, timeout_sec: int = 1800):
29
+ if kind not in KIND_DEFAULT_CMD:
30
+ raise ValueError(f"unknown text-cli kind: {kind}")
31
+ self.kind = kind
32
+ self.name = kind
33
+ self.cli_binary = cli_binary
34
+ self.timeout_sec = timeout_sec
35
+
36
+ def _argv(self, prompt: str) -> list[str]:
37
+ if self.kind in ("gemini", "cursor"):
38
+ return [self.cli_binary, "-p", prompt]
39
+ return [self.cli_binary, "exec", prompt] # codex
40
+
41
+ def prepare(self, capsule, workdir: Path) -> None:
42
+ (workdir / "deliverables").mkdir(parents=True, exist_ok=True)
43
+
44
+ def _prompt(self, step: SdlcStep, capsule) -> str:
45
+ parts = [
46
+ f"# 工单\n{capsule.workitem_md}",
47
+ f"# 当前步骤({step.step_id},顺序 {step.step_order}):{step.name}",
48
+ f"目标:{step.goal}",
49
+ ]
50
+ if step.deliverables:
51
+ parts.append("要求的产物:" + ", ".join(step.deliverables))
52
+ if step.done_gate:
53
+ parts.append(f"完成门:{step.done_gate}")
54
+ parts.append("完成后把产物写入 deliverables/ 目录。")
55
+ return "\n\n".join(parts)
56
+
57
+ def run(self, step: SdlcStep, ctx: RunContext) -> RunResult:
58
+ started = RuntimeEvent("step.started", step.step_order, step.step_id, step.name)
59
+ if ctx.emit_event:
60
+ ctx.emit_event(started)
61
+
62
+ argv = self._argv(self._prompt(step, ctx.capsule))
63
+ env = dict(os.environ)
64
+ # start_new_session:超时 killpg 不连坐 daemon(同 claude provider,DEF-009)
65
+ proc = subprocess.Popen(argv, cwd=str(ctx.workdir.root), env=env,
66
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
67
+ start_new_session=True)
68
+
69
+ def _kill_tree():
70
+ time.sleep(self.timeout_sec)
71
+ if proc.poll() is None:
72
+ try:
73
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
74
+ except (ProcessLookupError, PermissionError):
75
+ proc.kill()
76
+
77
+ watcher = threading.Thread(target=_kill_tree, daemon=True)
78
+ watcher.start()
79
+ timed_out = False
80
+ try:
81
+ out, err = proc.communicate(timeout=self.timeout_sec + 5)
82
+ except subprocess.TimeoutExpired:
83
+ timed_out = True
84
+ out, err = proc.communicate()
85
+ watcher.join(timeout=1)
86
+
87
+ text = (out or b"").decode("utf-8", "replace").strip()
88
+ if timed_out:
89
+ state, summary, error = "timed_out", "", f"{self.kind} timeout after {self.timeout_sec}s"
90
+ elif proc.returncode == 0:
91
+ state, summary, error = "completed", text or f"step {step.step_id} completed", ""
92
+ else:
93
+ state = "failed"
94
+ summary = f"{self.kind} exited with {proc.returncode}"
95
+ error = (err or b"").decode("utf-8", "replace")[-2000:] or summary
96
+
97
+ artifacts = sorted((ctx.workdir.root / "deliverables").glob("*"))
98
+ completed = RuntimeEvent("step.completed", step.step_order, step.step_id, step.name,
99
+ message=state)
100
+ if ctx.emit_event:
101
+ ctx.emit_event(completed)
102
+ return RunResult(state=state, summary=summary[:2000], error=error,
103
+ events=[started, completed], artifacts=artifacts, session_ref=None)
104
+
105
+ def cleanup(self, run_record: dict) -> None:
106
+ pass
@@ -0,0 +1,126 @@
1
+ """SQLite-backed local state (runs/events/outbox/terminal)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sqlite3
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+
10
+ _SCHEMA = """
11
+ CREATE TABLE IF NOT EXISTS runs (
12
+ dispatch_id INTEGER PRIMARY KEY,
13
+ tenant_id TEXT NOT NULL, workitem_id TEXT NOT NULL, attempt INTEGER NOT NULL,
14
+ state TEXT NOT NULL, capsule_dir TEXT, provider TEXT NOT NULL,
15
+ session_ref TEXT, error TEXT,
16
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
17
+ );
18
+ CREATE TABLE IF NOT EXISTS events (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ dispatch_id INTEGER NOT NULL, ts TEXT NOT NULL, type TEXT NOT NULL,
21
+ step_order INTEGER, step_id TEXT, step_name TEXT, payload TEXT,
22
+ FOREIGN KEY (dispatch_id) REFERENCES runs(dispatch_id)
23
+ );
24
+ CREATE INDEX IF NOT EXISTS idx_events_dispatch ON events(dispatch_id, id);
25
+ CREATE TABLE IF NOT EXISTS outbox (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ dispatch_id INTEGER, frame_type TEXT NOT NULL, payload TEXT NOT NULL,
28
+ state TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL, sent_at TEXT
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_outbox_state ON outbox(state, id);
31
+ CREATE TABLE IF NOT EXISTS terminal (
32
+ dispatch_id INTEGER PRIMARY KEY, success INTEGER NOT NULL, finished_at TEXT NOT NULL
33
+ );
34
+ """
35
+
36
+ ACTIVE_STATES = ("received", "preparing", "running")
37
+
38
+
39
+ def _now() -> str:
40
+ return time.strftime("%Y-%m-%dT%H:%M:%S%z")
41
+
42
+
43
+ class StateStore:
44
+ def __init__(self, path: str):
45
+ self.path = path
46
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
47
+ self._lock = threading.Lock()
48
+ self.conn = sqlite3.connect(path, check_same_thread=False)
49
+ self.conn.row_factory = sqlite3.Row
50
+ self.conn.execute("PRAGMA journal_mode=WAL")
51
+ self.conn.executescript(_SCHEMA)
52
+ self.conn.commit()
53
+
54
+ # ---- runs ----
55
+ def create_run(self, dispatch: dict, provider: str) -> bool:
56
+ """Insert run row; returns False if dispatch already tracked."""
57
+ try:
58
+ with self._lock, self.conn:
59
+ self.conn.execute(
60
+ "INSERT INTO runs (dispatch_id, tenant_id, workitem_id, attempt, state, provider, created_at, updated_at)"
61
+ " VALUES (?,?,?,?,?,?,?,?)",
62
+ (int(dispatch["dispatchId"]), str(dispatch.get("tenantId")), str(dispatch.get("workitemId")),
63
+ int(dispatch.get("attempt", 1)), "received", provider, _now(), _now()))
64
+ return True
65
+ except sqlite3.IntegrityError:
66
+ return False
67
+
68
+ def run_state(self, dispatch_id: int) -> str | None:
69
+ row = self.conn.execute("SELECT state FROM runs WHERE dispatch_id=?", (dispatch_id,)).fetchone()
70
+ return row[0] if row else None
71
+
72
+ def update_run(self, dispatch_id: int, **fields) -> None:
73
+ if not fields:
74
+ return
75
+ sets = ", ".join(f"{k}=?" for k in fields)
76
+ vals = list(fields.values()) + [_now(), dispatch_id]
77
+ with self._lock, self.conn:
78
+ self.conn.execute(f"UPDATE runs SET {sets}, updated_at=? WHERE dispatch_id=?", vals)
79
+
80
+ def set_session(self, dispatch_id: int, session_ref: str) -> None:
81
+ self.update_run(dispatch_id, session_ref=session_ref)
82
+
83
+ def get_session(self, dispatch_id: int) -> str | None:
84
+ row = self.conn.execute("SELECT session_ref FROM runs WHERE dispatch_id=?", (dispatch_id,)).fetchone()
85
+ return row[0] if row else None
86
+
87
+ def active_runs(self) -> list[sqlite3.Row]:
88
+ q = ",".join("?" * len(ACTIVE_STATES))
89
+ return self.conn.execute(f"SELECT * FROM runs WHERE state IN ({q})", ACTIVE_STATES).fetchall()
90
+
91
+ # ---- events ----
92
+ def add_event(self, dispatch_id: int, type_: str, *, step_order=None, step_id=None,
93
+ step_name=None, payload: dict | None = None) -> None:
94
+ with self._lock, self.conn:
95
+ self.conn.execute(
96
+ "INSERT INTO events (dispatch_id, ts, type, step_order, step_id, step_name, payload) VALUES (?,?,?,?,?,?,?)",
97
+ (dispatch_id, _now(), type_, step_order, step_id, step_name,
98
+ json.dumps(payload, ensure_ascii=False) if payload else None))
99
+
100
+ # ---- outbox ----
101
+ def enqueue_frame(self, frame_type: str, payload: dict, dispatch_id: int | None = None) -> int:
102
+ with self._lock, self.conn:
103
+ cur = self.conn.execute(
104
+ "INSERT INTO outbox (dispatch_id, frame_type, payload, created_at) VALUES (?,?,?,?)",
105
+ (dispatch_id, frame_type, json.dumps(payload, ensure_ascii=False), _now()))
106
+ return cur.lastrowid # type: ignore[return-value]
107
+
108
+ def pending_frames(self) -> list[sqlite3.Row]:
109
+ return self.conn.execute(
110
+ "SELECT id, dispatch_id, frame_type, payload FROM outbox WHERE state='pending' ORDER BY id").fetchall()
111
+
112
+ def mark_sent(self, outbox_id: int) -> None:
113
+ with self._lock, self.conn:
114
+ self.conn.execute("UPDATE outbox SET state='sent', sent_at=? WHERE id=?", (_now(), outbox_id))
115
+
116
+ # ---- terminal / idempotency ----
117
+ def is_terminal(self, dispatch_id: int) -> bool:
118
+ row = self.conn.execute("SELECT 1 FROM terminal WHERE dispatch_id=?", (dispatch_id,)).fetchone()
119
+ return row is not None
120
+
121
+ def mark_terminal(self, dispatch_id: int, success: bool) -> None:
122
+ with self._lock, self.conn:
123
+ self.conn.execute("INSERT OR REPLACE INTO terminal (dispatch_id, success, finished_at) VALUES (?,?,?)",
124
+ (dispatch_id, 1 if success else 0, _now()))
125
+ self.conn.execute("UPDATE runs SET state=?, updated_at=? WHERE dispatch_id=?",
126
+ ("completed" if success else "failed", _now(), dispatch_id))
@@ -0,0 +1,174 @@
1
+ """Minimal RFC6455 WebSocket client (text frames), stdlib only.
2
+
3
+ Implements the subset needed by the protocol: client->server masked text frames,
4
+ server->client unmasked text/binary/ping/close, client-initiated close.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import socket
13
+ import ssl
14
+ import struct
15
+ import threading
16
+
17
+ _GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
18
+
19
+
20
+ class WsClosed(Exception):
21
+ pass
22
+
23
+
24
+ class WsIdleTimeout(WsClosed):
25
+ """Read timeout with no inbound frames; the connection may still be alive."""
26
+ pass
27
+
28
+
29
+ class WsClient:
30
+ def __init__(self, host: str, port: int, path: str, use_tls: bool = False,
31
+ extra_headers: dict | None = None, timeout: float = 60.0):
32
+ self.host, self.port, self.path = host, port, path
33
+ self.use_tls = use_tls
34
+ self.extra_headers = extra_headers or {}
35
+ self.timeout = timeout
36
+ self.sock: socket.socket | None = None
37
+ self._send_lock = threading.Lock()
38
+ self._leftover = b""
39
+
40
+ # ---- handshake -------------------------------------------------------
41
+ def connect(self) -> None:
42
+ raw = socket.create_connection((self.host, self.port), timeout=self.timeout)
43
+ if self.use_tls:
44
+ ctx = ssl.create_default_context()
45
+ raw = ctx.wrap_socket(raw, server_hostname=self.host.split(":")[0])
46
+ key = base64.b64encode(os.urandom(16)).decode()
47
+ req = (
48
+ f"GET {self.path} HTTP/1.1\r\n"
49
+ f"Host: {self.host}\r\n"
50
+ "Upgrade: websocket\r\n"
51
+ "Connection: Upgrade\r\n"
52
+ f"Sec-WebSocket-Key: {key}\r\n"
53
+ "Sec-WebSocket-Version: 13\r\n"
54
+ )
55
+ for k, v in self.extra_headers.items():
56
+ req += f"{k}: {v}\r\n"
57
+ req += "\r\n"
58
+ raw.sendall(req.encode())
59
+ raw.settimeout(self.timeout)
60
+ status, headers = self._read_http_response(raw)
61
+ if status != 101:
62
+ raw.close()
63
+ raise WsClosed(f"handshake rejected: HTTP {status}: {headers.get('x-error', headers)}")
64
+ expect = base64.b64encode(hashlib.sha1((key + _GUID).encode()).digest()).decode()
65
+ if headers.get("sec-websocket-accept") != expect:
66
+ raw.close()
67
+ raise WsClosed("bad Sec-WebSocket-Accept")
68
+ self.sock = raw
69
+
70
+ def _read_http_response(self, sock: socket.socket) -> tuple[int, dict]:
71
+ data = b""
72
+ while b"\r\n\r\n" not in data:
73
+ chunk = sock.recv(4096)
74
+ if not chunk:
75
+ raise WsClosed("connection closed during handshake")
76
+ data += chunk
77
+ if len(data) > 65536:
78
+ raise WsClosed("handshake response too large")
79
+ head, _, rest = data.partition(b"\r\n\r\n")
80
+ lines = head.decode("latin-1").split("\r\n")
81
+ status = int(lines[0].split(" ")[1]) if len(lines[0].split(" ")) > 1 else 0
82
+ headers = {}
83
+ for line in lines[1:]:
84
+ if ":" in line:
85
+ k, v = line.split(":", 1)
86
+ headers[k.strip().lower()] = v.strip()
87
+ # stash leftover bytes for frame reader
88
+ self._leftover = rest
89
+ return status, headers
90
+
91
+ # ---- frames ----------------------------------------------------------
92
+ @staticmethod
93
+ def _encode_frame(opcode: int, payload: bytes) -> bytes:
94
+ mask = os.urandom(4)
95
+ header = bytes([0x80 | opcode])
96
+ n = len(payload)
97
+ if n < 126:
98
+ header += bytes([0x80 | n])
99
+ elif n < 65536:
100
+ header += bytes([0x80 | 126]) + struct.pack(">H", n)
101
+ else:
102
+ header += bytes([0x80 | 127]) + struct.pack(">Q", n)
103
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
104
+ return header + mask + masked
105
+
106
+ def _recv_exact(self, n: int) -> bytes:
107
+ assert self.sock is not None
108
+ buf = bytearray(self._leftover)
109
+ self._leftover = b""
110
+ while len(buf) < n:
111
+ try:
112
+ chunk = self.sock.recv(n - len(buf))
113
+ except socket.timeout as e:
114
+ raise WsIdleTimeout("read idle timeout") from e
115
+ except OSError as e:
116
+ raise WsClosed(f"socket error: {e}") from e
117
+ if not chunk:
118
+ raise WsClosed("connection closed")
119
+ buf.extend(chunk)
120
+ return bytes(buf)
121
+
122
+ def recv_frame(self) -> tuple[int, bytes]:
123
+ """Returns (opcode, payload). Auto-answers ping; raises WsClosed on close."""
124
+ if self.sock is None:
125
+ raise WsClosed("not connected")
126
+ b1, b2 = self._recv_exact(2)
127
+ opcode = b1 & 0x0F
128
+ masked = b2 & 0x80
129
+ n = b2 & 0x7F
130
+ if n == 126:
131
+ (n,) = struct.unpack(">H", self._recv_exact(2))
132
+ elif n == 127:
133
+ (n,) = struct.unpack(">Q", self._recv_exact(8))
134
+ mask = self._recv_exact(4) if masked else None
135
+ payload = self._recv_exact(n) if n else b""
136
+ if mask:
137
+ payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
138
+ if opcode == 0x9: # ping -> pong
139
+ self._send_raw(0xA, payload)
140
+ return self.recv_frame()
141
+ if opcode == 0x8:
142
+ raise WsClosed(f"server closed: {payload[:120]!r}")
143
+ if opcode in (0x1, 0x2, 0x0):
144
+ return opcode, payload
145
+ return self.recv_frame() # ignore unknown (e.g. pong)
146
+
147
+ def _send_raw(self, opcode: int, payload: bytes) -> None:
148
+ if self.sock is None:
149
+ raise WsClosed("not connected")
150
+ with self._send_lock:
151
+ self.sock.sendall(self._encode_frame(opcode, payload))
152
+
153
+ def send_text(self, text: str) -> None:
154
+ self._send_raw(0x1, text.encode())
155
+
156
+ def send_json(self, obj: dict) -> None:
157
+ self.send_text(json.dumps(obj, ensure_ascii=False))
158
+
159
+ def recv_json(self) -> dict:
160
+ while True:
161
+ _, payload = self.recv_frame()
162
+ return json.loads(payload.decode())
163
+
164
+ def close(self) -> None:
165
+ if self.sock is not None:
166
+ try:
167
+ self._send_raw(0x8, struct.pack(">H", 1000))
168
+ except OSError:
169
+ pass
170
+ try:
171
+ self.sock.close()
172
+ except OSError:
173
+ pass
174
+ self.sock = None
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.