Visualize version deliveries and task dependency branches

This commit is contained in:
aiagent
2026-10-19 21:58:17 -07:00
parent 9ae803de23
commit bf53f011c9
10 changed files with 275 additions and 15 deletions
+6 -1
View File
@@ -4,6 +4,8 @@
看板分为 **总览、任务、集群、沟通、决策、日志** 六页。前端基于随仓库维护的 React、TypeScript、Vite、Tailwind 和官方 shadcn/ui 模板,固定配色与组件规范,AI 在已有模板上组合功能。 看板分为 **总览、任务、集群、沟通、决策、日志** 六页。前端基于随仓库维护的 React、TypeScript、Vite、Tailwind 和官方 shadcn/ui 模板,固定配色与组件规范,AI 在已有模板上组合功能。
任务分支视图以显式版本交付为主线:默认显示未结束任务和必要前置,可切换全部历史;悬停、聚焦或点选突出上下游。仅实际集成验收通过的关系绘制汇合,无归属和缺时间记录明确标示,不冒充 Git 提交历史。
协调服务使用 Python 标准库和本地 SQLite,保存任务、消息、事件与会话映射。AI 可以留在已登录的本机运行;服务与监控部署在获准集群,不需要向集群复制模型凭据。沟通页绑定真实会话,消息排队和历史镜像使用受支持的宿主接口。 协调服务使用 Python 标准库和本地 SQLite,保存任务、消息、事件与会话映射。AI 可以留在已登录的本机运行;服务与监控部署在获准集群,不需要向集群复制模型凭据。沟通页绑定真实会话,消息排队和历史镜像使用受支持的宿主接口。
## 准备和启动 ## 准备和启动
@@ -16,8 +18,11 @@ npm ci
npm run build npm run build
cd ../.. cd ../..
python3 -m unittest discover -s tests -v python3 -m unittest discover -s tests -v
node --test assets/dashboard/tests/task-graph.test.mjs
``` ```
图逻辑测试直接读取 TypeScript 模块,需具备类型剥离能力的 Node;本项目验证环境为 Node 24。
在获准 Linux 服务账号下初始化任务自有运行目录(默认示例仅监听本机): 在获准 Linux 服务账号下初始化任务自有运行目录(默认示例仅监听本机):
```sh ```sh
@@ -59,7 +64,7 @@ python3 scripts/bridge_service.py --config /path/to/private/bridge.json
本仓库不包含任何实际集群地址、客户案例、私有业务代码或聊天历史。公开仓库不会自动公开你的局域网运行实例。前端上游授权与组件来源见模板内的第三方说明。 本仓库不包含任何实际集群地址、客户案例、私有业务代码或聊天历史。公开仓库不会自动公开你的局域网运行实例。前端上游授权与组件来源见模板内的第三方说明。
执行负责人先处理归属事项并可靠保存报告,跨任务或阻塞交唯一直属副主控定向核查;只有人类决策、冲突和重大事件升级总控。人类需求直接交总控。紧急事件遇副主控失联可按约定有界越级,沿用关联ID去重,不广播或循环转发。 执行负责人先处理归属事项并可靠保存报告,授权范围内可与相关执行者点对点协调并保存关键交接,跨任务归属冲突、范围改变或无法解决的阻塞交唯一直属副主控定向核查;只有人类决策、冲突和重大事件升级总控。人类需求直接交总控。紧急事件遇副主控失联可按约定有界越级,沿用关联ID去重,不广播或循环转发。
完成报告先进入私有持久outbox,有界重试并幂等确认;执行完成与集成验收分别记录。通知要求每目标最多一条未处理,其余保留并合并。当前支持execution_notification_targets显式映射:依据正式任务session_id,将任务相关事件和ETA优先交执行者;未映射事项及系统事件交配置的唯一副主控,决策直达总控。目标变更须保留旧回执并对账迁移,不盲重放积压。宿主原生队列总数与取消能力未知时如实说明,不能声称已清空。配置、实现范围与验收要求见[运行接入](references/operations.md#一层副主控的路由与可靠上报)。 完成报告先进入私有持久outbox,有界重试并幂等确认;执行完成与集成验收分别记录。通知要求每目标最多一条未处理,其余保留并合并。当前支持execution_notification_targets显式映射:依据正式任务session_id,将任务相关事件和ETA优先交执行者;未映射事项及系统事件交配置的唯一副主控,决策直达总控。目标变更须保留旧回执并对账迁移,不盲重放积压。宿主原生队列总数与取消能力未知时如实说明,不能声称已清空。配置、实现范围与验收要求见[运行接入](references/operations.md#一层副主控的路由与可靠上报)。
+8
View File
@@ -39,6 +39,8 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和
归属明确的事项由执行 agent 先在授权内处理并可靠落库。跨任务依赖、阻塞或无法本层解决的问题定向交唯一直属副主控;副主控读取关联任务、原会话和证据,向相关负责人定向核查,协调结果同样落库。仅人类决策、协调冲突或重大事件升级总控;普通进度、短暂重连、自动恢复不注入总控或人类入口。人类提出的新需求直接到总控,由总控分解,不排在底层事件队列之后。 归属明确的事项由执行 agent 先在授权内处理并可靠落库。跨任务依赖、阻塞或无法本层解决的问题定向交唯一直属副主控;副主控读取关联任务、原会话和证据,向相关负责人定向核查,协调结果同样落库。仅人类决策、协调冲突或重大事件升级总控;普通进度、短暂重连、自动恢复不注入总控或人类入口。人类提出的新需求直接到总控,由总控分解,不排在底层事件队列之后。
执行者在已明确的共享接口、文件归属及授权范围内可点对点协商,不必把所有信息逐级汇报。协商结果由唯一事项负责人保存结构化决定或交接记录并更新任务;涉及归属冲突、范围改变或本层无法解决的跨任务阻塞再交副主控。副主控可自行处理既有权限内的协调,只有重大风险、跨范围决策或人类待决策升级总控。禁止群发、无变化互相唤醒或以协调为由扩大权限。
报告保留项目/任务ID、事件或报告关联ID、版本、唯一负责人、接收方及处理回执。同一事项按关联ID去重、合并增量,回复关联原事项,不广播、不循环转发,也不另建同义事项绕过队列。重大紧急事件遇副主控失联时,先按任务约定的时限和重试上限核查;确需及时行动才携证据、影响及已尝试联系记录有界越级总控,同一关联ID最多一次,恢复后补记处理归属。越级不增加操作权限。 报告保留项目/任务ID、事件或报告关联ID、版本、唯一负责人、接收方及处理回执。同一事项按关联ID去重、合并增量,回复关联原事项,不广播、不循环转发,也不另建同义事项绕过队列。重大紧急事件遇副主控失联时,先按任务约定的时限和重试上限核查;确需及时行动才携证据、影响及已尝试联系记录有界越级总控,同一关联ID最多一次,恢复后补记处理归属。越级不增加操作权限。
当前 notifier 已提供执行者优先的显式映射:通过正式 `/api/state` 查询任务的 `session_id`,在 `execution_notification_targets` 中找到获准执行thread后,任务相关hook/ETA先投该唯一执行者。未配置执行者或系统事件回退内部核查目标;本流程将 `internal_event_thread_id` 与 `eta_review_thread_id` 配为唯一直属副主控。决策仍直达总控。映射须显式配置,不根据名称猜测、不自动创建会话;配置和回执迁移约束见运行指南。 当前 notifier 已提供执行者优先的显式映射:通过正式 `/api/state` 查询任务的 `session_id`,在 `execution_notification_targets` 中找到获准执行thread后,任务相关hook/ETA先投该唯一执行者。未配置执行者或系统事件回退内部核查目标;本流程将 `internal_event_thread_id` 与 `eta_review_thread_id` 配为唯一直属副主控。决策仍直达总控。映射须显式配置,不根据名称猜测、不自动创建会话;配置和回执迁移约束见运行指南。
@@ -149,3 +151,9 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和
运维日志与沟通分离:历史及后续自动故障/恢复整轮消息不进入聊天;保留用户主动询问时的解释。日志按故障聚合,信息/警告/错误/严重与处理中/已恢复分别用颜色和文字表示,支持筛选和详情。原始审计不删;安全投影遮蔽令牌、私有路径与工具/推理载荷。桥接首次失败为警告,持续功能失效达到私有配置的升级窗口(默认120秒)才升级严重并通知人类,已升级故障恢复最多通知一次;重大简报链接具体日志卡。 运维日志与沟通分离:历史及后续自动故障/恢复整轮消息不进入聊天;保留用户主动询问时的解释。日志按故障聚合,信息/警告/错误/严重与处理中/已恢复分别用颜色和文字表示,支持筛选和详情。原始审计不删;安全投影遮蔽令牌、私有路径与工具/推理载荷。桥接首次失败为警告,持续功能失效达到私有配置的升级窗口(默认120秒)才升级严重并通知人类,已升级故障恢复最多通知一次;重大简报链接具体日志卡。
人类入口的每次输入保持轻量,不重复注入整个项目JSON快照;需要进度事实时按需读取控制API与执行会话。网页投影隐藏内部封装不等于可以向原会话无界灌入状态,后台指令与证据仍要有界。 人类入口的每次输入保持轻量,不重复注入整个项目JSON快照;需要进度事实时按需读取控制API与执行会话。网页投影隐藏内部封装不等于可以向原会话无界灌入状态,后台指令与证据仍要有界。
## 版本交付关系图
任务分支视图以版本交付为主线、独立工作任务为支线,节点只表达实际登记的关键里程碑,不冒充Git提交或提交历史。交付任务显式使用kind=delivery,工作任务使用kind=work及delivery_id归属;没有归属显示未归属,不从version中的提交哈希推测。只有真实集成关系且源、目标均通过集成验收才绘制汇合。
默认显示所有未结束任务及递归必要前置,显示全部开关用于历史。悬停、键盘焦点及触屏选择突出递归上下游关系;无关项淡化但可读,退出交互恢复,不修改任务事实或自动开启全部。字段校验和迁移见references/operations.md,交互与验收见references/design-system.md。
+17 -4
View File
@@ -43,6 +43,7 @@ TASK_REPORT_DEFAULTS = {'execution_status':'pending','last_report_at':None,'last
TASK_RELATION_DEFAULTS = { TASK_RELATION_DEFAULTS = {
'kind':'work', 'delivery_id':None,
'goal':'', 'scope':'', 'coupling_assessment':'', 'owner':'', 'integration_owner':'', 'version':'', 'goal':'', 'scope':'', 'coupling_assessment':'', 'owner':'', 'integration_owner':'', 'version':'',
'parent_id':None, 'merged_into_id':None, 'depends_on':[], 'parallel_with':[], 'merge_sources':[], 'parent_id':None, 'merged_into_id':None, 'depends_on':[], 'parallel_with':[], 'merge_sources':[],
'parallel_scope':'', 'acceptance_criteria':[], 'sync_points':[], 'integration_status':'not_started'} 'parallel_scope':'', 'acceptance_criteria':[], 'sync_points':[], 'integration_status':'not_started'}
@@ -327,7 +328,7 @@ class Store:
def _task_defaults(record): def _task_defaults(record):
return {**{k:list(v) if isinstance(v,list) else v for k,v in TASK_RELATION_DEFAULTS.items()}, return {**{k:list(v) if isinstance(v,list) else v for k,v in TASK_RELATION_DEFAULTS.items()},
**ETA_DEFAULTS, **TASK_REPORT_DEFAULTS, 'report_evidence':[], **ETA_DEFAULTS, **TASK_REPORT_DEFAULTS, 'report_evidence':[],
'current_progress':'', 'progress_updated':None, **record} 'current_progress':'', 'progress_updated':None, 'created_at':None, **record}
@staticmethod @staticmethod
@@ -350,7 +351,11 @@ class Store:
if not isinstance(record[field],str): if not isinstance(record[field],str):
raise Invalid('invalid '+field) raise Invalid('invalid '+field)
string(record[field],field,limit,True) string(record[field],field,limit,True)
for field in ('parent_id','merged_into_id'): if record['kind'] not in ('work','delivery'):
raise Invalid('invalid task kind')
if record['kind']=='delivery' and record['delivery_id'] is not None:
raise Invalid('delivery cannot belong to another delivery')
for field in ('parent_id','merged_into_id','delivery_id'):
value = record[field] value = record[field]
if value is not None: if value is not None:
identifier(value,field) identifier(value,field)
@@ -385,6 +390,13 @@ class Store:
@staticmethod @staticmethod
def _validate_task_graphs(tasks): def _validate_task_graphs(tasks):
for task_id,task in tasks.items():
delivery = task.get('delivery_id')
if delivery is not None:
if delivery==task_id or delivery not in tasks:
raise Invalid('invalid delivery reference')
if task.get('kind','work')=='delivery' or tasks[delivery].get('kind','work')!='delivery':
raise Conflict('delivery reference requires a delivery task')
graphs = [ graphs = [
{key:([value['parent_id']] if value.get('parent_id') else []) for key,value in tasks.items()}, {key:([value['parent_id']] if value.get('parent_id') else []) for key,value in tasks.items()},
{key:value.get('depends_on',[]) for key,value in tasks.items()}, {key:value.get('depends_on',[]) for key,value in tasks.items()},
@@ -567,7 +579,7 @@ class Store:
def task(self, data, key=None): def task(self, data, key=None):
if key is not None and 'id' in data and data['id'] != key: if key is not None and 'id' in data and data['id'] != key:
raise Invalid('task id differs from target') raise Invalid('task id differs from target')
if 'progress_updated' in data or (ETA_MANAGED | set(TASK_REPORT_DEFAULTS)) & set(data): if 'created_at' in data or 'progress_updated' in data or (ETA_MANAGED | set(TASK_REPORT_DEFAULTS)) & set(data):
raise Invalid('server managed task field') raise Invalid('server managed task field')
key = identifier(key or data.get('id') or secrets.token_hex(12)) key = identifier(key or data.get('id') or secrets.token_hex(12))
rejected = False rejected = False
@@ -578,7 +590,7 @@ class Store:
record = dict(old, **data) record = dict(old, **data)
record['id'] = key record['id'] = key
allowed = {'id','name','session_id','status','phase','eta_min_minutes','eta_max_minutes', allowed = {'id','name','session_id','status','phase','eta_min_minutes','eta_max_minutes',
'eta_basis','decision','updated','current_progress','progress_updated'} | set(ETA_DEFAULTS) | set(TASK_RELATION_DEFAULTS) | set(TASK_REPORT_DEFAULTS) 'eta_basis','decision','updated','current_progress','progress_updated','created_at'} | set(ETA_DEFAULTS) | set(TASK_RELATION_DEFAULTS) | set(TASK_REPORT_DEFAULTS)
if set(record) - allowed: if set(record) - allowed:
raise Invalid('unknown task field') raise Invalid('unknown task field')
record['name'] = string(record.get('name'), 'name', 200) record['name'] = string(record.get('name'), 'name', 200)
@@ -600,6 +612,7 @@ class Store:
record['current_progress'] = string(progress,'current_progress',1000,True) record['current_progress'] = string(progress,'current_progress',1000,True)
record, related = self._stage_task_relations(db,record,old,set(data)) record, related = self._stage_task_relations(db,record,old,set(data))
now = time.time() now = time.time()
record['created_at'] = old.get('created_at') if row else now
rejected = self._eta_change(record,old,data,now) rejected = self._eta_change(record,old,data,now)
if rejected: if rejected:
record = self._task_defaults(old) record = self._task_defaults(old)
+1
View File
@@ -1,4 +1,5 @@
export type Task = { export type Task = {
kind?: 'work' | 'delivery'; delivery_id?: string | null; created_at?: number | null;
session_id?: string; session_id?: string;
execution_status?: 'pending' | 'running' | 'completed' | 'blocked' | 'failed'; last_report_at?: number | null; last_report_id?: string | null execution_status?: 'pending' | 'running' | 'completed' | 'blocked' | 'failed'; last_report_at?: number | null; last_report_id?: string | null
goal?: string; scope?: string; coupling_assessment?: string; owner?: string; integration_owner?: string; version?: string goal?: string; scope?: string; coupling_assessment?: string; owner?: string; integration_owner?: string; version?: string
+50
View File
@@ -0,0 +1,50 @@
import type { Task } from '../lib/types.ts'
export type GraphEdge = { from: string; to: string; kind: 'dependency' | 'membership' | 'merge' }
export function walk(seeds: Iterable<string>, adjacency: Map<string, string[]>) {
const seen = new Set<string>(), pending = [...seeds]
while (pending.length) { const id = pending.pop()!; if (seen.has(id)) continue; seen.add(id); for (const next of adjacency.get(id) ?? []) if (!seen.has(next)) pending.push(next) }
return seen
}
export function buildGraph(tasks: Task[], all = false) {
const byId = new Map(tasks.map(task => [task.id, task]))
const parents = new Map(tasks.map(task => [task.id, (task.depends_on ?? []).filter(id => byId.has(id))]))
const visible = all ? new Set(byId.keys()) : walk(tasks.filter(task => !['completed', 'cancelled', 'failed'].includes(task.status)).map(task => task.id), parents)
for (const id of [...visible]) { const delivery = byId.get(byId.get(id)?.delivery_id ?? ''); if (delivery?.kind === 'delivery') visible.add(delivery.id) }
const nodes = tasks.filter(task => visible.has(task.id))
const edges: GraphEdge[] = [], unique = new Set<string>()
function add(from: string, to: string, kind: GraphEdge['kind']) { const key = JSON.stringify([from, to, kind]); if (visible.has(from) && visible.has(to) && !unique.has(key)) { unique.add(key); edges.push({ from, to, kind }) } }
const mergeTargets = new Map<string, string[]>()
for (const target of tasks) for (const source of target.merge_sources ?? []) mergeTargets.set(source, [...(mergeTargets.get(source) ?? []), target.id])
for (const task of nodes) {
for (const id of task.depends_on ?? []) add(id, task.id, 'dependency')
if (task.kind !== 'delivery' && byId.get(task.delivery_id ?? '')?.kind === 'delivery') add(task.delivery_id!, task.id, 'membership')
const targets = [...(task.merged_into_id ? [task.merged_into_id] : []), ...(mergeTargets.get(task.id) ?? [])]
for (const targetId of targets) { const target = byId.get(targetId); if (target?.kind === 'delivery' && target.integration_status === 'verified' && task.integration_status === 'verified') add(task.id, target.id, 'merge') }
}
return { nodes, edges, deliveries: nodes.filter(task => task.kind === 'delivery').sort((a, b) => (validCreated(a) ?? Number.MAX_SAFE_INTEGER) - (validCreated(b) ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id)) }
}
export function related(id: string, edges: GraphEdge[]) {
const forward = new Map<string, string[]>(), backward = new Map<string, string[]>()
for (const edge of edges) { if (edge.kind === 'membership') continue; forward.set(edge.from, [...(forward.get(edge.from) ?? []), edge.to]); backward.set(edge.to, [...(backward.get(edge.to) ?? []), edge.from]) }
return new Set([...walk([id], forward), ...walk([id], backward)])
}
export function validCreated(task: Task) { return typeof task.created_at === 'number' && Number.isFinite(task.created_at) && task.created_at > 0 ? task.created_at : null }
export function compactLayout(graph: ReturnType<typeof buildGraph>) {
const rows: { task: Task; y: number; group: string | null }[] = []
const positions = new Map<string, { x: number; y: number }>()
const starts = new Map<string, number>()
const groups = new Map<string, Task[]>()
const deliveryIds = new Set(graph.deliveries.map(task => task.id))
const independent: Task[] = []
for (const task of graph.nodes) {
if (task.kind === 'delivery') continue
if (task.delivery_id && deliveryIds.has(task.delivery_id)) { const items = groups.get(task.delivery_id) ?? []; items.push(task); groups.set(task.delivery_id, items) }
else independent.push(task)
}
const add = (task: Task, group: string | null) => { const y = 50 + rows.length * 100; rows.push({ task, y, group }); positions.set(task.id, { x: task.kind === 'delivery' ? 32 : 100, y }) }
for (const delivery of graph.deliveries) { starts.set(delivery.id, 15 + rows.length * 100); for (const task of groups.get(delivery.id) ?? []) add(task, delivery.id); add(delivery, delivery.id) }
for (const task of independent) add(task, null)
return { rows, positions, starts, height: Math.max(100, rows.length * 100) }
}
+35 -9
View File
@@ -1,17 +1,43 @@
import { useId, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Panel, Empty, Status } from '@/components/shared' import { Empty, Status } from '@/components/shared'
import type { Task } from '@/lib/types' import type { Task } from '@/lib/types'
import { phaseLabels } from '@/lib/types'
import { buildGraph, related, validCreated, compactLayout } from './task-graph'
export function TaskLinks({ ids, tasks, onOpen }: { ids: string[]; tasks: Task[]; onOpen: (id: string) => void }) { export function TaskLinks({ ids, tasks, onOpen }: { ids: string[]; tasks: Task[]; onOpen: (id: string) => void }) {
return <div className="flex flex-wrap gap-2">{[...new Set(ids)].map(id => <Button key={id} type="button" variant="outline" size="sm" className="h-auto max-w-full whitespace-normal text-left" onClick={() => onOpen(id)}>{id} · {tasks.find(task => task.id === id)?.name ?? '未同步任务'}</Button>)}</div> return <div className="flex flex-wrap gap-2">{[...new Set(ids)].map(id => <Button key={id} type="button" variant="outline" size="sm" className="h-auto max-w-full whitespace-normal text-left" onClick={() => onOpen(id)}>{id} · {tasks.find(task => task.id === id)?.name ?? '未同步任务'}</Button>)}</div>
} }
export default function TaskRelations({ tasks, onOpen }: { tasks: Task[]; onOpen: (id: string) => void }) { export default function TaskRelations({ tasks, onOpen }: { tasks: Task[]; onOpen: (id: string) => void }) {
const groups = [ const [all, setAll] = useState(false), [hover, setHover] = useState<string | null>(null), [focus, setFocus] = useState<string | null>(null), [picked, setPicked] = useState<string | null>(null)
{ title: '父子拆分', description: '父任务包含子任务,不表示先后执行顺序。', label: '包含', edges: tasks.flatMap(task => task.parent_id ? [[task.parent_id, task.id]] : []) }, const graph = useMemo(() => buildGraph(tasks, all), [tasks, all])
{ title: '前置依赖', description: '箭头从前置任务指向依赖它的任务;具体等待点见详情。', label: '前置于', edges: tasks.flatMap(task => (task.depends_on ?? []).map(id => [id, task.id])) }, const active = hover ?? focus ?? picked
{ title: '归并', description: '箭头从来源任务指向接收归并的任务,不代表已经完成集成。', label: '归并到', edges: tasks.flatMap(task => [...(task.merged_into_id ? [[task.id, task.merged_into_id]] : []), ...(task.merge_sources ?? []).map(id => [id, task.id])]) }, const highlighted = useMemo(() => active ? related(active, graph.edges) : null, [active, graph.edges])
] const marker = useId().replace(/:/g, '')
return <div className="space-y-5"><p className="text-sm text-muted-foreground">这是任务关系图,不是 Git 分支。不同关系分别展示;可并行范围与同步点在任务详情中查看。</p> const layout = useMemo(() => compactLayout(graph), [graph])
<Panel title="全部任务"><div className="flex flex-wrap gap-3">{tasks.map(task => <div key={task.id} className="space-y-2 rounded-lg border p-3"><TaskLinks ids={[task.id]} tasks={tasks} onOpen={onOpen} /><Status value={task.status} />{Boolean(task.parallel_with?.length) && <p className="text-xs text-muted-foreground">有可并行任务,查看详情确认范围</p>}</div>)}</div></Panel> const labels = new Map(graph.nodes.map(task => [task.id, task.name]))
{groups.map(group => <Panel key={group.title} title={group.title} description={group.description}>{group.edges.length ? <div className="max-h-96 overflow-auto" tabIndex={0} aria-label={`${group.title}关系,可滚动`}><ul className="min-w-[36rem] space-y-3">{[...new Map(group.edges.map(edge => [JSON.stringify(edge), edge])).values()].map(([from, to]) => <li key={JSON.stringify([from, to])} className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 rounded-lg border p-3"><TaskLinks ids={[from]} tasks={tasks} onOpen={onOpen} /><span className="text-xs text-muted-foreground">{group.label} →</span><TaskLinks ids={[to]} tasks={tasks} onOpen={onOpen} /></li>)}</ul></div> : <Empty>尚未登记此类关系。</Empty>}</Panel>)} const dim = (id: string) => highlighted && !highlighted.has(id) ? 'opacity-50' : ''
return <div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-3"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={all} onChange={event => { setAll(event.target.checked); setPicked(null); setHover(null); setFocus(null) }} />显示全部(含历史)</label><Button variant="outline" size="sm" onClick={() => { setPicked(null); setHover(null); setFocus(null) }}>清除高亮</Button></div>
<p className="text-xs text-muted-foreground">交付按登记时间分组,交付节点位于组末。主线排列不是因果或 Git 历史;默认显示未结束任务与递归前置。悬停或聚焦查看上下游,点击名称或圆节点打开详情。</p>
<div aria-label="关系图图例" className="flex flex-wrap gap-4 text-xs"><span>│ 交付主线</span><span className="text-muted-foreground">细线:明确归属</span><span className="text-primary">粗线箭头:已验证汇合</span><span>虚线箭头:前置依赖</span><span>○ 登记同步点</span></div>
{!graph.nodes.length ? <Empty>当前没有未结束任务或前置任务。可选择显示全部查看历史。</Empty> : <div className="w-full min-w-0 max-w-full overflow-hidden rounded-xl border bg-card"><div className="max-h-[70vh] overflow-auto" tabIndex={0} aria-label="交付与任务关系图,可横向和纵向滚动" onMouseLeave={() => setHover(null)}>
<div className="relative" style={{ width: 960, height: layout.height }}>
<svg width="220" height={layout.height} className="pointer-events-none absolute inset-0 text-primary" aria-hidden="true"><defs><marker id={marker} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" /></marker></defs>
{graph.deliveries.length > 0 && <path d={`M 32 15 V ${layout.positions.get(graph.deliveries.at(-1)!.id)!.y}`} stroke="currentColor" opacity="0.35" strokeWidth="3" fill="none" />}
{graph.edges.map((edge, index) => { const from = layout.positions.get(edge.from)!, to = layout.positions.get(edge.to)!; const membership = edge.kind === 'membership', merge = edge.kind === 'merge'; const lane = 145 + index % 5 * 12; const start = layout.starts.get(edge.from) ?? from.y; const path = membership ? `M 32 ${start} C 64 ${start}, 64 ${to.y}, 100 ${to.y}` : merge ? `M ${from.x} ${from.y + 8} C 100 ${to.y - 18}, 72 ${to.y}, ${to.x + 8} ${to.y}` : `M ${from.x + 8} ${from.y} C ${lane} ${from.y}, ${lane} ${to.y}, ${to.x + 8} ${to.y}`; return <path key={`${edge.kind}:${edge.from}:${edge.to}`} d={path} fill="none" className={membership ? 'text-muted-foreground' : ''} stroke="currentColor" strokeWidth={merge ? 3 : 1.25} strokeDasharray={edge.kind === 'dependency' ? '4 4' : undefined} markerEnd={membership ? undefined : `url(#${marker})`} opacity={highlighted && !(highlighted.has(edge.from) && highlighted.has(edge.to)) ? 0.2 : membership ? 0.45 : 0.85} /> })}
</svg>
{layout.rows.map(({task, y, group}) => <article key={task.id} className={`absolute left-0 h-[100px] w-full border-b last:border-0 ${dim(task.id)}`} style={{ top: y - 50 }} onMouseEnter={() => setHover(task.id)} onMouseLeave={() => setHover(null)} onFocusCapture={() => setFocus(task.id)} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocus(null) }}>
<button type="button" aria-label={`查看${task.name}详情`} className={`absolute top-[42px] size-4 rounded-full border-2 border-primary bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${active === task.id ? 'ring-2 ring-ring' : ''}`} style={{ left: layout.positions.get(task.id)!.x - 8 }} onClick={() => onOpen(task.id)} />
<div className="absolute inset-y-0 left-[220px] right-3 flex items-center gap-3">
<div className="min-w-0 flex-1"><button type="button" className="block max-w-full truncate rounded-sm text-left text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" title={task.name} onClick={() => onOpen(task.id)}>{task.name}</button>
<p className="mt-1 truncate text-xs text-muted-foreground" title={group ? labels.get(group) : '无明确交付归属'}>{task.kind === 'delivery' ? '交付' : group ? `归属 ${labels.get(group)}` : '无明确交付归属'} · {task.id}{task.version ? ` · ${task.version}` : ''}{task.kind === 'delivery' && !validCreated(task) ? ' · 时间未登记' : ''}</p>
{!!task.sync_points?.length && <div className="mt-1 flex max-w-full gap-3 overflow-x-auto text-xs">{task.sync_points.map(point => <span key={point.id} className="shrink-0" title={point.label}>○ {point.label} · {{waiting:'等待',ready:'已就绪',completed:'已完成'}[point.status]}</span>)}</div>}
</div>
<div className="w-28 shrink-0 space-y-1"><Status value={task.status} /><p className="text-xs text-muted-foreground">{task.integration_status === 'verified' ? '集成已验证' : phaseLabels[task.phase] || task.phase}</p></div>
<Button variant={picked === task.id ? 'secondary' : 'ghost'} size="sm" aria-pressed={picked === task.id} aria-label={`高亮${task.name}上下游`} onClick={() => setPicked(picked === task.id ? null : task.id)}>高亮</Button><Button variant="outline" size="sm" onClick={() => onOpen(task.id)}>详情</Button>
</div>
</article>)}
</div>
</div></div>}
</div> </div>
} }
@@ -0,0 +1,23 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildGraph, related, validCreated } from '../src/pages/task-graph.ts'
const task = (id, extra = {}) => ({ id, name:id, status:'running', phase:'implementation', ...extra })
test('long chains and cycles use iterative traversal', () => {
const tasks=Array.from({length:15000},(_,i)=>task(String(i),{status:i===14999?'running':'completed',depends_on:i?[String(i-1)]:['14999']}))
const graph=buildGraph(tasks); assert.equal(graph.nodes.length,15000); assert.equal(related('7000',graph.edges).size,15000)
})
test('multiple prerequisites included, unrelated history excluded',()=>{
const g=buildGraph([task('a',{depends_on:['b','c']}),task('b',{status:'completed',depends_on:['d']}),task('c',{status:'completed'}),task('d',{status:'completed'}),task('old',{status:'completed'})]);assert.deepEqual(g.nodes.map(t=>t.id),['a','b','c','d']);assert.equal(g.edges.length,3)
})
test('no dependencies and legacy version do not infer delivery',()=>{const g=buildGraph([task('d',{kind:'delivery',version:'v'}),task('w',{version:'v'})]);assert.equal(g.edges.length,0);assert.equal(related('w',g.edges).size,1)})
test('all history toggle, explicit delivery context, missing IDs',()=>{const ts=[task('d',{kind:'delivery',status:'completed'}),task('w',{delivery_id:'d',depends_on:['missing']}),task('h',{status:'completed'})];assert.equal(buildGraph(ts).nodes.length,2);assert.equal(buildGraph(ts,true).nodes.length,3)})
test('merge requires explicit relationship, delivery target and both verified',()=>{
for(const source of ['integrated','verified'])for(const target of ['integrated','verified']){const g=buildGraph([task('w',{merged_into_id:'d',integration_status:source}),task('d',{kind:'delivery',integration_status:target})],true);assert.equal(g.edges.filter(e=>e.kind==='merge').length,source==='verified'&&target==='verified'?1:0)}
assert.equal(buildGraph([task('w',{integration_status:'verified'}),task('d',{integration_status:'verified',merge_sources:['w']})],true).edges.length,0)
assert.equal(buildGraph([task('w',{integration_status:'verified'}),task('d',{kind:'delivery',integration_status:'verified',merge_sources:['w']})],true).edges.length,1)
})
test('delivery order uses valid created_at only',()=>{const g=buildGraph([task('z',{kind:'delivery',created_at:2}),task('a',{kind:'delivery',updated:1}),task('b',{kind:'delivery',created_at:1})]);assert.deepEqual(g.deliveries.map(t=>t.id),['b','z','a']);assert.equal(validCreated(task('x',{created_at:NaN})),null)})
test('highlight ancestors and descendants without unrelated siblings',()=>{const g=buildGraph([task('a'),task('b',{depends_on:['a']}),task('c',{depends_on:['a']}),task('d',{depends_on:['b']})]);assert.deepEqual([...related('b',g.edges)].sort(),['a','b','d'])})
test('compact layout places delivery at group end with distinct origin and 100px rows', async()=>{
const {compactLayout}=await import('../src/pages/task-graph.ts');const g=buildGraph([task('d',{kind:'delivery',created_at:1}),task('w',{delivery_id:'d'}),task('solo')]);const l=compactLayout(g);assert.deepEqual(l.rows.map(r=>r.task.id),['w','d','solo']);assert.equal(l.positions.get('d').y-l.positions.get('w').y,100);assert.ok(l.starts.get('d')<l.positions.get('w').y);assert.equal(l.rows[2].group,null);assert.equal(l.height,300)
})
+11 -1
View File
@@ -61,6 +61,16 @@
## 任务三视图与详情 ## 任务三视图与详情
同一任务数据可切换展板、列表、分支,view参数保留选择。展板用于概要,列表便于比较当前进度、阶段、ETA与负责人,分支用于父子拆分、前置依赖和汇总集成关系;明确标注不是Git分支。关系没有登记时如实显示,不补假箭头。多依赖不等于全程串行或全程并行,详情说明可先做部分及等待同步点。 同一任务数据可切换展板、列表、分支,view参数保留选择。展板用于概要,列表便于比较当前进度、阶段、ETA与负责人,分支以版本交付主线和任务支线展示前置依赖与已验收集成;明确标注不是Git提交历史。关系没有登记时如实显示,不补假箭头。多依赖不等于全程串行或全程并行,详情说明可先做部分及等待同步点。
点击任务打开详情,展示目标、范围、负责人/真实会话、当前进度、版本、前置条件、并行部分、同步点、验收条件和集成状态。URL view=board/list/branches、task=<id>可定位,关闭详情只移除task。空/单任务/已完成/多依赖保持一致,过期状态与ETA规则不因切换消失。任务关系与Git分支不混用。 点击任务打开详情,展示目标、范围、负责人/真实会话、当前进度、版本、前置条件、并行部分、同步点、验收条件和集成状态。URL view=board/list/branches、task=<id>可定位,关闭详情只移除task。空/单任务/已完成/多依赖保持一致,过期状态与ETA规则不因切换消失。任务关系与Git分支不混用。
## 交付主线与任务支线交互
分支视图采用Git Graph形态表达任务交付关系,不表示真实Git提交。主线由kind=delivery任务按有效created_at的登记顺序组成,标签使用name/version;旧无时间项稳定按ID并标“时间未登记/顺序未登记”,不以updated代替,不暗示版本因果;任务按delivery_id分支,无归属单独展示。节点仅显示已有真实关键里程碑,不根据进度编造历史。实际merged_into_id或merge_sources关系只有在目标为delivery且源、目标integration_status均verified时显示汇合;真实depends_on用虚线,图例明确区分归属、依赖与验收汇合。
默认包含全部未结束状态(包括waiting、blocked和验证/集成阶段)及递归必要前置;显式“显示全部”显示历史。交付容器可为布局保留,但不应借容器显示无关历史工作任务。无版本或无归属明确显示未归属,不猜测版本。
悬停节点高亮递归上游、下游及对应边,无关节点与边淡化但文字仍可读;mouseleave恢复。键盘focus提供等效交互,触屏通过选中节点提供同效高亮和明确的详情按钮;退出选择恢复。高亮不改变事实、筛选开关或自动切换全部。详情仍保留任务定位链接、目标、负责人、依赖、验收与集成状态。
复用固定token和既有组件,以纯SVG与HTML实现,不引入新依赖。遍历使用visited与迭代队列/栈防循环和长链调用栈溢出。真实浏览器验收默认/全部、hover/focus/触屏、详情及窄屏;纯逻辑测试覆盖长链、多依赖、无依赖、筛选和汇合验收门禁。
+10
View File
@@ -136,3 +136,13 @@ POST /api/tasks/<target>/integrate {source_ids,owner,status,version?,reason}事
每个目标最多一条未处理通知;应用侧启用 `enforce_backpressure`,并配置 `max_inflight_per_target: 1`(当前实现允许1至3,本协作规则要求1)。accepted/submitting/observed且未processing_done的投递仍占名额,只有真实处理证据才能释放;发送确认本身不释放。后续通知留在持久outbox,同一关联事项合并,未知项先对账。仅限制单次发送批量不等于每目标背压;未启用或未验收时不得宣称已满足。 每个目标最多一条未处理通知;应用侧启用 `enforce_backpressure`,并配置 `max_inflight_per_target: 1`(当前实现允许1至3,本协作规则要求1)。accepted/submitting/observed且未processing_done的投递仍占名额,只有真实处理证据才能释放;发送确认本身不释放。后续通知留在持久outbox,同一关联事项合并,未知项先对账。仅限制单次发送批量不等于每目标背压;未启用或未验收时不得宣称已满足。
queue_health分别报告application_pending、inflight_by_target及已知完成情况。当前host_full_queue_count为null、host_queue_cancel_supported为false,表示没有完整宿主原生队列总数和取消能力;不代表原生队列为空。停止本机notifier、清应用待办或取消旧ETA记录都不能证明已进入宿主的输入已撤回。只能用受支持宿主接口核实取消结果,无法核实时保留未知并禁止盲重发,不访问内部数据库或抢writer锁。 queue_health分别报告application_pending、inflight_by_target及已知完成情况。当前host_full_queue_count为null、host_queue_cancel_supported为false,表示没有完整宿主原生队列总数和取消能力;不代表原生队列为空。停止本机notifier、清应用待办或取消旧ETA记录都不能证明已进入宿主的输入已撤回。只能用受支持宿主接口核实取消结果,无法核实时保留未知并禁止盲重发,不访问内部数据库或抢writer锁。
## 版本交付归属契约
Task.kind取work或delivery,旧数据缺省work;delivery_id缺省null。工作任务可显式引用已存在的kind=delivery任务,禁止自指、不存在引用及引用普通工作任务。delivery任务自身不能设置delivery_id;已被工作任务引用的delivery不得降为work。创建和更新均校验,执行报告不能绕过归属或集成字段的保护。旧数据不根据version推断归属;正式迁移由集成负责人显式登记并保留原关系与验收证据。
created_at由服务端在新建任务时记录,拒绝客户端输入及更新/报告改写;旧记录缺失返回null,不补造历史时间。交付主线仅按有效created_at展示版本登记顺序,以name/version标识;同时间稳定按ID排序。旧无时间项按ID稳定展示并标“时间未登记/顺序未登记”,不得用updated代替创建时间,也不得将登记顺序解释为版本因果或Git历史。delivery_id仅表示归属,不证明已经合入;绘制汇合必须有merged_into_id或merge_sources真实关系,目标kind=delivery且源、目标integration_status均为verified。completed本身不满足门禁。depends_on仍是前置关系,parent_id仍是包含关系,不能拿这些字段替代集成验收。
本轮验收包括旧数据默认、创建和更新非法归属、被引用交付降级、报告字段保护;前端纯逻辑覆盖长链、多依赖、无依赖、筛选与汇合门禁。执行负责人以既有持久报告流程上报真实证据,收到落库回执后等待集成;集成负责人另做真实浏览器验证和发布,不能以执行完成代替正式验收。
执行会话可按人类已授权的项目协作范围,使用宿主支持的定向消息就共享接口与交接点相互协调;无需每次抄送副主控。唯一事项负责人把关键决定、版本、影响与交接回执落库;范围或归属冲突才交直属副主控。普通进度只更新状态,不制造互相唤醒链。副主控的判断可由推理模型结合明确规则承担,具体模型、费用与执行权限分别核实;用户仅询问模型能力不构成切换型号或购买服务的授权。
+114
View File
@@ -0,0 +1,114 @@
"""Explicit delivery ownership without inferred merge or historical timestamps."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from agent_control.store import Store, Invalid
class DeliveryGraphTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name)/'delivery.sqlite3'
self.store = Store(self.path)
self.store.session(dict(id='worker',name='Worker'))
def tearDown(self):
self.tmp.cleanup()
def task(self, tid):
return next(t for t in self.store.state()['tasks'] if t['id']==tid)
def create(self, tid, **fields):
return self.store.task(dict(id=tid,name=tid,session_id='worker',**fields))
def report(self, **fields):
return self.store.task_report(dict(report_id='r',task_id='w',session_id='worker',
stage='execution_completed',current_progress='Checks passed',summary='Execution finished',evidence=[],**fields))
def test_create_update_clear_and_persistence(self):
self.create('d',kind='delivery')
work=self.create('w',delivery_id='d')
self.assertEqual(work['kind'],'work')
self.assertEqual(work['delivery_id'],'d')
self.assertEqual(Store(self.path).state()['tasks'],self.store.state()['tasks'])
self.store.task(dict(delivery_id=None),'w')
self.assertIsNone(self.task('w')['delivery_id'])
self.store.task(dict(kind='delivery'),'w')
self.assertEqual(self.task('w')['kind'],'delivery')
def test_invalid_references_and_kind_are_atomic(self):
self.create('d',kind='delivery')
self.create('w')
before=self.task('w')
for fields in [dict(kind='release'),dict(kind=None),dict(kind=[]),dict(delivery_id='missing'),
dict(delivery_id='w'),dict(delivery_id=True),dict(delivery_id=''),
dict(kind='delivery',delivery_id='d')]:
with self.subTest(fields=fields):
with self.assertRaises(Invalid): self.store.task(fields,'w')
self.create('other')
with self.assertRaises(Invalid): self.store.task(dict(delivery_id='other'),'w')
self.assertEqual(self.task('w'),before)
with self.assertRaises(Invalid): self.create('self',delivery_id='self')
self.assertFalse(any(t['id']=='self' for t in self.store.state()['tasks']))
def test_referenced_delivery_cannot_downgrade(self):
self.create('d',kind='delivery')
self.create('w',delivery_id='d')
with self.assertRaises(Invalid): self.store.task(dict(kind='work'),'d')
self.assertEqual(self.task('d')['kind'],'delivery')
self.store.task(dict(delivery_id=None),'w')
self.store.task(dict(kind='work'),'d')
self.assertEqual(self.task('d')['kind'],'work')
def test_old_data_default_does_not_guess_from_version_or_merge(self):
with self.store.db() as db:
db.execute('INSERT INTO tasks(id,data) VALUES(?,?)',('old',json.dumps(dict(id='old',name='Old',version='abc123'))))
old=self.task('old')
self.assertEqual(old['kind'],'work')
self.assertIsNone(old['delivery_id'])
self.assertIsNone(old['created_at'])
self.store.task(dict(current_progress='Reviewed existing work'),'old')
self.assertIsNone(self.task('old')['created_at'])
self.assertEqual(self.task('old')['kind'],'work')
def test_created_at_is_server_owned_stable_across_update_and_report(self):
with patch('agent_control.store.time.time',return_value=100):
self.create('d',kind='delivery')
self.create('w',delivery_id='d')
with patch('agent_control.store.time.time',return_value=200):
self.store.task(dict(name='Renamed'),'w')
self.report(version='candidate')
self.assertEqual(self.task('w')['created_at'],100)
self.assertEqual(self.task('d')['created_at'],100)
for fields in [dict(created_at=1),dict(created_at=None),dict(created=1)]:
with self.assertRaises(Invalid): self.store.task(fields,'w')
with self.assertRaises(Invalid): self.create('forged',created_at=0)
for fields in [dict(kind='delivery'),dict(delivery_id=None),dict(created_at=1)]:
with self.assertRaises(Invalid): self.report(**fields)
self.assertEqual(self.task('w')['kind'],'work')
self.assertEqual(self.task('w')['delivery_id'],'d')
def test_assignment_and_completion_never_invent_merge_or_verification(self):
self.create('d',kind='delivery')
self.create('w',delivery_id='d',status='completed',phase='done')
self.assertIsNone(self.task('w')['merged_into_id'])
self.assertEqual(self.task('d')['merge_sources'],[])
self.assertEqual(self.task('w')['integration_status'],'not_started')
self.store.integrate('d',dict(source_ids=['w'],owner='Integrator',status='verified',reason='Actual target acceptance'))
self.assertEqual(self.task('w')['merged_into_id'],'d')
self.assertEqual(self.task('d')['merge_sources'],['w'])
self.assertEqual(self.task('w')['integration_status'],'not_started')
self.assertEqual(self.task('d')['integration_status'],'verified')
self.store.task(dict(integration_status='verified'),'w')
self.assertEqual(self.task('w')['integration_status'],'verified')
def test_existing_work_to_work_merge_remains_compatible(self):
self.create('d',kind='delivery')
self.create('w',delivery_id='d')
self.create('target')
self.store.integrate('target',dict(source_ids=['w'],owner='Integrator',status='waiting',reason='Existing relationship'))
self.assertEqual(self.task('w')['merged_into_id'],'target')
self.assertEqual(self.task('w')['delivery_id'],'d')
self.assertEqual(self.task('target')['kind'],'work')