Files
codex-app-server/cloud/lifecycle.py
host be2962525b code-to10 v6 A7: unify row_get on peer_messages
- peer_messages.row_get gains default=None (was None-only); the
  three old variants agree on all real row types (None / dict /
  sqlite3.Row): auto_memory's old default was already None, and
  lifecycle's old None special-case and dict .get give identical
  results on those types, so no call-site behavior changes and no
  call site passes a non-None default.
- auto_memory and lifecycle delete their private _row_get copies
  and import peer_messages.row_get as _row_get (precedent: server,
  worker, handler_project, handler_support already do this);
  peer_messages imports no cloud modules, so no import cycle.
  lifecycle's old broad-Exception catch differs only for pathological
  __getitem__ inputs, which are not DB rows and are out of contract;
  this is documented on row_get, not left as a second story.
- Scope note: other modules' own row_get copies (approvals,
  automations, marker_tools, todos, wait_ops) are out of this item's
  stated scope and are untouched.
- Also drops auto_memory's unused 'import os' (pre-existing F401),
  so touched files are ruff E,F clean.
- Tests: cloud/tests/test_row_get_unified.py locks the identity
  (both aliases ARE row_get), the real-row-type matrix, the
  None-default fallback, and snapshot_to_dict via the unified path.

(cherry picked from commit 1644a0b837bfefb13cdc3c93d87fc1b246026607)
2026-11-03 02:49:26 +00:00

