Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

### Added

- #621: Add explicit task collaborators and responsibilities, validated dependency graphs, acceptance-based readiness, scoped delivery context and immutable execution snapshots with live project/review inspection.
- Versioned project task briefings: server-owned goal/task requirements and acceptance criteria enter execution and persist for conversation/review inspection, with stale-plan protection and preserved retry/approval bindings (#619).
- #617:接力任务支持可选本步要求,以及与模型输入一致的交接快照;可查看前序结果来源、裁剪和过滤提示,共享现有上下文预算并去除重复来源。新增本地记录字段,降级旧版本前请备份工作区。

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const SERVER_RUNTIME_FILES = [
"dist/src/experiments/service.js",
"dist/src/experiments/store.js",
"dist/src/gemini-binary.js",
"dist/src/goals/collaboration.js",
"dist/src/goals/store.js",
"dist/src/groups/relay-stop.js",
"dist/src/groups/store.js",
Expand Down Expand Up @@ -233,6 +234,7 @@ const SHARED_RUNTIME_FILES = [
"dist/sessions.js",
"dist/task-board.js",
"dist/task-briefing.js",
"dist/task-collaboration.js",
"dist/tool-output.js",
"dist/turn-progress.js",
"dist/turns.js",
Expand Down Expand Up @@ -336,6 +338,8 @@ const APP_RESOURCE_REQUIRED_ENTRIES = [
"apps/server/dist/src/turns/relay-handoff.js",
"apps/server/dist/src/turns/context-snapshot.js",
"apps/server/dist/src/turns/task-briefing.js",
"apps/server/dist/src/goals/collaboration.js",
"node_modules/@roleweave/shared/dist/task-collaboration.js",
"node_modules/@roleweave/shared/task-briefing.cjs",
"node_modules/@roleweave/shared/dist/task-briefing.js",
"examples/oss-maintainer/workspace.json",
Expand Down
33 changes: 31 additions & 2 deletions apps/desktop/renderer/src/goals/ProjectBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { useEngineLabel } from "../turns/engine-select";
import { TaskAcceptanceDrawer } from "./TaskAcceptanceDrawer";
import { ProjectTaskProgress } from "./ProjectTaskProgress";
import { useProjectTaskProgress } from "./useProjectTaskProgress";
import { taskDependenciesReady, taskParticipantError, taskDependencyGraphError } from "@roleweave/shared/task-collaboration";
import { TaskCollaborationEditor } from "./TaskCollaborationEditor";
import { TaskCollaborationDetails } from "./TaskCollaborationDetails";
import "./project-board.css";

export interface ProjectBoardProps {
Expand Down Expand Up @@ -188,6 +191,16 @@ export function ProjectBoard({
!!own(accepted, item.taskId) || !!own(detail.taskExecutions, item.taskId) ||
!!own(detail.taskDeliveries, item.taskId) || !!own(detail.taskDeliveryUnavailable, item.taskId) ||
!!detail.executionUnavailable || !!detail.goal.taskAcceptances?.some((record) => record.taskId === item.taskId);
const dependenciesReady = (item: GoalWorkItem) => {
if (!item.dependsOn?.length) return true;
const context = own(detail.taskCollaborations, item.taskId);
return !!context && taskDependenciesReady(context);
};
const relations = (item: GoalWorkItem) => {
const context = own(detail.taskCollaborations, item.taskId);
return context && (context.participants.length > 0 || context.dependencies.length > 0 || context.dependents.length > 0 || context.omittedDependentCount > 0)
? <TaskCollaborationDetails context={context} positionNames={positionNames} onOpenBoundSession={onOpenBoundSession} /> : null;
};
const openAcceptance = (item: GoalWorkItem) =>
setAcceptanceTask({ item: { ...item }, scopeKey });
const acceptanceItem = acceptanceTask?.scopeKey === scopeKey
Expand Down Expand Up @@ -286,8 +299,11 @@ export function ProjectBoard({
setEditor((current) =>
current ? { ...current, item: { ...current.item, ...fields } } : current,
);
const relationError = editor ? taskParticipantError(editor.item.participants ?? [], editor.item.assigneePositionId) ||
taskDependencyGraphError([...items.filter(item => item.taskId !== editor.item.taskId), editor.item]) : null;
const validEditor =
!!editor?.item.title.trim() &&
!relationError &&
validDate(editor.item.startDate) &&
validDate(editor.item.dueDate) &&
!(
Expand Down Expand Up @@ -316,6 +332,8 @@ export function ProjectBoard({
: {}),
...(draft.startDate ? { startDate: draft.startDate } : {}),
...(draft.dueDate ? { dueDate: draft.dueDate } : {}),
...(draft.participants !== undefined ? { participants: draft.participants.map(p => ({ ...p, responsibility: p.responsibility.trim() })) } : {}),
...(draft.dependsOn !== undefined ? { dependsOn: draft.dependsOn } : {}),
};
void save(
editor.isNew
Expand All @@ -331,6 +349,10 @@ export function ProjectBoard({
const deleteTask = async () => {
if (!editor || editor.isNew || busy) return;
const item = editor.item;
if (items.some(other => other.dependsOn?.includes(item.taskId))) {
setError(t("taskCollaboration.referencedDelete"));
return;
}
if (
!window.confirm(
t("project.deleteTaskConfirm", { title: item.title }),
Expand Down Expand Up @@ -358,6 +380,7 @@ export function ProjectBoard({
if (
!positionId ||
!workspaceKey ||
!dependenciesReady(item) ||
!engine ||
launchLocks.current.has(lockKey) ||
executionOf(item)?.status === "running" ||
Expand Down Expand Up @@ -403,7 +426,8 @@ export function ProjectBoard({
result.status !== 201 &&
result.status !== 202
)
throw new Error(result.status === 409 ? t("taskBriefing.launchConflict") : responseError(result.body, t("project.runError")));
throw new Error(result.body && typeof result.body === "object" && "code" in result.body && result.body.code === "goal_dependency_blocked"
? t("taskCollaboration.blocked") : result.status === 409 ? t("taskBriefing.launchConflict") : responseError(result.body, t("project.runError")));
setAccepted((current) => ({
...current,
[item.taskId]: {
Expand Down Expand Up @@ -554,14 +578,16 @@ export function ProjectBoard({
!!own(launching, item.taskId) ||
running ||
!engine ||
!!detail.executionUnavailable || !workspaceKey
!!detail.executionUnavailable || !workspaceKey || !dependenciesReady(item)
}
onClick={() => void launch(item)}
>
{t("project.run")}
</Button>
</div>
{acceptanceEntry(item)}
{relations(item)}
{!dependenciesReady(item) && <p className="owb-project-card__hint">{t("taskCollaboration.blocked")}</p>}
{item.assigneePositionId && !engine && (
<p className="owb-project-card__hint">
{t("project.configureEngine")}
Expand Down Expand Up @@ -864,6 +890,7 @@ export function ProjectBoard({
<small>{label}</small>
{execution(item)}
{acceptanceEntry(item)}
{relations(item)}
</div>
<div
className="owb-project-timeline__track"
Expand Down Expand Up @@ -1008,6 +1035,8 @@ export function ProjectBoard({
}
/>
</label>
<TaskCollaborationEditor item={editor.item} items={items} positionNames={positionNames} disabled={busy} onChange={patch} />
{relationError && <p role="alert">{t("taskCollaboration.invalid")}</p>}
<div className="owb-project-form__row">
<label>
<span>{t("project.taskStatus")}</span>
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/renderer/src/goals/TaskAcceptanceDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from "@roleweave/shared/goals";
import "./task-acceptance.css";
import { TaskBriefingDetails } from "../turns/TaskBriefingDetails";
import { TaskCollaborationDetails } from "./TaskCollaborationDetails";

interface TaskAcceptanceDrawerProps {
detail: GoalDetail;
Expand Down Expand Up @@ -83,6 +84,10 @@ export function TaskAcceptanceDrawer({
}, []);

const currentDelivery = own(detail.taskDeliveries, item.taskId);
const currentCollaboration = own(detail.taskCollaborations, item.taskId);
const usedCollaboration = draft.delivery?.taskBriefing?.payload.collaboration;
const collaborationChanged = currentCollaboration && usedCollaboration &&
JSON.stringify({ ...currentCollaboration, capturedAt: "" }) !== JSON.stringify({ ...usedCollaboration, capturedAt: "" });
const taskExists = !!detail.goal.workItems?.some((task) => task.taskId === item.taskId);
const stale = conflict || draft.version !== detail.goal.updatedAt ||
!taskExists ||
Expand Down Expand Up @@ -247,6 +252,9 @@ export function TaskAcceptanceDrawer({
<pre aria-label={t("project.acceptance.output")}>{outputText(draft.delivery.output)}</pre>
{draft.delivery.taskBriefing ? <TaskBriefingDetails snapshot={draft.delivery.taskBriefing} currentPlanVersion={draft.version} />
: <p>{t("taskBriefing.unavailable")}</p>}
{collaborationChanged && <p role="status">{t("taskCollaboration.changed")}</p>}
{currentCollaboration && (!!usedCollaboration || currentCollaboration.dependencies.length > 0 || currentCollaboration.participants.length > 0 || currentCollaboration.dependents.length > 0) &&
<TaskCollaborationDetails context={currentCollaboration} title={t("taskCollaboration.current")} />}
</section> : <p role="status" className="owb-project-notice">{t(unavailableKey)}</p>}
{!saved && draft.delivery && <fieldset disabled={busy || stale} className="owb-task-acceptance__criteria">
<legend>{t("project.acceptance.criteria")}</legend>
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/renderer/src/goals/TaskCollaborationDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";
import { useOwbLocale, useT } from "@roleweave/ui";
import type { TaskCollaborationContext } from "@roleweave/shared/task-collaboration";
import "./task-collaboration.css";

export function TaskCollaborationView({ context, positionNames = {}, onOpenBoundSession }: {
context: TaskCollaborationContext; positionNames?: Record<string, string>;
onOpenBoundSession?: (positionId: string, sessionId?: string, turnId?: string) => void;
}) {
const t = useT(), locale = useOwbLocale();
const name = (id: string) => Object.hasOwn(positionNames, id) ? positionNames[id]! : id;
return <div className="owb-task-collaboration__body">
<p className="owb-task-collaboration__muted">{t("taskCollaboration.observedAt")} <time dateTime={context.capturedAt}>
{new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(context.capturedAt))}</time></p>
<p>{t("taskCollaboration.fixedInput")}</p>
{context.redacted && <p>{t("taskCollaboration.redacted")}</p>}
<h4>{t("taskCollaboration.participants")}</h4>
{context.participants.length === 0 ? <p>{t("taskCollaboration.noParticipants")}</p> : <ul>{context.participants.map(p => <li key={p.positionId}>
<strong>{name(p.positionId)}</strong> · {t(`taskCollaboration.role.${p.role}`)}<p>{p.responsibility}</p>
</li>)}</ul>}
<h4>{t("taskCollaboration.dependencies")}</h4>
{context.dependencies.length === 0 ? <p>{t("taskCollaboration.independent")}</p> : <ul>{context.dependencies.map(d => <li key={d.taskId}>
<strong>{d.title}</strong>{d.assigneePositionId && <> · {name(d.assigneePositionId)}</>}
<p>{t(`project.status.${d.status}`)} · <span data-readiness={d.readiness}>{t(`taskCollaboration.readiness.${d.readiness}`)}</span>
{d.executionStatus && <> · {t(`project.execution.${d.executionStatus}`)}</>}</p>
{d.delivery && <div>
<p className="owb-task-collaboration__muted">{t("taskCollaboration.delivery")} <code>{d.delivery.turnId}</code></p>
<code className="owb-task-collaboration__digest">{d.delivery.outputDigest}</code>
<pre>{d.delivery.outputExcerpt || t("taskCollaboration.emptyOutput")}</pre>
{d.delivery.truncated && <p>{t("taskCollaboration.truncated")}</p>}
{onOpenBoundSession && <button type="button" onClick={() => onOpenBoundSession(d.delivery!.positionId, d.delivery!.sessionId, d.delivery!.turnId)}>{t("taskCollaboration.openSource")}</button>}
</div>}
</li>)}</ul>}
<h4>{t("taskCollaboration.dependents")}</h4>
{context.dependents.length === 0 ? <p>{t("taskCollaboration.noDependents")}</p> : <ul>{context.dependents.map(d => <li key={d.taskId}>
{d.title}{d.assigneePositionId && <> · {name(d.assigneePositionId)}</>}
</li>)}</ul>}
{context.omittedDependentCount > 0 && <p>{t("taskCollaboration.omitted", { count: context.omittedDependentCount })}</p>}
</div>;
}

export function TaskCollaborationDetails({ title, ...props }: Parameters<typeof TaskCollaborationView>[0] & { title?: string }) {
const t = useT();
const [open, setOpen] = useState(false);
return <details className="owb-task-collaboration" open={open} onToggle={event => setOpen(event.currentTarget.open)}>
<summary>{title ?? t("taskCollaboration.title")}</summary>
{open && <TaskCollaborationView {...props} />}
</details>;
}
48 changes: 48 additions & 0 deletions apps/desktop/renderer/src/goals/TaskCollaborationEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Button, Checkbox, Input } from "antd";
import { Select } from "@fullstack-ai-infra/ui";
import { useT } from "@roleweave/ui";
import type { GoalWorkItem } from "@roleweave/shared/goals";
import type { TaskParticipant } from "@roleweave/shared/task-collaboration";
import "./task-collaboration.css";

export function TaskCollaborationEditor({ item, items, positionNames, disabled, onChange }: {
item: GoalWorkItem; items: GoalWorkItem[]; positionNames: Record<string, string>; disabled: boolean;
onChange: (patch: Partial<GoalWorkItem>) => void;
}) {
const t = useT();
const participants = item.participants ?? [];
const dependencies = item.dependsOn ?? [];
const patchParticipant = (positionId: string, fields: Partial<TaskParticipant>) =>
onChange({ participants: participants.map(p => p.positionId === positionId ? { ...p, ...fields } : p) });
const available = Object.keys(positionNames).filter(id => id !== item.assigneePositionId && !participants.some(p => p.positionId === id));
return <section className="owb-task-collaboration-editor" aria-label={t("taskCollaboration.editor")}>
<h4>{t("taskCollaboration.participants")}</h4>
<p>{t("taskCollaboration.explicit")}</p>
{participants.map(p => {
const name = Object.hasOwn(positionNames, p.positionId) ? positionNames[p.positionId]! : p.positionId;
return <div key={p.positionId} className="owb-task-collaboration-editor__person">
<div><strong>{name}</strong><Button size="small" disabled={disabled} aria-label={t("taskCollaboration.remove", { name })}
onClick={() => onChange({ participants: participants.filter(person => person.positionId !== p.positionId) })}>{t("taskCollaboration.removeAction")}</Button></div>
<Select<TaskParticipant["role"]> aria-label={t("taskCollaboration.roleFor", { name })} value={p.role} disabled={disabled}
options={[{ value: "contributor", label: t("taskCollaboration.role.contributor") }, { value: "reviewer", label: t("taskCollaboration.role.reviewer") }]}
onChange={role => patchParticipant(p.positionId, { role })} />
<Input.TextArea rows={2} maxLength={1024} disabled={disabled} aria-label={t("taskCollaboration.responsibilityFor", { name })}
placeholder={t("taskCollaboration.responsibilityHint")} value={p.responsibility}
onChange={event => patchParticipant(p.positionId, { responsibility: event.target.value })} />
</div>;
})}
<Select<string> aria-label={t("taskCollaboration.add")} value="" disabled={disabled || participants.length >= 8 || available.length === 0}
options={[{ value: "", label: t("taskCollaboration.add") }, ...available.map(id => ({ value: id, label: positionNames[id]! }))]}
onChange={positionId => { if (positionId) onChange({ participants: [...participants, { positionId, role: "contributor", responsibility: "" }] }); }} />
<h4>{t("taskCollaboration.dependencies")}</h4>
<p>{t("taskCollaboration.dependencyHint")}</p>
<div className="owb-task-collaboration-editor__dependencies">
{items.filter(candidate => candidate.taskId !== item.taskId).map(candidate => <Checkbox key={candidate.taskId}
checked={dependencies.includes(candidate.taskId)} disabled={disabled || (!dependencies.includes(candidate.taskId) && dependencies.length >= 8)}
onChange={event => onChange({ dependsOn: event.target.checked ? [...dependencies, candidate.taskId] : dependencies.filter(id => id !== candidate.taskId) })}>
{candidate.title}
</Checkbox>)}
{items.every(candidate => candidate.taskId === item.taskId) && <p>{t("taskCollaboration.noCandidates")}</p>}
</div>
</section>;
}
16 changes: 16 additions & 0 deletions apps/desktop/renderer/src/goals/task-collaboration.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.owb-task-collaboration { margin-block: 8px; border: 1px solid var(--ui-border); border-radius: 8px; min-width: 0; font-size: 12px; }
.owb-task-collaboration > summary { padding: 8px; cursor: pointer; }
.owb-task-collaboration__body { padding: 8px; min-width: 0; overflow-wrap: anywhere; }
.owb-task-collaboration > .owb-task-collaboration__body { max-height: 360px; overflow: auto; }
.owb-task-collaboration__body h4 { font-size: 12px; margin: 12px 0 6px; }
.owb-task-collaboration__body ul { padding-inline-start: 16px; margin: 6px 0; }
.owb-task-collaboration__body p { margin: 5px 0; white-space: pre-wrap; }
.owb-task-collaboration__body pre { font: inherit; white-space: pre-wrap; overflow-wrap: anywhere; padding: 8px; background: var(--ui-surface-inset); border-radius: 6px; }
.owb-task-collaboration__muted, .owb-task-collaboration__digest { font-size: 11px; color: var(--ui-foreground-muted); overflow-wrap: anywhere; }
.owb-task-collaboration-editor { display: grid; gap: 10px; min-width: 0; }
.owb-task-collaboration-editor h4 { margin: 4px 0 0; font-size: 13px; }
.owb-task-collaboration-editor p { margin: 0; font-size: 12px; color: var(--ui-foreground-muted); }
.owb-task-collaboration-editor__person { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--ui-border); border-radius: 8px; min-width: 0; }
.owb-task-collaboration-editor__person > div:first-child { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.owb-task-collaboration-editor__dependencies { display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow: auto; overflow-wrap: anywhere; }
.owb-task-collaboration-editor__dependencies .ant-checkbox-wrapper { display: flex; align-items: flex-start; gap: 0; }
2 changes: 2 additions & 0 deletions apps/desktop/renderer/src/turns/TaskBriefingDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { useOwbLocale, useT } from "@roleweave/ui";
import type { TaskBriefingSnapshot } from "@roleweave/shared";
import "./task-briefing.css";
import { TaskCollaborationView } from "../goals/TaskCollaborationDetails";

/** Read only the execution snapshot; current project edits cannot change it. */
export function TaskBriefingDetails({ snapshot, currentPlanVersion }: {
Expand All @@ -28,6 +29,7 @@ export function TaskBriefingDetails({ snapshot, currentPlanVersion }: {
<strong>{t("taskBriefing.criteria")}</strong>
{p.acceptanceCriteria.length ? <ol>{p.acceptanceCriteria.map((criterion, index) => <li key={index}>{criterion}</li>)}</ol>
: <p>{t("taskBriefing.noCriteria")}</p>}
{p.collaboration && <TaskCollaborationView context={p.collaboration} />}
</div>}
</details>;
}
Loading
Loading