Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e9569eb
docs: R17 gap review report (42 confirmed) + fix plan with fork decis…
Jul 16, 2026
5569582
feat(g4): notification type labels + care opt-out + OKR titles wiring
Jul 16, 2026
81eeda7
feat(g4 #24/#9-web): project-home custom-instructions card + plan-dra…
Jul 16, 2026
0264115
feat(members): 会话参与者加人/退群/移出 + 拍板A注释 (R17-G1 #1/#16/#3)
Jul 16, 2026
3fa5868
feat(members): 工作区成员移出/角色变更路由 (R17-G1 #15)
Jul 16, 2026
5c04392
fix(observer): #4 open a real escalation when execute dispatch fails
Jul 16, 2026
0794f54
fix(escalations): #5 publish escalation.opened to the target user's /…
Jul 16, 2026
87012e6
fix(approval-digest): #18 count all project-scoped pending decisions
Jul 16, 2026
7203960
feat(g4 #9): E3 project-plan drafts — desktop schedule tab double-entry
Jul 16, 2026
e707972
feat(inbox): #17 give the shared attention inbox a refresh() handle
Jul 16, 2026
84d0fce
feat(workbench): #6/#17/#37/#38 decision-class me-stream + live inbox…
Jul 16, 2026
ee5d4a6
merge: r17/g4-wiring 后端接线批入 wave1
Jul 16, 2026
1ce1aee
chore(events): #25/#31 mark zero-producer event types + prune dead pe…
Jul 16, 2026
81737a4
feat(members): roster 邀请/移出入口 + 项目设置成员分区 + 兑现弹窗文案 (R17-G1 #14/#15/#2)
Jul 16, 2026
ac20ab6
merge: r17/g2-inbox-events 决策推送链入 wave1
Jul 16, 2026
08d9bac
feat(army): dual-publish run lifecycle events to the source conversat…
Jul 16, 2026
3491f58
feat(army): read-only GET /api/army/background endpoint (#8, fork B)
Jul 16, 2026
67574eb
fix(army): drop the unreachable budget_exhausted run-status label (#34)
Jul 16, 2026
7103b07
feat(army): panel realtime + operations — abort, escalated badge, dri…
Jul 16, 2026
d188c76
merge: r17/g3-army 军团实时性入 wave1
Jul 16, 2026
b60c4a8
feat(members): 会话成员条加人/退群/移出 UI + participants.updated 消费 (R17-G1 #1/…
Jul 16, 2026
fffc374
merge: r17/g1-members 群成员管理入 wave1
Jul 16, 2026
c598dd1
feat(editor): #12 merge_conflict panel reuse + #13 multi-file switcher
Jul 16, 2026
c8f8a51
feat(kanban): #27 assignee + keyword front-end filter
Jul 16, 2026
f9d9150
feat(schedule): #26 undated strip + #28 month/week toggle
Jul 16, 2026
1c4b0c3
feat(inbox): #29 kind filter chips + approval batch approve
Jul 16, 2026
38fd6e8
feat(search): #30 deep-link seq — position + highlight the hit message
Jul 16, 2026
f137966
merge: r17/g5-ux UX收尾批入 wave1
Jul 16, 2026
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
137 changes: 131 additions & 6 deletions apps/api/src/agent-runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3610,9 +3610,11 @@ test("agent run enqueue opens user_forbidden escalation for human-reserved worke
assert.equal(auditLogs.rows.some((row) => row.action === "escalation.opened"), true);
// findings[#tenancy]:全局 escalation 发到按工作区隔离的话题 `all:<workspaceId>`(不再裸 'all'),
// 与订阅侧对齐。单租户下解析到默认工作区。
// #5:升级除工作项流 + 工作区全局流外,也发到升级目标人(认领人优先、否则提交人=userId)的 per-user /me 流。
assert.deepEqual(events.map((event) => [event.topic, event.type]), [
[`workitem:${workItemId}`, "escalation.opened"],
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"]
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"],
[`user:${userId}`, "escalation.opened"]
]);
assert.equal(workItems.rows.get(workItemId)?.status, "pm_mode");
assert.equal(workItems.rows.get(workItemId)?.mode, "pm");
Expand Down Expand Up @@ -3665,7 +3667,42 @@ test("human-reserved guard audits and publishes escalation in the work item's wo
assert.equal(audit?.orgId, runtimeSettings.auth.defaultOrgId);
assert.deepEqual(events.map((event) => [event.topic, event.type]), [
[`workitem:${workItemId}`, "escalation.opened"],
[`all:${nonDefaultWorkspaceId}`, "escalation.opened"]
[`all:${nonDefaultWorkspaceId}`, "escalation.opened"],
// #5:目标人取该工作项的当前真人负责人(提交人=userId,认领人为空)。
[`user:${userId}`, "escalation.opened"]
]);
});

test("#5 human-reserved escalation routes the per-user /me publish to the claimant when the work item is claimed", async () => {
const runtimeSettings = settings();
const claimantUserId = "10000000-0000-4000-8000-000000000099";
assert.notEqual(claimantUserId, userId);
const workItems = new MemoryWorkItems([
humanReservedWorkItemRow({ claimedByUserId: claimantUserId })
]);
const decisions = new MemoryAiDecisions();
const auditLogs = new MemoryAuditLogs();
const events: { topic: string; type: string }[] = [];
const guard = createHumanReservedGuard({
workItems,
decisions,
auditLogs,
settings: runtimeSettings,
now: () => now,
bus: {
async publish(topic, type) {
events.push({ topic, type });
}
}
});

const result = await guard({ workItemId, actorId: userId, mode: "worker", settings: runtimeSettings });
assert.equal(result?.trigger, "user_forbidden");
// 目标人:认领人优先于提交人——被转人接手的正是当前认领人。
assert.deepEqual(events.map((event) => [event.topic, event.type]), [
[`workitem:${workItemId}`, "escalation.opened"],
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"],
[`user:${claimantUserId}`, "escalation.opened"]
]);
});

Expand Down Expand Up @@ -3787,7 +3824,9 @@ test("R9.7 high-risk legal, finance, identity, and publishing tool calls are sto
assert.equal(auditLogs.rows.some((row) => row.action === "escalation.opened"), true);
assert.deepEqual(events.map((event) => [event.topic, event.type]), [
[`workitem:${caseWorkItemId}`, "escalation.opened"],
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"]
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"],
// #5:高风险工具调用升级同样发到目标人(提交人=userId)的 per-user /me 流。
[`user:${userId}`, "escalation.opened"]
]);
}
});
Expand Down Expand Up @@ -3971,10 +4010,12 @@ test("R9.7 high-risk tool calls create durable evidence instead of reusing a gen
tool_id: "finance_payment_release",
risk_category: "finance"
});
assert.equal(events.length, 4, "the tool-specific escalation is published alongside the existing generic card");
assert.deepEqual(events.slice(2).map((event) => [event.topic, event.type, event.data.source, event.data.tool_id]), [
// #5:每次升级现发 3 条(工作项流 + 工作区全局流 + 目标人 per-user /me 流)——generic + tool-call 各 3 条 = 6。
assert.equal(events.length, 6, "the tool-specific escalation is published alongside the existing generic card");
assert.deepEqual(events.slice(3).map((event) => [event.topic, event.type, event.data.source, event.data.tool_id]), [
[`workitem:${workItemId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"],
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"]
[`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"],
[`user:${userId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"]
]);
});

Expand Down Expand Up @@ -6596,6 +6637,90 @@ test("FIX#4 + R14: a run with no conversation source omits conversationId from t
assert.equal("conversationId" in (milestoneNotifications[0] ?? {}), false);
});

// R17 G3(#7 军团面板实时性):一个带 source_conversation_id 的 run,其【状态级】生命周期事件
// (started/failed/escalated + 成功终态 agent_run.step[kind:'done'])除了发 topics.run/topics.workitem,
// 还要双发到血缘会话 topic——军团会话情境面板订的是会话流,据此局部重拉,纯执行推进不再停在旧快照。
// 逐 step 高频增量(step.tool_result / step.snapshot / 非 done 的 agent_run.step)**不**双发到会话,
// 避免放大扇出/触发整面刷新(与 workitem 流同一取舍)。
test("R17 G3 #7: a run sourced from a conversation dual-publishes only status-level lifecycle events to the conversation topic", async () => {
const runtimeSettings = settings();
const workdir = await mkdtemp(path.join(os.tmpdir(), "workhub-g3-conversation-run-test-"));
const snapshotRoot = await mkdtemp(path.join(os.tmpdir(), "workhub-g3-conversation-snapshot-test-"));
const snapshots = new MemorySnapshots();
const auditLogs = new MemoryAuditLogs();
const decisions = new MemoryAiDecisions();
const publishedEvents: { topic: string; type: string; data: WorkHubEvent<Record<string, unknown>> }[] = [];
const sourceConversationId = "60000000-0000-4000-8000-000000000701";
const queue = createInMemoryAgentRunQueue({
settings: runtimeSettings,
now: () => now,
id: () => "40000000-0000-4000-8000-000000000045",
workdir: () => workdir,
client: () => executableAgentClient(),
snapshotRoot,
snapshotId: () => snapshotId,
snapshots,
auditLogs,
confidence: createAgentRunConfidenceRecorder({ decisions, auditLogs, settings: runtimeSettings }),
proposals: false,
notificationWorkItem: async () => ({
id: workItemId,
code: "WH-21",
title: "Conversation-sourced worker run",
projectId: "50000000-0000-4000-8000-000000000099",
submitterUserId: userId,
projectOwnerUserId: projectOwnerId
}),
eventBus: {
async publish(topic, type, data) {
publishedEvents.push({ topic, type, data: data as WorkHubEvent<Record<string, unknown>> });
}
}
});

const queued = await queue.enqueue({
workItemId,
actorId: userId,
title: "Conversation-sourced worker run",
sourceConversationId
});
const executed = await queue.runNext();
assert.equal(executed?.run_id, queued.run_id);
assert.equal(executed?.status, "succeeded", JSON.stringify(executed?.trace.at(-1)));

const runTopic = topics.run(queued.run_id).topic;
const workItemTopic = topics.workitem(workItemId).topic;
const conversationTopic = topics.conversation(sourceConversationId).topic;

const isLifecycleKey = (event: { type: string; data: WorkHubEvent<Record<string, unknown>> }) =>
/(?:started|failed|escalated)/u.test(event.type)
|| (event.type === eventTypes.agentRunStep && event.data.data["kind"] === "done");

const conversationEvents = publishedEvents.filter((event) => event.topic === conversationTopic);
// 会话 topic 只该收到状态级子集:至少 started + 成功终态;且【每一条】都是 lifecycle-key。
assert.equal(conversationEvents.length > 0, true);
assert.equal(conversationEvents.every(isLifecycleKey), true);
assert.equal(conversationEvents.some((event) => event.type === eventTypes.agentRunStarted), true);
assert.equal(
conversationEvents.some((event) => event.type === eventTypes.agentRunStep && event.data.data["kind"] === "done"),
true
);
// 逐 step 增量(tool_result / snapshot)只在 run topic 上,绝不出现在会话 topic。
assert.equal(publishedEvents.some((event) => event.topic === runTopic && event.type === eventTypes.stepToolResult), true);
assert.equal(conversationEvents.some((event) => event.type === eventTypes.stepToolResult), false);
assert.equal(conversationEvents.some((event) => event.type === eventTypes.stepSnapshot), false);
// 会话 topic 收到的条数严格少于 run topic(证明高频增量被过滤,没有整流照搬)。
const runEvents = publishedEvents.filter((event) => event.topic === runTopic);
assert.equal(conversationEvents.length < runEvents.length, true);
// envelope.topic 与投递 topic 一致(与 workitem 双发同一改写)。
for (const event of conversationEvents) {
assert.equal(event.data.topic, conversationTopic);
assert.equal(event.data.run_id, queued.run_id);
}
// 对照:workitem topic 仍照旧双发(本改动不动它)。
assert.equal(publishedEvents.some((event) => event.topic === workItemTopic && event.type === eventTypes.agentRunStarted), true);
});

test("FIX#7: POST /workitems/:id/agent-runs on a spec_ready item kicks it to ai_working and reaches in_review (not stuck)", async () => {
const runtimeSettings = settings();
const workdir = await mkdtemp(path.join(os.tmpdir(), "workhub-fix7-test-"));
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { createProposalRoutes, createWorkItemProposalRoutes } from "./routes/pro
import { createCostRoutes } from "./routes/cost.js";
import { createConversationRoutes } from "./routes/conversations.js";
import { createDmRoutes } from "./routes/dm.js";
import { createWorkspaceMemberRoutes } from "./routes/workspace-members.js";
import { createAiSettingsRoutes } from "./routes/ai-settings.js";
import { createUserProfileRoutes } from "./routes/user-profile.js";
import { createUserAvatarRoutes } from "./routes/user-avatar.js";
Expand Down Expand Up @@ -83,6 +84,7 @@ import { ProjectInstructionsServiceError } from "./services/project-instructions
import { ProjectPlannerServiceError } from "./services/project-planner.js";
import { InternalContractError } from "./pages/output-contract.js";
import { ConversationServiceError } from "./services/conversations.js";
import { WorkspaceMemberServiceError } from "./services/workspace-members.js";
import { AiSettingsServiceError } from "./services/ai-settings.js";
import { UserProfileServiceError } from "./services/user-profile.js";
import { UserAvatarServiceError } from "./services/user-avatar.js";
Expand Down Expand Up @@ -276,6 +278,8 @@ app.route("/api", createProjectInstructionsRoutes());
app.route("/api", createProjectPlannerRoutes());
app.route("/api", createConversationRoutes());
app.route("/api/dm", createDmRoutes());
// R17 批 G1(群成员管理):工作区成员移出/角色变更(DELETE/PATCH /api/workspace/members/:userId)。
app.route("/api", createWorkspaceMemberRoutes());
app.route("/api", createAiSettingsRoutes());
// R13 批 A2(派人推荐 v2):GET/PATCH /me/profile ——「我是谁」资料面(title/bio/技能标签)。
app.route("/api", createUserProfileRoutes());
Expand Down Expand Up @@ -432,6 +436,19 @@ app.onError((error, c) => {
);
}

if (error instanceof WorkspaceMemberServiceError) {
return c.json(
{
ok: false,
error: {
code: error.code,
message: error.message
}
},
error.status as 400
);
}

if (error instanceof AiSettingsServiceError) {
return c.json(
{
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,20 @@ class MemoryMemberships implements WorkspaceMembershipRepository {
row.updatedAt = at;
return row;
}

async listActiveByWorkspace(workspaceId: string) {
return this.rows.filter((row) => row.workspaceId === workspaceId && row.deletedAt === null);
}

async updateRole(id: string, role: MembershipRole, at: Date) {
const row = this.rows.find((candidate) => candidate.id === id && candidate.deletedAt === null);
if (!row) {
return null;
}
row.role = role;
row.updatedAt = at;
return row;
}
}

function inviteRow(input: CreateInviteInput, seq = 1): UserInviteRow {
Expand Down
73 changes: 73 additions & 0 deletions apps/api/src/conversation-observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,79 @@ test("a per-item work-item creation failure escalates only that item and does no
assert.equal(items[0]?.status, "escalated");
});

// #4:execute 类派发失败(工作项已建成、enqueue 抛错)时补落真实 escalation_event,让这次失败进决策收件箱、
// 有恢复入口——对齐 decide 类总会落 ai_decisions 行的语义。同时把工作项推进 escalated、往会话里发一条转人系统提示。
test("execute dispatch failure after the work item exists opens a real escalation so the failure reaches the decision inbox", async () => {
let cardInput: unknown;
let escalationInput: unknown;
let transitionInput: unknown;
let systemNoteInput: unknown;
const deps = baseDeps({
actionCards: {
...baseDeps().actionCards,
async listObserverCandidates() {
return [candidate()];
},
async createOrAppendCard(input) {
cardInput = input;
return baseDeps().actionCards.createOrAppendCard(input);
},
async postSystemMessage(input) {
systemNoteInput = input;
return cardMessageRow({ id: "system-message-escalated", kind: "system_event" });
}
},
workItems: {
async createWorkItem(input) {
return workItemRow({ id: "work-item-execute-failed", submitterUserId: input.submitterUserId });
},
async findProjectById() {
return projectRow();
},
async transitionWorkItemStatus(input) {
transitionInput = input;
return { id: input.workItemId, status: input.to, transitioned: true };
}
},
agentRuns: {
async enqueue() {
throw new Error("queue is down");
}
},
aiSettings: {
async findUserProfileAccessRecord() {
return { membershipRole: "member", profile: { workspaceId, userId: assigneeUserId, defaultMode: 3, granularJson: {}, dispatchPolicy: "auto", cuuProactivity: "balanced", modelTierPref: null, createdAt: now, updatedAt: now } as UserAiProfileRow };
}
},
decisions: {
async createEscalationEvent(input) {
escalationInput = input;
return escalationRow();
}
},
client: llmClientReturning({
items: [{ kind: "execute", title_md: "重写第三节", confidence: "high", suggested_assignee_nickname: "张三" }]
})
});

const result = await createConversationObserverScheduler(deps).tick();
assert.equal(result.failed, 0, "a dispatch failure that we recover into an escalation is not a tick-level analysis failure");
assert.equal(result.cards_created, 1);
const items = (cardInput as { items: Array<Record<string, unknown>> }).items;
assert.equal(items[0]?.status, "escalated");
assert.equal(items[0]?.workItemId, "work-item-execute-failed", "the item keeps the work-item lineage so the inbox can resolve it");
// 真落了一条 escalation(进决策收件箱的驱动数据),指向失败工作项、带负责人与来源溯源。
assert.equal((escalationInput as { workItemId: string }).workItemId, "work-item-execute-failed");
assert.equal((escalationInput as { trigger: string }).trigger, "unqualified");
assert.equal((escalationInput as { suggestedLeadUserId: string }).suggestedLeadUserId, assigneeUserId);
assert.equal((escalationInput as { handoffJson: { execute_dispatch_failed?: boolean } }).handoffJson.execute_dispatch_failed, true);
// 工作项被推进 escalated(状态诚实反映"在等人")。
assert.equal((transitionInput as { workItemId: string }).workItemId, "work-item-execute-failed");
assert.equal((transitionInput as { to: string }).to, "escalated");
// 会话里落一条转人系统提示。
assert.equal((systemNoteInput as { content: { event?: string } }).content.event, "execute_item_escalated");
});

// ── tick: decide dispatch ────────────────────────────────────────────────────────────

test("decide items open a pm-mode work item, create an escalation, and post a threaded @-mention", async () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/conversation-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ function service(overrides: Partial<ConversationService> = {}): ConversationServ
async listParticipants() {
throw new Error("listParticipants not expected");
},
async addParticipant() {
throw new Error("addParticipant not expected");
},
async removeParticipant() {
throw new Error("removeParticipant not expected");
},
async listMessages() {
return { messages: [], has_more: false, next_after_seq: 0 };
},
Expand Down
Loading
Loading