add skill activity stream and job runtime foundation (v1.1.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s

Introduce SAS v1 emit/finish/journal API, sibling CLI bridge, RPA video session integration, and tests for the host Job Runner activity timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 19:12:51 +08:00
parent 1632828372
commit 1c51139883
11 changed files with 1054 additions and 4 deletions

View File

@@ -1,3 +1,17 @@
from .activity import (
emit,
finish,
get_job_id,
get_run_mode,
job_context,
rpa_step,
step,
)
from .sibling_bridge import (
call_sibling_json,
call_sibling_json_array,
get_sibling_main_path,
)
from .client import EntitlementClient
from .config import ensure_env_file, get, get_bool, get_float, get_int
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
@@ -5,8 +19,10 @@ from .guard import enforce_entitlement
from .models import EntitlementResult
from .runtime_env import (
apply_cli_local_defaults,
ensure_runs_dir,
find_chrome_executable,
get_data_root,
get_runs_dir,
get_sibling_skills_root,
get_skills_root,
get_user_id,
@@ -52,6 +68,18 @@ except ImportError:
rpa = None # type: ignore[assignment,misc]
__all__ = [
"call_sibling_json",
"call_sibling_json_array",
"emit",
"finish",
"get_job_id",
"get_run_mode",
"get_runs_dir",
"ensure_runs_dir",
"job_context",
"rpa_step",
"step",
"get_sibling_main_path",
"EntitlementClient",
"EntitlementDeniedError",
"EntitlementError",
@@ -88,6 +116,7 @@ __all__ = [
"get_data_root",
"get_float",
"get_int",
"get_runs_dir",
"get_sibling_skills_root",
"get_skills_root",
"get_skill_log_file_path",

View File

@@ -0,0 +1,343 @@
"""
Skill Activity Stream (SAS) v1 — Run Journal + stdout result contract.
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
finish() is the only API that writes a single-line result JSON to stdout.
"""
from __future__ import annotations
import asyncio
import functools
import inspect
import json
import os
import sys
import time
import uuid
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional, TypeVar
from .runtime_env import ensure_runs_dir, get_runs_dir
_SCHEMA_VERSION = 1
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
_RESULT_MAX_CHARS = 4096
_cached_job_id: Optional[str] = None
_journal_path: Optional[str] = None
_meta_written: bool = False
_journal_file: Optional[Any] = None
F = TypeVar("F", bound=Callable[..., Any])
def _activity_debug_enabled() -> bool:
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
return v in ("1", "true", "yes", "on")
def get_run_mode() -> str:
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
if mode in ("job", "cli"):
return mode
return "cli"
def get_job_id() -> str:
global _cached_job_id
env_job = (os.getenv("JIANGCHANG_JOB_ID") or "").strip()
if env_job:
return env_job
if _cached_job_id is None:
_cached_job_id = uuid.uuid4().hex
return _cached_job_id
def _get_journal_path() -> str:
global _journal_path
if _journal_path is None:
ensure_runs_dir()
_journal_path = os.path.join(get_runs_dir(), f"{get_job_id()}.jsonl")
return _journal_path
def _open_journal() -> Any:
global _journal_file
if _journal_file is None:
path = _get_journal_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
_journal_file = open(path, "a", encoding="utf-8")
return _journal_file
def _write_journal_line(event: dict[str, Any]) -> None:
global _meta_written
f = _open_journal()
if not _meta_written:
meta = {
"type": "meta",
"schema_version": _SCHEMA_VERSION,
"job_id": get_job_id(),
"run_mode": get_run_mode(),
"ts": _now_ms(),
}
f.write(json.dumps(meta, ensure_ascii=False, separators=(",", ":")) + "\n")
f.flush()
_meta_written = True
f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
f.flush()
def _now_ms() -> int:
return int(time.time() * 1000)
def _build_event(
type_: str,
text: str,
*,
skill: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
item_id: str | None = None,
ts: int | None = None,
**extra: Any,
) -> dict[str, Any]:
cleaned = (text or "").strip()
if not cleaned:
raise ValueError("text must be non-empty")
event: dict[str, Any] = {
"type": type_,
"text": cleaned,
"schema_version": _SCHEMA_VERSION,
"ts": ts if ts is not None else _now_ms(),
}
if skill is not None:
event["skill"] = skill
if stage is not None:
event["stage"] = stage
if current is not None:
event["current"] = current
if total is not None:
event["total"] = total
if item_id is not None:
event["item_id"] = item_id
for key, value in extra.items():
if key not in event:
event[key] = value
return event
def emit(
text: str,
*,
type: str = "activity",
skill: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
item_id: str | None = None,
**extra: Any,
) -> None:
"""Append one SAS event to the Run Journal. Never writes to stdout."""
if type == "debug" and not _activity_debug_enabled():
return
if type not in _VALID_EMIT_TYPES:
raise ValueError(f"invalid emit type: {type!r}")
event = _build_event(
type,
text,
skill=skill,
stage=stage,
current=current,
total=total,
item_id=item_id,
**extra,
)
_write_journal_line(event)
def step(text: str, **kwargs: Any) -> None:
"""Shorthand for emit(type='activity')."""
emit(text, type="activity", **kwargs)
def _serialize_result(result: dict[str, Any]) -> str:
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
if len(line) <= _RESULT_MAX_CHARS:
return line
core_keys = ("type", "schema_version", "status", "skill", "message")
truncated: dict[str, Any] = {
k: result[k] for k in core_keys if k in result
}
truncated["truncated"] = True
for key, value in result.items():
if key in truncated or key == "truncated":
continue
candidate = {**truncated, key: value}
candidate_line = json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))
if len(candidate_line) <= _RESULT_MAX_CHARS:
truncated[key] = value
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
if len(line) > _RESULT_MAX_CHARS:
truncated.pop("message", None)
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
if len(line) > _RESULT_MAX_CHARS:
return line[:_RESULT_MAX_CHARS]
return line
def finish(
*,
status: str = "success",
message: str | None = None,
skill: str | None = None,
**fields: Any,
) -> None:
"""
1) Write terminal done|error event to Run Journal.
2) Print a single compact result JSON line to stdout (Agent/bash path).
"""
if status not in ("success", "partial", "failed"):
raise ValueError(f"invalid finish status: {status!r}")
journal_type = "error" if status == "failed" else "done"
terminal_text = message or ("任务失败" if status == "failed" else "任务完成")
journal_event = _build_event(
journal_type,
terminal_text,
skill=skill,
status=status,
**fields,
)
if message is not None:
journal_event["message"] = message
_write_journal_line(journal_event)
result: dict[str, Any] = {
"type": "result",
"schema_version": _SCHEMA_VERSION,
"status": status,
}
if skill is not None:
result["skill"] = skill
if message is not None:
result["message"] = message
result.update(fields)
line = _serialize_result(result)
sys.stdout.write(line + "\n")
sys.stdout.flush()
@contextmanager
def job_context(
*,
job_id: str | None = None,
skill: str | None = None,
run_mode: str | None = None,
) -> Iterator[None]:
"""Optional wrapper: sets job context; auto finish(failed) on unhandled exception."""
global _cached_job_id, _journal_path, _meta_written, _journal_file
saved_job_id = _cached_job_id
saved_journal_path = _journal_path
saved_meta = _meta_written
saved_file = _journal_file
saved_env_job = os.environ.get("JIANGCHANG_JOB_ID")
saved_env_mode = os.environ.get("JIANGCHANG_RUN_MODE")
if job_id:
os.environ["JIANGCHANG_JOB_ID"] = job_id
_cached_job_id = None
_journal_path = None
_meta_written = False
if _journal_file is not None:
try:
_journal_file.close()
except Exception:
pass
_journal_file = None
if run_mode:
os.environ["JIANGCHANG_RUN_MODE"] = run_mode
try:
yield
except Exception as exc:
finish(status="failed", message=str(exc), skill=skill)
raise
finally:
_cached_job_id = saved_job_id
_journal_path = saved_journal_path
_meta_written = saved_meta
if _journal_file is not None and _journal_file is not saved_file:
try:
_journal_file.close()
except Exception:
pass
_journal_file = saved_file
if saved_env_job is None:
os.environ.pop("JIANGCHANG_JOB_ID", None)
else:
os.environ["JIANGCHANG_JOB_ID"] = saved_env_job
if saved_env_mode is None:
os.environ.pop("JIANGCHANG_RUN_MODE", None)
else:
os.environ["JIANGCHANG_RUN_MODE"] = saved_env_mode
def rpa_step(name: str | None = None) -> Callable[[F], F]:
"""Decorator for sync/async RPA steps: emit enter/success/warn on failure."""
def decorator(fn: F) -> F:
step_name = name or fn.__name__
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
emit(f"{step_name}")
try:
result = await fn(*args, **kwargs)
emit(f"{step_name}")
return result
except Exception as exc:
emit(f"{step_name}: {exc}", type="warn")
raise
return async_wrapper # type: ignore[return-value]
@functools.wraps(fn)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
emit(f"{step_name}")
try:
result = fn(*args, **kwargs)
emit(f"{step_name}")
return result
except Exception as exc:
emit(f"{step_name}: {exc}", type="warn")
raise
return sync_wrapper # type: ignore[return-value]
return decorator
def _reset_for_tests() -> None:
"""Clear module-level journal state (tests only)."""
global _cached_job_id, _journal_path, _meta_written, _journal_file
if _journal_file is not None:
try:
_journal_file.close()
except Exception:
pass
_cached_job_id = None
_journal_path = None
_meta_written = False
_journal_file = None

View File

@@ -568,12 +568,14 @@ class RpaVideoSession:
batch_id: str,
title: str = "",
closing_title: str = "",
emit_activity: bool = True,
) -> None:
self.skill_slug = skill_slug
self.skill_data_dir = os.path.abspath(skill_data_dir)
self.batch_id = batch_id
self.title = title
self.closing_title = closing_title
self.emit_activity = emit_activity
self.enabled = _record_video_enabled()
self.warnings: List[str] = []
self.output_video_path: Optional[str] = None
@@ -631,13 +633,25 @@ class RpaVideoSession:
self.add_step(text, duration=duration)
def add_step(self, text: str, *, duration: float = 4.0) -> None:
"""记录一步骤字幕(相对录屏开始时间)。"""
"""记录一步骤字幕(相对录屏开始时间);可选同步写入 Activity Stream"""
cleaned = text.strip()
if self.emit_activity and cleaned:
try:
from .. import activity
activity.emit(
cleaned,
skill=self.skill_slug,
stage="rpa.video",
)
except Exception:
pass
if not self.enabled:
return
if self._t0 is None:
self._t0 = time.monotonic()
elapsed = time.monotonic() - self._t0
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
self._steps.append(_StepEntry(start=elapsed, text=cleaned, duration=duration))
def _close_ffmpeg_record_log(self) -> None:
fh = self._ffmpeg_record_log_file

View File

@@ -44,6 +44,20 @@ def get_user_id() -> str:
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
def get_runs_dir() -> str:
"""Run Journal 目录:{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/"""
return os.path.join(get_data_root(), ".jiangchang", "runs")
def ensure_runs_dir() -> str:
path = get_runs_dir()
try:
os.makedirs(path, exist_ok=True)
except OSError:
pass
return path
def _looks_like_skills_root(path: str) -> bool:
if not path or not os.path.isdir(path):
return False

View File

@@ -0,0 +1,71 @@
"""
兄弟技能 CLI 桥:同步 subprocess 调用并列技能 scripts/main.py。
account-manager 等兄弟技能为同步 CLI非 async、非线程池。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from typing import Any, List, Optional, Sequence
from .runtime_env import get_skills_root
from .unified_logging import subprocess_env_with_trace
def get_sibling_main_path(skill_slug: str) -> str:
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
def _python_exe() -> str:
return sys.executable
def _run_sibling(skill_slug: str, argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
main_path = get_sibling_main_path(skill_slug)
cmd = [_python_exe(), main_path, *argv]
return subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=subprocess_env_with_trace(),
)
def _parse_json_from_stdout(stdout: str) -> Any:
for line in reversed((stdout or "").splitlines()):
stripped = line.strip()
if not stripped or stripped.startswith("ERROR:"):
continue
try:
return json.loads(stripped)
except json.JSONDecodeError:
continue
return None
def call_sibling_json(skill_slug: str, argv: Sequence[str]) -> dict | None:
"""Run sibling CLI; return last JSON object line from stdout, or None."""
result = _run_sibling(skill_slug, argv)
if result.returncode != 0:
return None
parsed = _parse_json_from_stdout(result.stdout or "")
if isinstance(parsed, dict):
return parsed
return None
def call_sibling_json_array(skill_slug: str, argv: Sequence[str]) -> list | None:
"""Run sibling CLI; return last JSON array line from stdout, or None."""
result = _run_sibling(skill_slug, argv)
if result.returncode != 0:
return None
parsed = _parse_json_from_stdout(result.stdout or "")
if isinstance(parsed, list):
return parsed
return None