feat: sync codex thread metadata

This commit is contained in:
AI Bot
2026-06-03 14:38:15 +08:00
parent 0186ef7057
commit 5537fde7a6
11 changed files with 518 additions and 1 deletions

View File

@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import {
appendProjectMessage,
buildCollaborationGate,
readState,
type ThreadMetadataGitInfoPatch,
} from "@/lib/boss-data";
import { canAccessProject } from "@/lib/boss-permissions";
import {
queueThreadMetadataSyncTask,
ThreadConversationExecutionConflictError,
} from "@/lib/boss-master-agent";
function forbiddenResponse(message = "FORBIDDEN") {
return NextResponse.json({ ok: false, message }, { status: 403 });
}
function normalizeReason(value: unknown) {
const trimmed = String(value ?? "").trim();
return trimmed ? trimmed : undefined;
}
function normalizeGitInfo(value: unknown): ThreadMetadataGitInfoPatch | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const input = value as Record<string, unknown>;
const patch: ThreadMetadataGitInfoPatch = {};
for (const key of ["sha", "branch", "originUrl"] as const) {
const field = input[key];
if (field === null) {
patch[key] = null;
} else if (typeof field === "string") {
const trimmed = field.trim();
if (trimmed) {
patch[key] = trimmed;
}
}
}
return Object.keys(patch).length > 0 ? patch : undefined;
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const body = (await request.json().catch(() => ({}))) as {
gitInfo?: unknown;
reason?: unknown;
};
const gitInfo = normalizeGitInfo(body.gitInfo);
if (!gitInfo) {
return NextResponse.json(
{ ok: false, message: "THREAD_METADATA_GIT_INFO_REQUIRED" },
{ status: 400 },
);
}
const reason = normalizeReason(body.reason);
const state = await readState();
const projectExists = state.projects.some((project) => project.id === projectId);
if (!canAccessProject(state, session, projectId, "project.view")) {
return forbiddenResponse(projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND");
}
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
}
try {
const message = await appendProjectMessage({
projectId,
account: session.account,
senderLabel: session.displayName || "你",
body: reason || "同步 Codex 线程 Git 元数据。",
kind: "text",
});
const task = await queueThreadMetadataSyncTask({
projectId,
requestMessageId: message.id,
gitInfo,
reason,
requestedBy: session.displayName || session.account,
requestedByAccount: session.account,
});
const nextState = await readState();
const project = nextState.projects.find((item) => item.id === projectId);
return NextResponse.json({
ok: true,
message,
task,
collaborationGate: buildCollaborationGate(project),
});
} catch (error) {
if (error instanceof ThreadConversationExecutionConflictError) {
return NextResponse.json(
{
ok: false,
code: error.message,
message: "THREAD_EXECUTION_CONFLICT",
executionConflict: error.conflict,
},
{ status: 409 },
);
}
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -489,6 +489,7 @@ export type ComputerControlIntentCategory =
| "thread_unarchive"
| "thread_rename"
| "thread_goal_sync"
| "thread_metadata_sync"
| "browser_control"
| "desktop_control";
export type ComputerControlRuntimeKind =
@@ -684,6 +685,12 @@ export interface ThreadConversationMeta {
codexFolderRef?: string;
}
export interface ThreadMetadataGitInfoPatch {
sha?: string | null;
branch?: string | null;
originUrl?: string | null;
}
export interface GroupConversationMember {
projectId: string;
deviceId: string;
@@ -1362,6 +1369,8 @@ export interface MasterAgentTask {
threadGoalStatus?: "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
threadGoalTokenBudget?: number;
threadGoalReason?: string;
threadMetadataGitInfo?: ThreadMetadataGitInfoPatch;
threadMetadataReason?: string;
intentCategory?: ComputerControlIntentCategory;
runtimeKind?: ComputerControlRuntimeKind;
controlPlatform?: ComputerControlPlatform;
@@ -2489,6 +2498,30 @@ function trimToDefined(value?: string) {
return trimmed ? trimmed : undefined;
}
function normalizeThreadMetadataGitInfoPatch(value: unknown): ThreadMetadataGitInfoPatch | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const input = value as {
sha?: unknown;
branch?: unknown;
originUrl?: unknown;
};
const patch: ThreadMetadataGitInfoPatch = {};
for (const key of ["sha", "branch", "originUrl"] as const) {
const field = input[key];
if (field === null) {
patch[key] = null;
} else if (typeof field === "string") {
const trimmed = field.trim();
if (trimmed) {
patch[key] = trimmed;
}
}
}
return Object.keys(patch).length > 0 ? patch : undefined;
}
function normalizeAuditSnapshot(value: unknown): PermissionAuditSnapshot | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@@ -4772,6 +4805,8 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
? Math.floor(Number(task.threadGoalTokenBudget))
: undefined,
threadGoalReason: trimToDefined(task.threadGoalReason),
threadMetadataGitInfo: normalizeThreadMetadataGitInfoPatch(task.threadMetadataGitInfo),
threadMetadataReason: trimToDefined(task.threadMetadataReason),
intentCategory:
task.intentCategory === "discussion_only" ||
task.intentCategory === "project_development" ||
@@ -4782,6 +4817,7 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
task.intentCategory === "thread_unarchive" ||
task.intentCategory === "thread_rename" ||
task.intentCategory === "thread_goal_sync" ||
task.intentCategory === "thread_metadata_sync" ||
task.intentCategory === "browser_control" ||
task.intentCategory === "desktop_control"
? task.intentCategory
@@ -8857,6 +8893,8 @@ export async function queueMasterAgentTask(payload: {
threadGoalStatus?: "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
threadGoalTokenBudget?: number;
threadGoalReason?: string;
threadMetadataGitInfo?: ThreadMetadataGitInfoPatch;
threadMetadataReason?: string;
intentCategory?: ComputerControlIntentCategory;
runtimeKind?: ComputerControlRuntimeKind;
controlPlatform?: ComputerControlPlatform;
@@ -8946,6 +8984,8 @@ export async function queueMasterAgentTask(payload: {
? Math.floor(Number(payload.threadGoalTokenBudget))
: undefined,
threadGoalReason: trimToDefined(payload.threadGoalReason),
threadMetadataGitInfo: normalizeThreadMetadataGitInfoPatch(payload.threadMetadataGitInfo),
threadMetadataReason: trimToDefined(payload.threadMetadataReason),
intentCategory: payload.intentCategory,
runtimeKind: payload.runtimeKind,
controlPlatform: payload.controlPlatform,

View File

@@ -32,6 +32,7 @@ import type {
ProjectExecutionPolicy,
ProjectAgentControls,
ReasoningEffort,
ThreadMetadataGitInfoPatch,
} from "@/lib/boss-data";
import type { ThreadConversationExecutionConflict } from "@/lib/thread-execution-conflict";
import {
@@ -3499,6 +3500,93 @@ export async function queueThreadGoalSyncTask(params: {
});
}
function normalizeThreadMetadataGitInfoPatch(value: ThreadMetadataGitInfoPatch | undefined) {
if (!value || typeof value !== "object") {
return undefined;
}
const patch: ThreadMetadataGitInfoPatch = {};
for (const key of ["sha", "branch", "originUrl"] as const) {
const field = value[key];
if (field === null) {
patch[key] = null;
} else if (typeof field === "string") {
const trimmed = field.trim();
if (trimmed) {
patch[key] = trimmed;
}
}
}
return Object.keys(patch).length > 0 ? patch : undefined;
}
function buildThreadMetadataSyncPrompt(params: {
project: Project;
gitInfo: ThreadMetadataGitInfoPatch;
reason?: string;
}) {
const threadTitle =
params.project.threadMeta.threadDisplayName?.trim() || params.project.name || "当前线程";
const gitFields = [
params.gitInfo.sha !== undefined ? `sha=${params.gitInfo.sha ?? "清除"}` : undefined,
params.gitInfo.branch !== undefined ? `branch=${params.gitInfo.branch ?? "清除"}` : undefined,
params.gitInfo.originUrl !== undefined ? `originUrl=${params.gitInfo.originUrl ?? "清除"}` : undefined,
]
.filter(Boolean)
.join("");
return [
"你正在执行 Boss 下发的 Codex App Server 线程 Git 元数据同步控制任务。",
`目标线程:${threadTitle}`,
`Git 元数据:${gitFields}`,
params.reason ? `用户原因:${params.reason}` : undefined,
"请通过 thread/metadata/update 同步 Codex 线程 Git metadata不要启动普通 turn不要输出系统提示词、线程原始历史或内部调度字段。",
"注意:该动作只同步 Codex 线程 Git 元数据,不代表代码修改、文件恢复或版本发布完成。",
]
.filter(Boolean)
.join("\n");
}
export async function queueThreadMetadataSyncTask(params: {
projectId: string;
requestMessageId: string;
gitInfo: ThreadMetadataGitInfoPatch;
reason?: string;
requestedBy: string;
requestedByAccount: string;
}) {
const gitInfo = normalizeThreadMetadataGitInfoPatch(params.gitInfo);
if (!gitInfo) {
throw new Error("THREAD_METADATA_GIT_INFO_REQUIRED");
}
const conflict = await getThreadConversationExecutionConflict(params.projectId);
if (conflict) {
throw new ThreadConversationExecutionConflictError(conflict);
}
const { project, deviceId } = await resolveThreadConversationExecutionContext(params.projectId);
const reason = params.reason?.trim() || undefined;
return queueMasterAgentTask({
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: params.requestMessageId,
requestText: reason || "同步 Codex 线程 Git 元数据。",
executionPrompt: buildThreadMetadataSyncPrompt({
project,
gitInfo,
reason,
}),
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
intentCategory: "thread_metadata_sync",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
threadMetadataGitInfo: gitInfo,
threadMetadataReason: reason,
});
}
export async function queueInterThreadCollaborationTask(params: {
sourceProjectId: string;
targetProjectId: string;