285 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""线程生命周期补充:worktree 快照、任务外部引用、自动化心跳绑定。
本模块只补控制面已有能力的缺口,不发明执行能力:
- worktree 快照(task_worktree_snapshots):归档线程时真正要
保住的是“未提交修改 + 未推送 commit 的位置 + 非忽略文件
清单”。快照记 branch / head_sha / git status(截 4000 字)/
git diff(截 20000 字)/ untracked 路径清单。注意诚实边界:
untracked 文件只记路径不拷内容,diff 只覆盖已跟踪文件的
修改;快照是事后可查的证据,不是可一键还原的备份。恢复
(restore)只在 worktree 目录还在时解归档;目录已删时
回 409 并附快照(重建需要仓库路径,服务端没有,不编造)。
- 任务外部引用(task_refs):任务附件表只记文件包路径,
交付物里的“生成的文档 / 构建结果 / 外部服务结果引用”
没有地方放。refs 只记 kind + ref + note 的引用行,不存
文件内容、不校验外部链接可达——引用就是引用。
- 自动化心跳绑定(automation_bindings):自动化定义本身
只有 interval + prompt,没有“绑到哪个线程”。绑定记
automation -> task + mode(heartbeat | cron)。heartbeat
是“到期唤醒已有线程”(走 thread/message 的投递路);
cron 是“到期独立跑新任务”(走自动化自身的产出路)。
本模块只记绑定与取绑定;到期触发仍由外部触发器
(host run-due / 未来的 run_due_automation)调用
/heartbeat 端点,服务端没有也不会偷偷起定时器。
三张表都走 UNMIGRATED_TABLES(见 cloud/migrations.py):
运行时 ensure 建表,与 task_handoffs 同款,不进
POSTGRES_SCHEMA 快照。"""
import json
import time
import uuid
# 行取值统一走 peer_messages.row_get(A7):本模块旧私有版在
# 真实行类型(None / dict / sqlite3.Row)下与之结果完全一致
# (None 特判、dict .get、缺列回 None),调用处仍用 _row_get
# 别名,行为不变;旧版宽 Exception 兜底的病态行不在契约内,
# 见 peer_messages.row_get docstring。
from cloud.peer_messages import row_get as _row_get
SNAPSHOT_STATUS_LIMIT = 4000
SNAPSHOT_DIFF_LIMIT = 20000
REF_LIMIT = 2000
NOTE_LIMIT = 1000
REF_KINDS = ("doc", "build", "external", "pr", "file")
BINDING_MODES = ("heartbeat", "cron")
# -- worktree 快照 ------------------------------------------------------
def ensure_snapshots_table(conn):
conn.execute(
"CREATE TABLE IF NOT EXISTS task_worktree_snapshots("
"id TEXT PRIMARY KEY, "
"task_id TEXT, "
"project_id TEXT, "
"worktree_path TEXT, "
"branch TEXT, "
"head_sha TEXT, "
"status_text TEXT, "
"diff_text TEXT, "
"untracked_json TEXT, "
"created_at DOUBLE PRECISION, "
"restored_at DOUBLE PRECISION, "
"restore_note TEXT)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_snapshots_task"
" ON task_worktree_snapshots(task_id, created_at)")
def snapshot_to_dict(row):
try:
untracked = json.loads(_row_get(row, "untracked_json") or "[]")
except ValueError:
untracked = []
if not isinstance(untracked, list):
untracked = []
return {
"id": _row_get(row, "id"),
"task_id": _row_get(row, "task_id"),
"project_id": _row_get(row, "project_id"),
"worktree_path": _row_get(row, "worktree_path"),
"branch": _row_get(row, "branch"),
"head_sha": _row_get(row, "head_sha"),
"status_text": _row_get(row, "status_text"),
"diff_text": _row_get(row, "diff_text"),
"untracked": untracked,
"created_at": _row_get(row, "created_at"),
"restored_at": _row_get(row, "restored_at"),
"restore_note": _row_get(row, "restore_note"),
}
def create_snapshot(conn, task_id, project_id, worktree_path, branch,
head_sha, status_text, diff_text, untracked, now=None):
"""插一条快照并提交。status/diff 在调用方截断,这里再截
一次防直接调用方越界;untracked 只接受 list[str]。"""
ensure_snapshots_table(conn)
snap_id = uuid.uuid4().hex
now = time.time() if now is None else now
untracked = [str(p) for p in (untracked or []) if isinstance(p, str)]
conn.execute(
"INSERT INTO task_worktree_snapshots(id, task_id, project_id,"
" worktree_path, branch, head_sha, status_text, diff_text,"
" untracked_json, created_at, restored_at, restore_note)"
" VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)",
(snap_id, task_id, project_id, worktree_path, branch,
head_sha, (status_text or "")[:SNAPSHOT_STATUS_LIMIT],
(diff_text or "")[:SNAPSHOT_DIFF_LIMIT],
json.dumps(untracked), now))
conn.commit()
row = conn.execute(
"SELECT * FROM task_worktree_snapshots WHERE id = ?",
(snap_id,)).fetchone()
return snapshot_to_dict(row)
def list_snapshots(conn, task_id):
ensure_snapshots_table(conn)
rows = conn.execute(
"SELECT * FROM task_worktree_snapshots WHERE task_id = ?"
" ORDER BY created_at DESC, id",
(task_id,)).fetchall()
return [snapshot_to_dict(r) for r in rows]
def latest_snapshot(conn, task_id):
rows = list_snapshots(conn, task_id)
return rows[0] if rows else None
def mark_restored(conn, snapshot_id, note, now=None):
ensure_snapshots_table(conn)
now = time.time() if now is None else now
cur = conn.execute(
"UPDATE task_worktree_snapshots SET restored_at = ?,"
" restore_note = ? WHERE id = ?",
(now, note, snapshot_id))
conn.commit()
if cur.rowcount != 1:
return None
row = conn.execute(
"SELECT * FROM task_worktree_snapshots WHERE id = ?",
(snapshot_id,)).fetchone()
return snapshot_to_dict(row)
# -- 任务外部引用 ---------------------------------------------------------
def ensure_refs_table(conn):
conn.execute(
"CREATE TABLE IF NOT EXISTS task_refs("
"id TEXT PRIMARY KEY, "
"task_id TEXT, "
"project_id TEXT, "
"kind TEXT, "
"ref TEXT, "
"note TEXT, "
"created_by TEXT, "
"created_at DOUBLE PRECISION)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_refs_task"
" ON task_refs(task_id, created_at)")
def ref_to_dict(row):
return {
"id": _row_get(row, "id"),
"task_id": _row_get(row, "task_id"),
"project_id": _row_get(row, "project_id"),
"kind": _row_get(row, "kind"),
"ref": _row_get(row, "ref"),
"note": _row_get(row, "note"),
"created_by": _row_get(row, "created_by"),
"created_at": _row_get(row, "created_at"),
}
def add_ref(conn, task_id, project_id, kind, ref, note, created_by,
now=None):
"""校验后插引用行。kind 不在白名单、ref 为空或超长、
note 超长时抛 ValueError(调用方转 400)。"""
if kind not in REF_KINDS:
raise ValueError("kind must be one of %s" % ", ".join(REF_KINDS))
if not ref or not isinstance(ref, str) or not ref.strip():
raise ValueError("ref required")
ref = ref.strip()
if len(ref) > REF_LIMIT:
raise ValueError("ref too long")
if note is not None and len(str(note)) > NOTE_LIMIT:
raise ValueError("note too long")
ensure_refs_table(conn)
ref_id = uuid.uuid4().hex
now = time.time() if now is None else now
conn.execute(
"INSERT INTO task_refs(id, task_id, project_id, kind, ref,"
" note, created_by, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
(ref_id, task_id, project_id, kind, ref, note, created_by,
now))
conn.commit()
row = conn.execute(
"SELECT * FROM task_refs WHERE id = ?", (ref_id,)).fetchone()
return ref_to_dict(row)
def list_refs(conn, task_id):
ensure_refs_table(conn)
rows = conn.execute(
"SELECT * FROM task_refs WHERE task_id = ?"
" ORDER BY created_at, id",
(task_id,)).fetchall()
return [ref_to_dict(r) for r in rows]
def delete_ref(conn, task_id, ref_id):
"""只删属于该任务的引用行;行不存在或不属于该任务时
返回 False(调用方一律 404,不泄露跨任务存在性)。"""
ensure_refs_table(conn)
cur = conn.execute(
"DELETE FROM task_refs WHERE id = ? AND task_id = ?",
(ref_id, task_id))
conn.commit()
return cur.rowcount == 1
# -- 自动化心跳绑定 --------------------------------------------------------
def ensure_bindings_table(conn):
conn.execute(
"CREATE TABLE IF NOT EXISTS automation_bindings("
"automation_id TEXT PRIMARY KEY, "
"project_id TEXT, "
"task_id TEXT, "
"mode TEXT, "
"created_at DOUBLE PRECISION)")
def binding_to_dict(row):
if row is None:
return None
return {
"automation_id": _row_get(row, "automation_id"),
"project_id": _row_get(row, "project_id"),
"task_id": _row_get(row, "task_id"),
"mode": _row_get(row, "mode"),
"created_at": _row_get(row, "created_at"),
}
def bind_automation(conn, automation_id, project_id, task_id, mode,
now=None):
if mode not in BINDING_MODES:
raise ValueError("mode must be heartbeat or cron")
ensure_bindings_table(conn)
now = time.time() if now is None else now
conn.execute(
"INSERT INTO automation_bindings(automation_id, project_id,"
" task_id, mode, created_at) VALUES(?, ?, ?, ?, ?)"
" ON CONFLICT(automation_id) DO UPDATE SET"
" task_id = excluded.task_id, mode = excluded.mode,"
" created_at = excluded.created_at",
(automation_id, project_id, task_id, mode, now))
conn.commit()
row = conn.execute(
"SELECT * FROM automation_bindings WHERE automation_id = ?",
(automation_id,)).fetchone()
return binding_to_dict(row)
def get_binding(conn, automation_id):
ensure_bindings_table(conn)
row = conn.execute(
"SELECT * FROM automation_bindings WHERE automation_id = ?",
(automation_id,)).fetchone()
return binding_to_dict(row)
def unbind_automation(conn, automation_id):
ensure_bindings_table(conn)
cur = conn.execute(
"DELETE FROM automation_bindings WHERE automation_id = ?",
(automation_id,))
conn.commit()
return cur.rowcount == 1