feishu-codex-console 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +101 -0
- package/.feishu-codex-policy.example.json +11 -0
- package/.feishu-codex-runbooks.example.json +36 -0
- package/CHANGELOG.md +129 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +81 -0
- package/README.md +398 -0
- package/ROADMAP.md +40 -0
- package/SECURITY.md +53 -0
- package/dist/account-quota-card.js +233 -0
- package/dist/account-quota.js +125 -0
- package/dist/app-server-client.js +281 -0
- package/dist/card-session.js +166 -0
- package/dist/codex-events.js +1 -0
- package/dist/codex-runner.js +875 -0
- package/dist/config.js +198 -0
- package/dist/confirmation-card.js +135 -0
- package/dist/control-card.js +345 -0
- package/dist/conversation-turn-session.js +209 -0
- package/dist/data-maintenance.js +71 -0
- package/dist/device-card.js +460 -0
- package/dist/device-health.js +94 -0
- package/dist/diagnostics.js +253 -0
- package/dist/doctor.js +250 -0
- package/dist/fallback-card-session.js +37 -0
- package/dist/health-file.js +75 -0
- package/dist/index.js +4330 -0
- package/dist/lark-cli.js +558 -0
- package/dist/lark-retry.js +34 -0
- package/dist/maintenance.js +140 -0
- package/dist/model-capabilities.js +31 -0
- package/dist/onboarding-card.js +312 -0
- package/dist/permission-lease.js +22 -0
- package/dist/policy.js +506 -0
- package/dist/progress.js +267 -0
- package/dist/project-card.js +303 -0
- package/dist/project-overview-card.js +182 -0
- package/dist/project-overview.js +278 -0
- package/dist/project-policy.js +160 -0
- package/dist/project-registry.js +259 -0
- package/dist/project-status.js +45 -0
- package/dist/project-workspace.js +55 -0
- package/dist/quota-card.js +94 -0
- package/dist/recovery-policy.js +26 -0
- package/dist/redaction.js +67 -0
- package/dist/remote-ready.js +112 -0
- package/dist/response-card.js +139 -0
- package/dist/result-card.js +166 -0
- package/dist/review-card.js +452 -0
- package/dist/runbook-card.js +272 -0
- package/dist/runbooks.js +191 -0
- package/dist/runtime-card.js +337 -0
- package/dist/session-card.js +128 -0
- package/dist/session-naming.js +14 -0
- package/dist/smoke.js +28 -0
- package/dist/state-backup.js +302 -0
- package/dist/state-store.js +874 -0
- package/dist/task-card.js +640 -0
- package/dist/task-center-card.js +176 -0
- package/dist/task-failure.js +43 -0
- package/dist/task-intent.js +76 -0
- package/dist/task-queue.js +187 -0
- package/dist/task-reconciliation.js +80 -0
- package/dist/task-review.js +497 -0
- package/dist/team-card.js +275 -0
- package/dist/team-directory.js +54 -0
- package/dist/team-policy.js +93 -0
- package/dist/types.js +1 -0
- package/dist/version.js +9 -0
- package/dist/workspace-session.js +64 -0
- package/docs/ARCHITECTURE.md +54 -0
- package/docs/COMPATIBILITY.md +55 -0
- package/docs/CONFIGURATION.md +88 -0
- package/docs/DEMO.md +45 -0
- package/docs/GOOD_FIRST_ISSUES.md +23 -0
- package/docs/INSTALLATION.md +207 -0
- package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
- package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
- package/docs/RELEASE_CHECKLIST.md +65 -0
- package/docs/TEAM_DEPLOYMENT.md +35 -0
- package/docs/TROUBLESHOOTING.md +130 -0
- package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
- package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
- package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
- package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
- package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
- package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
- package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
- package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
- package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
- package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
- package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
- package/package.json +76 -0
- package/scripts/capability-probe.mjs +113 -0
- package/scripts/cli.mjs +919 -0
- package/scripts/config-file.mjs +137 -0
- package/scripts/discovery-lib.mjs +78 -0
- package/scripts/install-card.mjs +37 -0
- package/scripts/install-detection.mjs +126 -0
- package/scripts/install-state.mjs +107 -0
- package/scripts/launchd.mjs +161 -0
- package/scripts/migrate-legacy.mjs +97 -0
- package/scripts/package-smoke.mjs +163 -0
- package/scripts/release-dist-tag.mjs +7 -0
- package/scripts/runbook-template.mjs +36 -0
- package/scripts/service-health.mjs +110 -0
- package/scripts/service.mjs +24 -0
- package/scripts/setup-lib.mjs +118 -0
- package/scripts/systemd.mjs +96 -0
- package/scripts/upgrade-lib.mjs +99 -0
- package/scripts/verify-release.mjs +37 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4330 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { arch, hostname, platform, release } from "node:os";
|
|
4
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
5
|
+
import { TaskCardSession } from "./card-session.js";
|
|
6
|
+
import { unavailableAccountQuota } from "./account-quota.js";
|
|
7
|
+
import { formatAccountQuotaText } from "./account-quota-card.js";
|
|
8
|
+
import { ConversationTurnSession } from "./conversation-turn-session.js";
|
|
9
|
+
import { CodexCancelledError, CodexPolicyViolationError, CodexRunner, } from "./codex-runner.js";
|
|
10
|
+
import { renderExternalConfirmationCard } from "./confirmation-card.js";
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { reasoningEffortLabel, renderControlCenterCard, } from "./control-card.js";
|
|
13
|
+
import { renderDeviceCard, } from "./device-card.js";
|
|
14
|
+
import { deriveDeviceAvailability } from "./device-health.js";
|
|
15
|
+
import { FallbackCardSession } from "./fallback-card-session.js";
|
|
16
|
+
import { eventSummary, LarkCli, logRef } from "./lark-cli.js";
|
|
17
|
+
import { readBridgeHealth, removeBridgeHealth, writeBridgeHealth, } from "./health-file.js";
|
|
18
|
+
import { cleanupAttachmentCache, isSupportedImageFile, isUtf8TextFile, trimRuntimeLogs, } from "./maintenance.js";
|
|
19
|
+
import { compatibleModelSettings, resolveModelPreference, } from "./model-capabilities.js";
|
|
20
|
+
import { renderOnboardingCard, } from "./onboarding-card.js";
|
|
21
|
+
import { FULL_ACCESS_ONCE_TTL_MINUTES, FULL_ACCESS_SESSION_TTL_MINUTES, FULL_ACCESS_TIMED_TTL_MINUTES, permissionLeaseLabel, persistentSandboxMode, } from "./permission-lease.js";
|
|
22
|
+
import { classifyCommand, externalActionLabel, externalActionsForPrompt, HELP_TEXT, isAttachmentMessageType, isAuthorized, isAuthorizedCardAction, isConfirmedProjectSwitch, isTextualMessageType, normalizePrompt, parseCardActionEvent, parseFeishuEvent, projectSelector, queuedPrompt, settingsChange, steerPrompt, } from "./policy.js";
|
|
23
|
+
import { applyCodexEvent, attachTaskReview, cancelTask as cancelTaskProgress, createTaskProgress, failTask, interruptTask as interruptTaskProgress, noteSteer, recoverTask as recoverTaskProgress, startTask, succeedTask, testEvidenceState, updateTaskCollaboration, updateQueuePosition, } from "./progress.js";
|
|
24
|
+
import { renderProjectCard } from "./project-card.js";
|
|
25
|
+
import { renderProjectOverviewCard, renderProjectOverviewText, } from "./project-overview-card.js";
|
|
26
|
+
import { readProjectOverview } from "./project-overview.js";
|
|
27
|
+
import { applyProjectSandboxMaximum, decideProjectActions, defaultProjectPolicy, loadProjectPolicy, projectPolicySummary, ProjectPolicyError, } from "./project-policy.js";
|
|
28
|
+
import { readProjectGitStatus } from "./project-status.js";
|
|
29
|
+
import { ProjectRegistry, } from "./project-registry.js";
|
|
30
|
+
import { rankProjectWorkspace, resolveProjectSelector } from "./project-workspace.js";
|
|
31
|
+
import { readPowerStatus, RemoteReadyController } from "./remote-ready.js";
|
|
32
|
+
import { renderQuotaCard } from "./quota-card.js";
|
|
33
|
+
import { assessBridgeRecovery, shouldSendRecoveryNotice } from "./recovery-policy.js";
|
|
34
|
+
import { installConsoleRedaction, redactSensitiveText, safeErrorText, } from "./redaction.js";
|
|
35
|
+
import { renderTaskResultCard } from "./result-card.js";
|
|
36
|
+
import { renderReviewCard, } from "./review-card.js";
|
|
37
|
+
import { renderRunbookCenterCard, } from "./runbook-card.js";
|
|
38
|
+
import { loadProjectRunbooks, parseRunbookInvocation, renderRunbookPrompt, } from "./runbooks.js";
|
|
39
|
+
import { renderSessionCenterCard } from "./session-card.js";
|
|
40
|
+
import { sessionNameFromPrompt } from "./session-naming.js";
|
|
41
|
+
import { StateStore } from "./state-store.js";
|
|
42
|
+
import { renderRuntimeApprovalCard, renderRuntimeQuestionCard, } from "./runtime-card.js";
|
|
43
|
+
import { QueueCapacityError, TaskQueue } from "./task-queue.js";
|
|
44
|
+
import { captureTaskReview, captureTaskReviewBaseline, readTaskFileDiff, } from "./task-review.js";
|
|
45
|
+
import { reconcilePersistedTask } from "./task-reconciliation.js";
|
|
46
|
+
import { memberLabel, memberSelector, operatingTeamMembers, resolveMemberSelector, teamMemberOptionLabel, teamMembers, } from "./team-directory.js";
|
|
47
|
+
import { renderTeamDashboardCard, } from "./team-card.js";
|
|
48
|
+
import { renderTaskCenterCard, } from "./task-center-card.js";
|
|
49
|
+
import { inferTaskMode, runningActivity, sandboxForTaskMode, taskModeAllowsWrites, taskModeCapturesReview, taskModeLabel, taskModeOf, } from "./task-intent.js";
|
|
50
|
+
import { canAccessProject, canAdminister, canControlOwnedResource, canControlTask, canTakeOverTask, canViewOwnedResource, canViewTask, canOperate, conversationKeyForCard, conversationKeyForEvent, isSandboxModeAllowed, roleForSender, roleLabel, selectableSandboxModes, visibleProjects, } from "./team-policy.js";
|
|
51
|
+
import { PACKAGE_ROOT, PACKAGE_VERSION } from "./version.js";
|
|
52
|
+
import { isTopicConversationKey, workspaceSessionForEvent, workspaceSessionForPrompt, } from "./workspace-session.js";
|
|
53
|
+
installConsoleRedaction();
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
const state = new StateStore(config.databaseFile, config.maxSeenEvents, config.stateFile);
|
|
56
|
+
const queue = new TaskQueue(config.maxConcurrentTasks, config.maxQueuedPerConversation);
|
|
57
|
+
const runner = new CodexRunner(config);
|
|
58
|
+
const remoteReady = new RemoteReadyController();
|
|
59
|
+
const projectRegistry = new ProjectRegistry(config.projectRoots, config.workdir, config.projectScanDepth, config.maxProjects, config.syncSavedProjects ? config.codexProjectStateFile : undefined);
|
|
60
|
+
const lark = new LarkCli({
|
|
61
|
+
binary: config.larkCliPath,
|
|
62
|
+
cwd: config.projectDir,
|
|
63
|
+
maxReplyChars: config.maxReplyChars,
|
|
64
|
+
});
|
|
65
|
+
const shutdownController = new AbortController();
|
|
66
|
+
const bridgeStartedAt = new Date().toISOString();
|
|
67
|
+
let healthTimer = null;
|
|
68
|
+
let ownsHealthMarker = false;
|
|
69
|
+
const tasks = new Map();
|
|
70
|
+
const projectCards = new Map();
|
|
71
|
+
const deviceCards = new Map();
|
|
72
|
+
const controlCards = new Map();
|
|
73
|
+
const sessionCards = new Map();
|
|
74
|
+
const taskCenterCards = new Map();
|
|
75
|
+
const teamCards = new Map();
|
|
76
|
+
const runbookCards = new Map();
|
|
77
|
+
const onboardingCards = new Map();
|
|
78
|
+
const reviewCards = new Map();
|
|
79
|
+
const pendingConfirmations = new Map();
|
|
80
|
+
const pendingRuntimeApprovals = new Map();
|
|
81
|
+
const pendingRuntimeQuestions = new Map();
|
|
82
|
+
const pendingQuestionByConversation = new Map();
|
|
83
|
+
const inFlightEvents = new Set();
|
|
84
|
+
const runtimeHealth = {
|
|
85
|
+
startedAt: Date.now(),
|
|
86
|
+
lastCardActionAt: 0,
|
|
87
|
+
completedTasks: 0,
|
|
88
|
+
failedTasks: 0,
|
|
89
|
+
blockedCommands: 0,
|
|
90
|
+
};
|
|
91
|
+
let maintenanceTimer = null;
|
|
92
|
+
let outboxTimer = null;
|
|
93
|
+
let outboxReplayPromise = null;
|
|
94
|
+
const taskPersistTimers = new Map();
|
|
95
|
+
const TEXT_ATTACHMENT_EXTENSIONS = new Set([
|
|
96
|
+
".txt",
|
|
97
|
+
".md",
|
|
98
|
+
".json",
|
|
99
|
+
".jsonl",
|
|
100
|
+
".yaml",
|
|
101
|
+
".yml",
|
|
102
|
+
".toml",
|
|
103
|
+
".ini",
|
|
104
|
+
".csv",
|
|
105
|
+
".log",
|
|
106
|
+
".ts",
|
|
107
|
+
".tsx",
|
|
108
|
+
".js",
|
|
109
|
+
".jsx",
|
|
110
|
+
".mjs",
|
|
111
|
+
".cjs",
|
|
112
|
+
".py",
|
|
113
|
+
".go",
|
|
114
|
+
".rs",
|
|
115
|
+
".java",
|
|
116
|
+
".kt",
|
|
117
|
+
".swift",
|
|
118
|
+
".css",
|
|
119
|
+
".scss",
|
|
120
|
+
".html",
|
|
121
|
+
".xml",
|
|
122
|
+
".sql",
|
|
123
|
+
".sh",
|
|
124
|
+
]);
|
|
125
|
+
function key(event) {
|
|
126
|
+
return conversationKeyForEvent(event, config);
|
|
127
|
+
}
|
|
128
|
+
async function preparePromptWorkspaceSession(event, ownerId) {
|
|
129
|
+
const session = workspaceSessionForPrompt(event, config);
|
|
130
|
+
if (session.conversationKey === session.baseConversationKey ||
|
|
131
|
+
state.getProject(session.conversationKey)) {
|
|
132
|
+
return session;
|
|
133
|
+
}
|
|
134
|
+
const project = await currentProject(session.baseConversationKey, ownerId);
|
|
135
|
+
await state.setProject(session.conversationKey, project.path);
|
|
136
|
+
const preferences = state.getPreferences(session.baseConversationKey);
|
|
137
|
+
if (preferences) {
|
|
138
|
+
const { updatedAt: _updatedAt, ...copy } = preferences;
|
|
139
|
+
await state.setPreferences(session.conversationKey, copy);
|
|
140
|
+
}
|
|
141
|
+
await audit(ownerId, "session.topic_create", "session", session.conversationKey, "allowed", project.name);
|
|
142
|
+
return session;
|
|
143
|
+
}
|
|
144
|
+
function cardKey(event) {
|
|
145
|
+
return conversationKeyForCard(event.chat_id, event.operator_id, state.getChatType(event.chat_id), config);
|
|
146
|
+
}
|
|
147
|
+
function onboardingStateKey(conversationKey, ownerId) {
|
|
148
|
+
return `${conversationKey}::onboarding::${ownerId}`;
|
|
149
|
+
}
|
|
150
|
+
function makeTaskId(seed) {
|
|
151
|
+
const clean = seed.replace(/[^a-zA-Z0-9]/g, "").slice(-8);
|
|
152
|
+
const fallback = Date.now().toString(36).slice(-8);
|
|
153
|
+
let candidate = clean || fallback;
|
|
154
|
+
let suffix = 1;
|
|
155
|
+
while (tasks.has(candidate)) {
|
|
156
|
+
candidate = `${(clean || fallback).slice(0, 6)}${suffix.toString(36).padStart(2, "0")}`;
|
|
157
|
+
suffix += 1;
|
|
158
|
+
}
|
|
159
|
+
return candidate;
|
|
160
|
+
}
|
|
161
|
+
function replyKey(seed, phase) {
|
|
162
|
+
return `${seed}-${phase}`;
|
|
163
|
+
}
|
|
164
|
+
async function reply(event, text, phase) {
|
|
165
|
+
const session = workspaceSessionForEvent(event, config);
|
|
166
|
+
const idempotencyKey = replyKey(event.event_id, phase);
|
|
167
|
+
try {
|
|
168
|
+
await lark.replyMarkdown(event.message_id, redactSensitiveText(text), idempotencyKey, session.replyInThread);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
console.warn(`[bridge] natural reply unavailable phase=${phase}; using text fallback`, error);
|
|
172
|
+
await durableReply(event.message_id, text, `${idempotencyKey}-text-fallback`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function productReply(messageId, text, idempotencyKey, phase) {
|
|
176
|
+
try {
|
|
177
|
+
return await FallbackCardSession.create(lark, messageId, text, phase, idempotencyKey);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
console.warn(`[bridge] product reply card unavailable phase=${phase}; using text fallback`, error);
|
|
181
|
+
await durableReply(messageId, text, `${idempotencyKey}-text-fallback`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function updateTaskFallbackCard(record, text, phase, idempotencyKey) {
|
|
186
|
+
if (record.fallbackCard && await record.fallbackCard.update(text, phase))
|
|
187
|
+
return;
|
|
188
|
+
record.fallbackCard = await productReply(record.replyToMessageId, text, idempotencyKey, phase);
|
|
189
|
+
}
|
|
190
|
+
function taskLiveSession(record) {
|
|
191
|
+
return record.card ?? record.conversation;
|
|
192
|
+
}
|
|
193
|
+
async function notifyTaskFallback(record, text, phase, idempotencyKey) {
|
|
194
|
+
if (record.conversation) {
|
|
195
|
+
await durableReply(record.replyToMessageId, text, idempotencyKey);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await updateTaskFallbackCard(record, text, phase, idempotencyKey);
|
|
199
|
+
}
|
|
200
|
+
async function durableReply(messageId, text, idempotencyKey) {
|
|
201
|
+
const safeText = redactSensitiveText(text);
|
|
202
|
+
const outboxId = createHash("sha256")
|
|
203
|
+
.update(`${messageId}\u0000${idempotencyKey}`)
|
|
204
|
+
.digest("hex");
|
|
205
|
+
const now = new Date().toISOString();
|
|
206
|
+
await state.enqueueOutbox({
|
|
207
|
+
id: outboxId,
|
|
208
|
+
kind: "reply_text",
|
|
209
|
+
payload: { messageId, text: safeText, idempotencyKey },
|
|
210
|
+
attempts: 0,
|
|
211
|
+
createdAt: now,
|
|
212
|
+
nextAttemptAt: now,
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
await deliverTextFallback(messageId, safeText, idempotencyKey);
|
|
216
|
+
await state.markOutboxSent(outboxId);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
await state.markOutboxFailed(outboxId, safeErrorText(error));
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// This is the only direct text-delivery gateway. Product replies must try the
|
|
224
|
+
// shared Card 2.0 renderer first and reach this function only as a reliability fallback.
|
|
225
|
+
async function deliverTextFallback(messageId, text, idempotencyKey) {
|
|
226
|
+
await lark.reply(messageId, redactSensitiveText(text), idempotencyKey);
|
|
227
|
+
}
|
|
228
|
+
async function replyOnboardingCard(event, onboarding, feedback = "", phase = "onboarding", context) {
|
|
229
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
230
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
231
|
+
const snapshot = await buildOnboardingSnapshot(onboarding, conversationKey, ownerId, feedback);
|
|
232
|
+
try {
|
|
233
|
+
const cardId = await lark.createCard(renderOnboardingCard(snapshot));
|
|
234
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
235
|
+
if (messageId) {
|
|
236
|
+
onboardingCards.set(messageId, {
|
|
237
|
+
cardId,
|
|
238
|
+
conversationKey,
|
|
239
|
+
ownerId,
|
|
240
|
+
sequence: 0,
|
|
241
|
+
createdAt: Date.now(),
|
|
242
|
+
});
|
|
243
|
+
pruneControlCards(onboardingCards);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
console.error("[bridge] onboarding card unavailable; falling back to text", error);
|
|
248
|
+
await productReply(event.message_id, formatOnboardingSnapshot(snapshot), replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function buildOnboardingSnapshot(onboarding, conversationKey, ownerId, feedback = "") {
|
|
252
|
+
let projectName = "尚未授权项目";
|
|
253
|
+
let projectAvailable = false;
|
|
254
|
+
try {
|
|
255
|
+
projectName = (await currentProject(conversationKey, ownerId)).name;
|
|
256
|
+
projectAvailable = true;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
console.warn(`[bridge] onboarding project unavailable: ${error.message}`);
|
|
260
|
+
}
|
|
261
|
+
const settings = executionSettings(conversationKey, ownerId);
|
|
262
|
+
const consumers = lark.getConsumerHealth();
|
|
263
|
+
const role = roleForSender(config, ownerId) ?? "viewer";
|
|
264
|
+
return {
|
|
265
|
+
role,
|
|
266
|
+
state: onboarding,
|
|
267
|
+
projectName,
|
|
268
|
+
projectAvailable,
|
|
269
|
+
modelLabel: settings.model ?? "Codex 默认",
|
|
270
|
+
sandboxLabel: permissionLabel(settings.sandboxMode),
|
|
271
|
+
canWrite: role !== "viewer" && settings.sandboxMode !== "read-only",
|
|
272
|
+
deviceOnline: consumers.length === 2 && consumers.every((consumer) => consumer.ready),
|
|
273
|
+
groupChatEnabled: config.allowedChatIds.size > 0,
|
|
274
|
+
...(feedback ? { feedback } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function formatOnboardingSnapshot(snapshot) {
|
|
278
|
+
if (snapshot.state.status === "dismissed") {
|
|
279
|
+
return "新手引导已暂停。需要时发送“新手引导”或 /start 即可重新打开。";
|
|
280
|
+
}
|
|
281
|
+
if (!snapshot.deviceOnline) {
|
|
282
|
+
return "本地 Codex 正在重新连接。发送“控制台”查看连接状态,恢复后重新发送刚才的话即可。";
|
|
283
|
+
}
|
|
284
|
+
if (!snapshot.projectAvailable) {
|
|
285
|
+
return "开始前先发送“项目”,从已授权项目中选择一个工作区。";
|
|
286
|
+
}
|
|
287
|
+
if (snapshot.role === "viewer") {
|
|
288
|
+
return [
|
|
289
|
+
"欢迎使用 Codex",
|
|
290
|
+
`当前项目:${snapshot.projectName} · 只读`,
|
|
291
|
+
"你可以查看项目、历史会话和任务状态;需要执行工作时请联系管理员调整角色。",
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
return [
|
|
295
|
+
snapshot.state.status === "completed" ? "Codex 已准备好" : "欢迎使用 Codex",
|
|
296
|
+
`当前项目:${snapshot.projectName} · ${snapshot.sandboxLabel}`,
|
|
297
|
+
"直接发送一句完整要求即可,例如:“这个项目是做什么的?”或“运行测试并修复失败用例”。",
|
|
298
|
+
"继续同一件事就回复上一条消息;新事情发一条新消息。",
|
|
299
|
+
snapshot.groupChatEnabled
|
|
300
|
+
? "团队协作时,一个项目建一个群。"
|
|
301
|
+
: "团队协作时,一个项目建一个群;团队群可以以后再开。",
|
|
302
|
+
].join("\n");
|
|
303
|
+
}
|
|
304
|
+
async function openOnboarding(event, phase = "onboarding") {
|
|
305
|
+
const conversationKey = key(event);
|
|
306
|
+
const onboarding = await state.setOnboarding(onboardingStateKey(conversationKey, event.sender_id), event.sender_id, "active", 1);
|
|
307
|
+
await replyOnboardingCard(event, onboarding, "", phase);
|
|
308
|
+
await audit(event.sender_id, "onboarding.open", "onboarding", conversationKey, "allowed");
|
|
309
|
+
}
|
|
310
|
+
async function maybeShowFirstRunOnboarding(event, conversationKey, ownerId) {
|
|
311
|
+
if (event.chat_type !== "p2p")
|
|
312
|
+
return;
|
|
313
|
+
const stateKey = onboardingStateKey(conversationKey, ownerId);
|
|
314
|
+
if (!config.autoOnboarding || state.getOnboarding(stateKey))
|
|
315
|
+
return;
|
|
316
|
+
try {
|
|
317
|
+
const onboarding = await state.setOnboarding(stateKey, ownerId, "active", 1);
|
|
318
|
+
await replyOnboardingCard(event, onboarding, "", "auto-onboarding");
|
|
319
|
+
await audit(ownerId, "onboarding.auto_open", "onboarding", conversationKey, "allowed");
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error("[bridge] unable to show first-run onboarding", error);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function replyProjectCard(event, current, feedback = "", phase = "projects", context) {
|
|
326
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
327
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
328
|
+
const workspace = await buildProjectWorkspace(conversationKey, ownerId, current);
|
|
329
|
+
try {
|
|
330
|
+
const cardId = await lark.createCard(renderProjectCard(workspace.projects, current, feedback, workspace.context));
|
|
331
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
332
|
+
if (messageId) {
|
|
333
|
+
const record = {
|
|
334
|
+
cardId,
|
|
335
|
+
conversationKey,
|
|
336
|
+
ownerId,
|
|
337
|
+
sequence: 0,
|
|
338
|
+
createdAt: Date.now(),
|
|
339
|
+
};
|
|
340
|
+
projectCards.set(messageId, record);
|
|
341
|
+
await state.upsertProjectCard({ messageId, ...record });
|
|
342
|
+
pruneProjectCards();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
console.error("[bridge] project card unavailable; falling back to text", error);
|
|
347
|
+
const prefix = feedback ? `${feedback}\n\n` : "";
|
|
348
|
+
await productReply(event.message_id, `${prefix}${formatProjectList(current, workspace.projects)}`, replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function replyProjectOverview(event, phase = "project-overview", context) {
|
|
352
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
353
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
354
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
355
|
+
const snapshot = await readProjectOverview(project);
|
|
356
|
+
try {
|
|
357
|
+
const cardId = await lark.createCard(renderProjectOverviewCard(snapshot));
|
|
358
|
+
await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
console.error("[bridge] project overview card unavailable; falling back to text", error);
|
|
362
|
+
await productReply(event.message_id, renderProjectOverviewText(snapshot), replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
363
|
+
}
|
|
364
|
+
await state.recordProjectUse(ownerId, project.path);
|
|
365
|
+
await audit(ownerId, "project.overview", "project", project.path, "allowed", "local snapshot");
|
|
366
|
+
}
|
|
367
|
+
async function buildProjectWorkspace(conversationKey, ownerId, current) {
|
|
368
|
+
const visible = visibleProjects(config, ownerId, projectRegistry.list());
|
|
369
|
+
const ranked = rankProjectWorkspace(visible, state.listProjectUsage(ownerId));
|
|
370
|
+
const gitStatus = current.isGitRepository
|
|
371
|
+
? await readProjectGitStatus(current.path)
|
|
372
|
+
: null;
|
|
373
|
+
let policyLabel;
|
|
374
|
+
try {
|
|
375
|
+
policyLabel = projectPolicySummary(await loadProjectPolicy(current.path));
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
policyLabel = `策略错误 · ${error.message}`;
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
projects: ranked.projects,
|
|
382
|
+
context: {
|
|
383
|
+
gitStatus,
|
|
384
|
+
favoritePaths: ranked.favoritePaths,
|
|
385
|
+
recentPaths: ranked.recentPaths,
|
|
386
|
+
policyLabel,
|
|
387
|
+
canSwitch: canOperate(config, ownerId),
|
|
388
|
+
activeTasks: runner.getActiveTask(conversationKey) ? 1 : 0,
|
|
389
|
+
queuedTasks: queue.queuedForConversation(conversationKey),
|
|
390
|
+
hasSavedThread: Boolean(state.getThread(conversationKey)),
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async function replyDeviceCard(event, feedback = "", phase = "device", context) {
|
|
395
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
396
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
397
|
+
const snapshot = await buildDeviceSnapshot(conversationKey, ownerId, feedback);
|
|
398
|
+
try {
|
|
399
|
+
const cardId = await lark.createCard(renderDeviceCard(snapshot));
|
|
400
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
401
|
+
if (messageId) {
|
|
402
|
+
const record = {
|
|
403
|
+
cardId,
|
|
404
|
+
conversationKey,
|
|
405
|
+
ownerId,
|
|
406
|
+
sequence: 0,
|
|
407
|
+
createdAt: Date.now(),
|
|
408
|
+
};
|
|
409
|
+
deviceCards.set(messageId, record);
|
|
410
|
+
await state.upsertDeviceCard({ messageId, ...record });
|
|
411
|
+
pruneDeviceCards();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
console.error("[bridge] device card unavailable; falling back to text", error);
|
|
416
|
+
await productReply(event.message_id, formatDeviceSnapshot(snapshot), replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function replyQuotaCard(event, feedback = "", phase = "quota", context) {
|
|
420
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
421
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
422
|
+
const quota = await readAccountQuota();
|
|
423
|
+
const snapshot = {
|
|
424
|
+
deviceName: hostname() || "本地设备",
|
|
425
|
+
quota,
|
|
426
|
+
...(feedback ? { feedback } : {}),
|
|
427
|
+
};
|
|
428
|
+
try {
|
|
429
|
+
const cardId = await lark.createCard(renderQuotaCard(snapshot));
|
|
430
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
431
|
+
if (messageId) {
|
|
432
|
+
const record = {
|
|
433
|
+
cardId,
|
|
434
|
+
conversationKey,
|
|
435
|
+
ownerId,
|
|
436
|
+
sequence: 0,
|
|
437
|
+
createdAt: Date.now(),
|
|
438
|
+
};
|
|
439
|
+
deviceCards.set(messageId, record);
|
|
440
|
+
await state.upsertDeviceCard({ messageId, ...record });
|
|
441
|
+
pruneDeviceCards();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
console.error("[bridge] quota card unavailable; falling back to text", error);
|
|
446
|
+
await productReply(event.message_id, ["Codex 账户额度", ...formatAccountQuotaText(quota)].join("\n"), replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async function replyControlCenter(event, feedback = "", phase = "settings", context) {
|
|
450
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
451
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
452
|
+
const snapshot = await buildControlSnapshot(conversationKey, ownerId, feedback);
|
|
453
|
+
try {
|
|
454
|
+
const cardId = await lark.createCard(renderControlCenterCard(snapshot));
|
|
455
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
456
|
+
if (messageId) {
|
|
457
|
+
controlCards.set(messageId, {
|
|
458
|
+
cardId,
|
|
459
|
+
conversationKey,
|
|
460
|
+
ownerId,
|
|
461
|
+
sequence: 0,
|
|
462
|
+
createdAt: Date.now(),
|
|
463
|
+
});
|
|
464
|
+
pruneControlCards(controlCards);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
console.error("[bridge] control center card unavailable", error);
|
|
469
|
+
await productReply(event.message_id, `${feedback ? `${feedback}\n\n` : ""}${formatControlSnapshot(snapshot)}`, replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function buildControlSnapshot(conversationKey, ownerId, feedback = "") {
|
|
473
|
+
const [models, accountQuota] = await Promise.all([
|
|
474
|
+
runner.listModels().catch((error) => {
|
|
475
|
+
console.warn(`[bridge] unable to list Codex models: ${safeErrorText(error)}`);
|
|
476
|
+
return [];
|
|
477
|
+
}),
|
|
478
|
+
readAccountQuota(),
|
|
479
|
+
]);
|
|
480
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
481
|
+
let projectPolicy = defaultProjectPolicy();
|
|
482
|
+
let policyNotice = "";
|
|
483
|
+
try {
|
|
484
|
+
projectPolicy = await loadProjectPolicy(project.path);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
projectPolicy = { ...defaultProjectPolicy(), maximumSandbox: "read-only" };
|
|
488
|
+
policyNotice = `仓库策略无效,任务已安全降为只读:${error.message}`;
|
|
489
|
+
}
|
|
490
|
+
const requestedSettings = executionSettings(conversationKey, ownerId);
|
|
491
|
+
requestedSettings.sandboxMode = applyProjectSandboxMaximum(requestedSettings.sandboxMode, projectPolicy);
|
|
492
|
+
const compatibility = compatibleModelSettings(requestedSettings, models);
|
|
493
|
+
const settings = compatibility.settings;
|
|
494
|
+
const maximumSandbox = applyProjectSandboxMaximum(maxSandboxForActor(ownerId), projectPolicy);
|
|
495
|
+
const fullAccessLease = maximumSandbox === "danger-full-access"
|
|
496
|
+
? state.getPermissionLease(conversationKey, ownerId)
|
|
497
|
+
: undefined;
|
|
498
|
+
const selectedModel = settings.model ?? "__default__";
|
|
499
|
+
const selected = compatibility.effectiveModel;
|
|
500
|
+
const selectedEffort = settings.reasoningEffort ?? selected?.defaultReasoningEffort ?? config.reasoningEffort ?? "medium";
|
|
501
|
+
return {
|
|
502
|
+
projectName: project.name,
|
|
503
|
+
role: roleForSender(config, ownerId) ?? "viewer",
|
|
504
|
+
models,
|
|
505
|
+
selectedModel,
|
|
506
|
+
selectedEffort,
|
|
507
|
+
selectedSandbox: settings.sandboxMode,
|
|
508
|
+
sandboxModes: selectableSandboxModes(persistentSandboxMode(undefined, maximumSandbox)),
|
|
509
|
+
modelCatalogAvailable: models.length > 0,
|
|
510
|
+
accountQuota,
|
|
511
|
+
fullAccessMaximum: maximumSandbox === "danger-full-access",
|
|
512
|
+
...(fullAccessLease
|
|
513
|
+
? { fullAccessLeaseLabel: permissionLeaseLabel(fullAccessLease) }
|
|
514
|
+
: {}),
|
|
515
|
+
fullAccessSessionAvailable: Boolean(state.getThread(conversationKey)),
|
|
516
|
+
...([feedback, policyNotice, ...compatibility.notices].filter(Boolean).length > 0
|
|
517
|
+
? {
|
|
518
|
+
feedback: [feedback, policyNotice, ...compatibility.notices]
|
|
519
|
+
.filter(Boolean)
|
|
520
|
+
.join(";"),
|
|
521
|
+
}
|
|
522
|
+
: {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function formatControlSnapshot(snapshot) {
|
|
526
|
+
return [
|
|
527
|
+
"Codex 控制中心",
|
|
528
|
+
`- 项目:${snapshot.projectName}`,
|
|
529
|
+
`- 身份:${roleLabel(snapshot.role)}`,
|
|
530
|
+
`- 模型:${snapshot.selectedModel === "__default__" ? "Codex 默认" : snapshot.selectedModel}`,
|
|
531
|
+
`- 推理:${reasoningEffortLabel(snapshot.selectedEffort)}`,
|
|
532
|
+
`- 权限:${permissionLabel(snapshot.selectedSandbox)}`,
|
|
533
|
+
...(snapshot.accountQuota ? formatAccountQuotaText(snapshot.accountQuota) : []),
|
|
534
|
+
...(snapshot.fullAccessLeaseLabel
|
|
535
|
+
? [`- 临时完全访问:${snapshot.fullAccessLeaseLabel}`]
|
|
536
|
+
: []),
|
|
537
|
+
"发送“模型 <名称>”“推理 high”或“权限 只读”也可以直接切换。",
|
|
538
|
+
].join("\n");
|
|
539
|
+
}
|
|
540
|
+
async function replySessionCenter(event, feedback = "", phase = "sessions", context) {
|
|
541
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
542
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
543
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
544
|
+
const currentThreadId = state.getThread(conversationKey);
|
|
545
|
+
const sessions = visibleSessions(ownerId, project.path, currentThreadId, await runner.listThreads(project.path, 50)).slice(0, 12);
|
|
546
|
+
const snapshot = {
|
|
547
|
+
projectName: project.name,
|
|
548
|
+
sessions,
|
|
549
|
+
...(currentThreadId ? { currentThreadId } : {}),
|
|
550
|
+
...(feedback ? { feedback } : {}),
|
|
551
|
+
};
|
|
552
|
+
try {
|
|
553
|
+
const cardId = await lark.createCard(renderSessionCenterCard(snapshot));
|
|
554
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
555
|
+
if (messageId) {
|
|
556
|
+
sessionCards.set(messageId, {
|
|
557
|
+
cardId,
|
|
558
|
+
conversationKey,
|
|
559
|
+
ownerId,
|
|
560
|
+
sequence: 0,
|
|
561
|
+
createdAt: Date.now(),
|
|
562
|
+
});
|
|
563
|
+
pruneControlCards(sessionCards);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
console.error("[bridge] session center card unavailable", error);
|
|
568
|
+
const lines = sessions.slice(0, 10).map((session, index) => `${index + 1}. ${session.name || session.preview || session.id.slice(0, 8)}\n ${session.id}`);
|
|
569
|
+
await productReply(event.message_id, [feedback, `最近会话(${sessions.length})`, ...lines].filter(Boolean).join("\n"), replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function replyTaskCenter(event, phase = "tasks", context) {
|
|
573
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
574
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
575
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
576
|
+
const snapshot = buildTaskCenterSnapshot(ownerId, project);
|
|
577
|
+
try {
|
|
578
|
+
const cardId = await lark.createCard(renderTaskCenterCard(snapshot));
|
|
579
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
580
|
+
if (messageId) {
|
|
581
|
+
taskCenterCards.set(messageId, {
|
|
582
|
+
cardId,
|
|
583
|
+
conversationKey,
|
|
584
|
+
ownerId,
|
|
585
|
+
sequence: 0,
|
|
586
|
+
createdAt: Date.now(),
|
|
587
|
+
});
|
|
588
|
+
pruneControlCards(taskCenterCards);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
console.error("[bridge] task center card unavailable", error);
|
|
593
|
+
await productReply(event.message_id, [
|
|
594
|
+
`任务中心 · 运行 ${snapshot.running} · 排队 ${snapshot.queued}`,
|
|
595
|
+
...snapshot.tasks
|
|
596
|
+
.slice(0, 10)
|
|
597
|
+
.map((task) => `- [${task.status}] ${task.project.name} · ${task.prompt.slice(0, 80)}`),
|
|
598
|
+
].join("\n"), replyKey(event.event_id, `${phase}-fallback`), `${phase}-fallback`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async function replyTeamDashboard(event, feedback = "", phase = "team", context) {
|
|
602
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
603
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
604
|
+
const snapshot = buildTeamDashboardSnapshot(ownerId, feedback);
|
|
605
|
+
try {
|
|
606
|
+
const cardId = await lark.createCard(renderTeamDashboardCard(snapshot));
|
|
607
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
608
|
+
if (messageId) {
|
|
609
|
+
teamCards.set(messageId, {
|
|
610
|
+
cardId,
|
|
611
|
+
conversationKey,
|
|
612
|
+
ownerId,
|
|
613
|
+
sequence: 0,
|
|
614
|
+
createdAt: Date.now(),
|
|
615
|
+
});
|
|
616
|
+
pruneControlCards(teamCards);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
console.error("[bridge] team dashboard card unavailable", error);
|
|
621
|
+
await productReply(event.message_id, [
|
|
622
|
+
`团队工作台 · 活跃 ${snapshot.activeTasks} · 排队 ${snapshot.queuedTasks}`,
|
|
623
|
+
`- 可见成员:${snapshot.members.length}`,
|
|
624
|
+
`- 最近完成:${snapshot.completedTasks}`,
|
|
625
|
+
`- 输入 / 输出:${snapshot.inputTokens} / ${snapshot.outputTokens} tokens`,
|
|
626
|
+
].join("\n"), replyKey(event.event_id, `${phase}-fallback`), "team-dashboard-fallback");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function replyRunbookCenter(event, feedback = "", phase = "runbooks", context) {
|
|
630
|
+
const ownerId = context?.ownerId ?? actorForEvent(event);
|
|
631
|
+
const conversationKey = context?.conversationKey ?? conversationForEvent(event);
|
|
632
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
633
|
+
const catalog = await loadProjectRunbooks(project.path);
|
|
634
|
+
const snapshot = {
|
|
635
|
+
projectName: project.name,
|
|
636
|
+
catalog,
|
|
637
|
+
canOperate: canOperate(config, ownerId),
|
|
638
|
+
...(feedback ? { feedback } : {}),
|
|
639
|
+
};
|
|
640
|
+
try {
|
|
641
|
+
const cardId = await lark.createCard(renderRunbookCenterCard(snapshot));
|
|
642
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, phase));
|
|
643
|
+
if (messageId) {
|
|
644
|
+
runbookCards.set(messageId, {
|
|
645
|
+
cardId,
|
|
646
|
+
conversationKey,
|
|
647
|
+
ownerId,
|
|
648
|
+
sequence: 0,
|
|
649
|
+
createdAt: Date.now(),
|
|
650
|
+
});
|
|
651
|
+
pruneControlCards(runbookCards);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
console.error("[bridge] runbook center card unavailable", error);
|
|
656
|
+
await productReply(event.message_id, catalog.status === "ready"
|
|
657
|
+
? [
|
|
658
|
+
`团队运行手册 · ${project.name}`,
|
|
659
|
+
...catalog.runbooks.map((runbook) => `- ${runbook.name}:/run ${runbook.id}`),
|
|
660
|
+
].join("\n")
|
|
661
|
+
: catalog.status === "missing"
|
|
662
|
+
? `当前项目还没有 ${catalog.file.split("/").at(-1)}。`
|
|
663
|
+
: `运行手册配置无效:${catalog.error ?? "未知错误"}`, replyKey(event.event_id, `${phase}-fallback`), "runbook-center-fallback");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async function launchRunbook(replyToMessageId, conversationKey, ownerId, seed, runbook, values = {}) {
|
|
667
|
+
const prompt = renderRunbookPrompt(runbook, values);
|
|
668
|
+
if (externalActionsForPrompt(prompt).length > 0) {
|
|
669
|
+
throw new Error("运行手册参数产生了提交、推送、部署或 PR 动作;请把它作为普通任务发送并单独确认。");
|
|
670
|
+
}
|
|
671
|
+
const settings = {
|
|
672
|
+
...(runbook.model ? { model: runbook.model } : {}),
|
|
673
|
+
...(runbook.reasoningEffort ? { reasoningEffort: runbook.reasoningEffort } : {}),
|
|
674
|
+
...(runbook.sandboxMode ? { sandboxMode: runbook.sandboxMode } : {}),
|
|
675
|
+
};
|
|
676
|
+
const taskId = await enqueuePrompt(replyToMessageId, conversationKey, ownerId, prompt, seed, [], [], {
|
|
677
|
+
runbook: { id: runbook.id, name: runbook.name },
|
|
678
|
+
settings,
|
|
679
|
+
});
|
|
680
|
+
await audit(ownerId, "runbook.execute", "task", taskId, "allowed", runbook.id);
|
|
681
|
+
return taskId;
|
|
682
|
+
}
|
|
683
|
+
async function replyReviewCard(event, task, feedback = "", phase = "task-review", page = 0, selectedFileIndex, diffPage = 0) {
|
|
684
|
+
if (!task.progress.review)
|
|
685
|
+
await refreshTaskReview(task);
|
|
686
|
+
const embedded = Boolean(task.card &&
|
|
687
|
+
task.card.messageId === event.message_id &&
|
|
688
|
+
task.progress.phase !== "queued" &&
|
|
689
|
+
task.progress.phase !== "running");
|
|
690
|
+
const snapshot = await buildReviewCardSnapshot(task, page, selectedFileIndex, feedback, diffPage, embedded);
|
|
691
|
+
try {
|
|
692
|
+
if (embedded && task.card) {
|
|
693
|
+
const updated = await task.card.showSurface(renderReviewCard(snapshot), "review-surface");
|
|
694
|
+
if (!updated)
|
|
695
|
+
throw new Error("unable to update the task card review surface");
|
|
696
|
+
reviewCards.set(event.message_id, {
|
|
697
|
+
cardId: task.card.cardId,
|
|
698
|
+
conversationKey: task.conversationKey,
|
|
699
|
+
ownerId: task.ownerId,
|
|
700
|
+
taskId: task.id,
|
|
701
|
+
page: snapshot.page,
|
|
702
|
+
...(snapshot.selectedFileIndex === undefined
|
|
703
|
+
? {}
|
|
704
|
+
: { selectedFileIndex: snapshot.selectedFileIndex }),
|
|
705
|
+
...(snapshot.diffPage === undefined ? {} : { diffPage: snapshot.diffPage }),
|
|
706
|
+
sequence: task.card.sequenceNumber,
|
|
707
|
+
createdAt: Date.now(),
|
|
708
|
+
embedded: true,
|
|
709
|
+
});
|
|
710
|
+
pruneControlCards(reviewCards);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const cardId = await lark.createCard(renderReviewCard(snapshot));
|
|
714
|
+
const messageId = await lark.replyCard(event.message_id, cardId, replyKey(event.event_id, `${phase}-${task.id}`));
|
|
715
|
+
if (messageId) {
|
|
716
|
+
reviewCards.set(messageId, {
|
|
717
|
+
cardId,
|
|
718
|
+
conversationKey: task.conversationKey,
|
|
719
|
+
ownerId: task.ownerId,
|
|
720
|
+
taskId: task.id,
|
|
721
|
+
page: snapshot.page,
|
|
722
|
+
...(snapshot.selectedFileIndex === undefined
|
|
723
|
+
? {}
|
|
724
|
+
: { selectedFileIndex: snapshot.selectedFileIndex }),
|
|
725
|
+
...(snapshot.diffPage === undefined ? {} : { diffPage: snapshot.diffPage }),
|
|
726
|
+
sequence: 0,
|
|
727
|
+
createdAt: Date.now(),
|
|
728
|
+
});
|
|
729
|
+
pruneControlCards(reviewCards);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
console.error(`[bridge] review card unavailable task=${task.id}`, error);
|
|
734
|
+
const review = snapshot.review;
|
|
735
|
+
await productReply(event.message_id, [
|
|
736
|
+
`代码审阅 · ${task.project.name}`,
|
|
737
|
+
`- 文件:${review.totalFiles}`,
|
|
738
|
+
`- 行数:+${review.totalAdditions} / -${review.totalDeletions}`,
|
|
739
|
+
`- 归因:${review.attribution === "task" ? "任务基线清晰" : "需要回本机确认"}`,
|
|
740
|
+
`- 测试:${reviewTestLabel(snapshot.commandRuns)}`,
|
|
741
|
+
...review.files.slice(0, 20).map((file) => `- ${file.kind} ${file.path}`),
|
|
742
|
+
].join("\n"), replyKey(event.event_id, `${phase}-${task.id}-fallback`), `${phase}-fallback`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async function buildReviewCardSnapshot(task, requestedPage = 0, selectedFileIndex, feedback = "", requestedDiffPage = 0, embedded = false) {
|
|
746
|
+
const review = task.progress.review ?? await captureTaskReview(task.project.path, task.progress.reviewBaseline, task.progress.changedFiles);
|
|
747
|
+
const pageSize = 5;
|
|
748
|
+
const lastPage = Math.max(0, Math.ceil(review.files.length / pageSize) - 1);
|
|
749
|
+
const page = Math.min(Math.max(0, requestedPage), lastPage);
|
|
750
|
+
const selected = selectedFileIndex === undefined ? undefined : review.files[selectedFileIndex];
|
|
751
|
+
const fileDiff = selected
|
|
752
|
+
? await readTaskFileDiff(task.project.path, task.progress.reviewBaseline, selected)
|
|
753
|
+
: undefined;
|
|
754
|
+
const diffPageSize = 7_000;
|
|
755
|
+
const lastDiffPage = fileDiff
|
|
756
|
+
? Math.max(0, Math.ceil(fileDiff.content.length / diffPageSize) - 1)
|
|
757
|
+
: 0;
|
|
758
|
+
const diffPage = Math.min(Math.max(0, requestedDiffPage), lastDiffPage);
|
|
759
|
+
return {
|
|
760
|
+
taskId: task.id,
|
|
761
|
+
projectLabel: task.project.name,
|
|
762
|
+
prompt: task.prompt,
|
|
763
|
+
phase: task.progress.phase,
|
|
764
|
+
review,
|
|
765
|
+
commandRuns: task.progress.commandRuns ?? [],
|
|
766
|
+
page,
|
|
767
|
+
pageSize,
|
|
768
|
+
...(selected && selectedFileIndex !== undefined ? { selectedFileIndex } : {}),
|
|
769
|
+
...(fileDiff ? { fileDiff } : {}),
|
|
770
|
+
...(fileDiff ? { diffPage, diffPageSize } : {}),
|
|
771
|
+
...(feedback ? { feedback } : {}),
|
|
772
|
+
...(embedded ? { embedded: true } : {}),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function reviewTestLabel(commandRuns) {
|
|
776
|
+
const tests = commandRuns.filter((run) => run.category === "test");
|
|
777
|
+
const state = testEvidenceState(tests);
|
|
778
|
+
if (state === "missing")
|
|
779
|
+
return "未检测到测试命令";
|
|
780
|
+
if (state === "failed")
|
|
781
|
+
return "存在未通过测试";
|
|
782
|
+
if (state === "running")
|
|
783
|
+
return "测试仍在运行";
|
|
784
|
+
return "测试已通过";
|
|
785
|
+
}
|
|
786
|
+
function buildTaskCenterSnapshot(ownerId, currentProject) {
|
|
787
|
+
const isAdmin = canAdminister(config, ownerId);
|
|
788
|
+
const persisted = new Map(state.listTasks().map((task) => [task.id, task]));
|
|
789
|
+
for (const record of tasks.values()) {
|
|
790
|
+
const saved = persisted.get(record.id);
|
|
791
|
+
if (!saved)
|
|
792
|
+
continue;
|
|
793
|
+
persisted.set(record.id, {
|
|
794
|
+
...saved,
|
|
795
|
+
controllerId: record.controllerId,
|
|
796
|
+
status: record.status,
|
|
797
|
+
progress: structuredClone(record.progress),
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const scoped = [...persisted.values()].filter((task) => isAdmin
|
|
801
|
+
? task.project.path === currentProject.path
|
|
802
|
+
: (task.ownerId === ownerId || task.controllerId === ownerId) &&
|
|
803
|
+
canAccessProject(config, ownerId, task.project));
|
|
804
|
+
const sorted = scoped.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
805
|
+
return {
|
|
806
|
+
scopeLabel: isAdmin
|
|
807
|
+
? `${currentProject.name} · 团队任务`
|
|
808
|
+
: "我发起或控制的授权项目任务",
|
|
809
|
+
tasks: sorted.slice(0, 20),
|
|
810
|
+
running: scoped.filter((task) => task.status === "running").length,
|
|
811
|
+
queued: scoped.filter((task) => task.status === "queued").length,
|
|
812
|
+
canOperate: canOperate(config, ownerId),
|
|
813
|
+
people: Object.fromEntries(scoped.map((task) => [
|
|
814
|
+
task.id,
|
|
815
|
+
{
|
|
816
|
+
initiator: memberLabel(config, task.ownerId),
|
|
817
|
+
controller: memberLabel(config, task.controllerId),
|
|
818
|
+
},
|
|
819
|
+
])),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function buildTeamDashboardSnapshot(actorId, feedback = "") {
|
|
823
|
+
const admin = canAdminister(config, actorId);
|
|
824
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60_000;
|
|
825
|
+
const persisted = new Map(state.listTasks().map((task) => [task.id, task]));
|
|
826
|
+
for (const record of tasks.values()) {
|
|
827
|
+
const saved = persisted.get(record.id);
|
|
828
|
+
if (!saved)
|
|
829
|
+
continue;
|
|
830
|
+
persisted.set(record.id, {
|
|
831
|
+
...saved,
|
|
832
|
+
controllerId: record.controllerId,
|
|
833
|
+
status: record.status,
|
|
834
|
+
progress: structuredClone(record.progress),
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
const recent = [...persisted.values()].filter((task) => {
|
|
838
|
+
const updatedAt = Date.parse(task.updatedAt);
|
|
839
|
+
if (!Number.isFinite(updatedAt) || updatedAt < cutoff)
|
|
840
|
+
return false;
|
|
841
|
+
if (admin)
|
|
842
|
+
return true;
|
|
843
|
+
return ((task.ownerId === actorId || task.controllerId === actorId) &&
|
|
844
|
+
canAccessProject(config, actorId, task.project));
|
|
845
|
+
});
|
|
846
|
+
const visibleMembers = teamMembers(config).filter((member) => admin || member.id === actorId);
|
|
847
|
+
const members = visibleMembers.map((member) => {
|
|
848
|
+
const initiated = recent.filter((task) => task.ownerId === member.id);
|
|
849
|
+
const controlled = recent.filter((task) => task.controllerId === member.id);
|
|
850
|
+
const active = recent.filter((task) => (task.ownerId === member.id || task.controllerId === member.id) &&
|
|
851
|
+
(task.status === "running" || task.status === "queued"));
|
|
852
|
+
return {
|
|
853
|
+
label: member.label,
|
|
854
|
+
role: member.role,
|
|
855
|
+
tasks: initiated.length,
|
|
856
|
+
active: active.length,
|
|
857
|
+
controlled: controlled.length,
|
|
858
|
+
succeeded: initiated.filter((task) => task.status === "succeeded").length,
|
|
859
|
+
failed: initiated.filter((task) => task.status === "failed").length,
|
|
860
|
+
inputTokens: initiated.reduce((total, task) => total + (task.progress.usage?.input_tokens ?? 0), 0),
|
|
861
|
+
outputTokens: initiated.reduce((total, task) => total + (task.progress.usage?.output_tokens ?? 0), 0),
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
const projectGroups = new Map();
|
|
865
|
+
for (const task of recent) {
|
|
866
|
+
const current = projectGroups.get(task.project.path) ?? {
|
|
867
|
+
label: task.project.name,
|
|
868
|
+
tasks: 0,
|
|
869
|
+
active: 0,
|
|
870
|
+
members: new Set(),
|
|
871
|
+
};
|
|
872
|
+
current.tasks += 1;
|
|
873
|
+
if (task.status === "running" || task.status === "queued")
|
|
874
|
+
current.active += 1;
|
|
875
|
+
current.members.add(task.ownerId);
|
|
876
|
+
projectGroups.set(task.project.path, current);
|
|
877
|
+
}
|
|
878
|
+
const projects = [...projectGroups.values()]
|
|
879
|
+
.map((project) => ({
|
|
880
|
+
label: project.label,
|
|
881
|
+
tasks: project.tasks,
|
|
882
|
+
active: project.active,
|
|
883
|
+
members: project.members.size,
|
|
884
|
+
}))
|
|
885
|
+
.sort((left, right) => right.active - left.active || right.tasks - left.tasks);
|
|
886
|
+
const succeeded = recent.filter((task) => task.status === "succeeded").length;
|
|
887
|
+
const failed = recent.filter((task) => task.status === "failed").length;
|
|
888
|
+
const completedTasks = succeeded + failed;
|
|
889
|
+
return {
|
|
890
|
+
scopeLabel: admin
|
|
891
|
+
? `${config.instanceId} · 授权团队汇总`
|
|
892
|
+
: `${config.instanceId} · 我的协作概览`,
|
|
893
|
+
periodLabel: "最近 7 天",
|
|
894
|
+
members,
|
|
895
|
+
projects,
|
|
896
|
+
activeTasks: recent.filter((task) => task.status === "running").length,
|
|
897
|
+
queuedTasks: recent.filter((task) => task.status === "queued").length,
|
|
898
|
+
completedTasks,
|
|
899
|
+
...(completedTasks > 0 ? { successRate: succeeded / completedTasks } : {}),
|
|
900
|
+
inputTokens: recent.reduce((total, task) => total + (task.progress.usage?.input_tokens ?? 0), 0),
|
|
901
|
+
outputTokens: recent.reduce((total, task) => total + (task.progress.usage?.output_tokens ?? 0), 0),
|
|
902
|
+
canAdminister: admin,
|
|
903
|
+
...(feedback ? { feedback } : {}),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
async function buildDeviceSnapshot(conversationKey, ownerId, feedback = "") {
|
|
907
|
+
const [project, power, accountQuota] = await Promise.all([
|
|
908
|
+
currentProject(conversationKey, ownerId),
|
|
909
|
+
readPowerStatus(),
|
|
910
|
+
readAccountQuota(),
|
|
911
|
+
]);
|
|
912
|
+
const appServer = runner.getHealth();
|
|
913
|
+
const consumers = lark.getConsumerHealth();
|
|
914
|
+
const listenerReady = consumers.length === 2 && consumers.every((consumer) => consumer.ready);
|
|
915
|
+
const listenerRestarts = consumers.reduce((total, consumer) => total + consumer.restartCount, 0);
|
|
916
|
+
const remote = remoteReady.getStatus();
|
|
917
|
+
const activeTask = runner.getActiveTask(conversationKey);
|
|
918
|
+
const sampledAt = new Date().toISOString();
|
|
919
|
+
const lastSuccessfulTaskAt = state
|
|
920
|
+
.listTasks()
|
|
921
|
+
.filter((task) => task.ownerId === ownerId && task.status === "succeeded")
|
|
922
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.updatedAt;
|
|
923
|
+
const codexState = appServer.ready
|
|
924
|
+
? "online"
|
|
925
|
+
: appServer.lastError
|
|
926
|
+
? "error"
|
|
927
|
+
: "standby";
|
|
928
|
+
return {
|
|
929
|
+
deviceName: hostname() || "本地 Mac",
|
|
930
|
+
osLabel: operatingSystemLabel(),
|
|
931
|
+
uptimeLabel: formatDuration(Date.now() - runtimeHealth.startedAt),
|
|
932
|
+
availability: deriveDeviceAvailability({
|
|
933
|
+
consumers,
|
|
934
|
+
codex: appServer,
|
|
935
|
+
api: lark.getApiHealth(),
|
|
936
|
+
sampledAt,
|
|
937
|
+
...(lastSuccessfulTaskAt ? { lastSuccessfulTaskAt } : {}),
|
|
938
|
+
}),
|
|
939
|
+
project: {
|
|
940
|
+
name: project.name,
|
|
941
|
+
displayPath: project.displayPath,
|
|
942
|
+
isGitRepository: project.isGitRepository,
|
|
943
|
+
},
|
|
944
|
+
codex: {
|
|
945
|
+
state: codexState,
|
|
946
|
+
restartCount: appServer.restartCount,
|
|
947
|
+
...(appServer.pid !== undefined ? { pid: appServer.pid } : {}),
|
|
948
|
+
...(appServer.lastError ? { lastError: appServer.lastError } : {}),
|
|
949
|
+
},
|
|
950
|
+
listener: {
|
|
951
|
+
ready: listenerReady,
|
|
952
|
+
restartCount: listenerRestarts,
|
|
953
|
+
},
|
|
954
|
+
remoteReady: remote,
|
|
955
|
+
powerLabel: power.label,
|
|
956
|
+
...(activeTask ? { activeTask } : {}),
|
|
957
|
+
queuedForConversation: queue.queuedForConversation(conversationKey),
|
|
958
|
+
activeTasks: queue.activeCount,
|
|
959
|
+
queuedTasks: queue.pendingCount,
|
|
960
|
+
maxConcurrentTasks: config.maxConcurrentTasks,
|
|
961
|
+
threadCount: state.threadCount,
|
|
962
|
+
sandboxLabel: permissionLabel(executionSettings(conversationKey, ownerId).sandboxMode),
|
|
963
|
+
networkEnabled: config.networkAccessEnabled,
|
|
964
|
+
accountQuota,
|
|
965
|
+
...(feedback ? { feedback } : {}),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
function actorForEvent(event) {
|
|
969
|
+
return event.type === "im.message.receive_v1" ? event.sender_id : event.operator_id;
|
|
970
|
+
}
|
|
971
|
+
function conversationForEvent(event) {
|
|
972
|
+
return event.type === "im.message.receive_v1" ? key(event) : cardKey(event);
|
|
973
|
+
}
|
|
974
|
+
function formatDeviceSnapshot(snapshot) {
|
|
975
|
+
const codex = snapshot.codex.state === "online"
|
|
976
|
+
? "在线"
|
|
977
|
+
: snapshot.codex.state === "error"
|
|
978
|
+
? "异常"
|
|
979
|
+
: "待命";
|
|
980
|
+
return [
|
|
981
|
+
`本地 Codex 控制台 · ${snapshot.deviceName}`,
|
|
982
|
+
`- 设备状态:${snapshot.availability.title}`,
|
|
983
|
+
`- 状态说明:${snapshot.availability.detail}`,
|
|
984
|
+
`- 下一步:${snapshot.availability.nextAction}`,
|
|
985
|
+
`- 状态采样:${new Date(snapshot.availability.sampledAt).toLocaleString("zh-CN", { hour12: false })}`,
|
|
986
|
+
`- 本人最后成功任务:${snapshot.availability.lastSuccessfulTaskAt ? new Date(snapshot.availability.lastSuccessfulTaskAt).toLocaleString("zh-CN", { hour12: false }) : "暂无"}`,
|
|
987
|
+
`- 飞书连接:${snapshot.listener.ready ? "在线" : "重连中"}`,
|
|
988
|
+
`- Codex 引擎:${codex}`,
|
|
989
|
+
`- 远程就绪:${snapshot.remoteReady.active ? "已开启" : "未开启"}`,
|
|
990
|
+
`- 电源:${snapshot.powerLabel}`,
|
|
991
|
+
`- 当前项目:${snapshot.project.name}`,
|
|
992
|
+
`- 当前任务:${snapshot.activeTask ?? "无"}`,
|
|
993
|
+
`- 当前聊天排队:${snapshot.queuedForConversation}`,
|
|
994
|
+
`- 全局任务:运行 ${snapshot.activeTasks} / ${snapshot.maxConcurrentTasks} · 排队 ${snapshot.queuedTasks}`,
|
|
995
|
+
`- 权限:${snapshot.sandboxLabel} · 网络${snapshot.networkEnabled ? "开启" : "关闭"}`,
|
|
996
|
+
...(snapshot.accountQuota ? formatAccountQuotaText(snapshot.accountQuota) : []),
|
|
997
|
+
...(snapshot.feedback ? [`- 操作结果:${snapshot.feedback}`] : []),
|
|
998
|
+
].join("\n");
|
|
999
|
+
}
|
|
1000
|
+
async function readAccountQuota() {
|
|
1001
|
+
try {
|
|
1002
|
+
return await runner.readAccountQuota();
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
console.warn(`[bridge] unable to read Codex account quota: ${safeErrorText(error)}`);
|
|
1006
|
+
return unavailableAccountQuota();
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
function operatingSystemLabel() {
|
|
1010
|
+
const system = platform() === "darwin" ? "macOS" : platform();
|
|
1011
|
+
return `${system} ${release()} · ${arch()}`;
|
|
1012
|
+
}
|
|
1013
|
+
async function enqueuePrompt(replyToMessageId, conversationKey, ownerId, prompt, seed, attachments = [], allowedExternalActions = [], launchOptions = {}) {
|
|
1014
|
+
const existing = [...tasks.values()].find((task) => task.seed === seed && task.conversationKey === conversationKey);
|
|
1015
|
+
if (existing)
|
|
1016
|
+
return existing.id;
|
|
1017
|
+
if (!queue.canEnqueue(conversationKey))
|
|
1018
|
+
throw new QueueCapacityError(conversationKey);
|
|
1019
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
1020
|
+
const projectPolicy = await loadProjectPolicy(project.path);
|
|
1021
|
+
const actionDecision = decideProjectActions(projectPolicy, allowedExternalActions);
|
|
1022
|
+
if (actionDecision.blockedActions.length > 0) {
|
|
1023
|
+
const blocked = actionDecision.blockedActions.map(externalActionLabel).join("、");
|
|
1024
|
+
await audit(ownerId, "project_policy.external_action", "project", project.path, "denied", actionDecision.blockedActions.join(","));
|
|
1025
|
+
throw new ProjectPolicyError(`当前仓库策略禁止:${blocked}`);
|
|
1026
|
+
}
|
|
1027
|
+
await state.recordProjectUse(ownerId, project.path);
|
|
1028
|
+
const requestedSettings = executionSettings(conversationKey, ownerId);
|
|
1029
|
+
if (launchOptions.settings?.model)
|
|
1030
|
+
requestedSettings.model = launchOptions.settings.model;
|
|
1031
|
+
if (launchOptions.settings?.reasoningEffort) {
|
|
1032
|
+
requestedSettings.reasoningEffort = launchOptions.settings.reasoningEffort;
|
|
1033
|
+
}
|
|
1034
|
+
if (launchOptions.settings?.sandboxMode &&
|
|
1035
|
+
isSandboxModeAllowed(launchOptions.settings.sandboxMode, requestedSettings.sandboxMode)) {
|
|
1036
|
+
requestedSettings.sandboxMode = launchOptions.settings.sandboxMode;
|
|
1037
|
+
}
|
|
1038
|
+
requestedSettings.sandboxMode = applyProjectSandboxMaximum(requestedSettings.sandboxMode, projectPolicy);
|
|
1039
|
+
let availableModels = [];
|
|
1040
|
+
try {
|
|
1041
|
+
availableModels = await runner.listModels();
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
console.warn(`[bridge] unable to verify task model capabilities: ${error.message}`);
|
|
1045
|
+
}
|
|
1046
|
+
const compatibility = compatibleModelSettings(requestedSettings, availableModels);
|
|
1047
|
+
const settings = compatibility.settings;
|
|
1048
|
+
const taskMode = inferTaskMode(prompt);
|
|
1049
|
+
settings.sandboxMode = sandboxForTaskMode(taskMode, settings.sandboxMode);
|
|
1050
|
+
if (compatibility.notices.length > 0) {
|
|
1051
|
+
await audit(ownerId, "settings.compatibility_fallback", "settings", conversationKey, "allowed", compatibility.notices.join("; ").slice(0, 500));
|
|
1052
|
+
}
|
|
1053
|
+
const id = makeTaskId(seed);
|
|
1054
|
+
const estimatedPosition = queue.nextPosition(conversationKey);
|
|
1055
|
+
const progress = createTaskProgress(id, prompt, estimatedPosition, project.name, Date.now(), permissionLabel(settings.sandboxMode), {
|
|
1056
|
+
taskMode,
|
|
1057
|
+
modelLabel: compatibility.effectiveModel?.displayName ?? settings.model ?? "Codex 默认",
|
|
1058
|
+
reasoningLabel: settings.reasoningEffort
|
|
1059
|
+
? reasoningEffortLabel(settings.reasoningEffort)
|
|
1060
|
+
: "默认推理",
|
|
1061
|
+
sessionLabel: state.getThread(conversationKey) ? "继续当前会话" : "新会话",
|
|
1062
|
+
...(launchOptions.runbook ? { runbookLabel: launchOptions.runbook.name } : {}),
|
|
1063
|
+
...taskCollaborationContext(ownerId, ownerId, project, conversationKey),
|
|
1064
|
+
});
|
|
1065
|
+
let card = null;
|
|
1066
|
+
let conversation = null;
|
|
1067
|
+
let fallbackCard = null;
|
|
1068
|
+
const replyInThread = launchOptions.replyInThread ?? false;
|
|
1069
|
+
const conversational = taskMode === "answer" || taskMode === "analyze";
|
|
1070
|
+
if (conversational) {
|
|
1071
|
+
try {
|
|
1072
|
+
conversation = await ConversationTurnSession.create(lark, progress, replyToMessageId, replyKey(seed, `conversation-${id}`), replyInThread);
|
|
1073
|
+
}
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
console.error(`[bridge] natural conversation unavailable for task=${id}; using task card`, error);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (!conversation) {
|
|
1079
|
+
try {
|
|
1080
|
+
card = await TaskCardSession.create(lark, progress, replyToMessageId, replyKey(seed, `card-${id}`), replyInThread);
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
console.error(`[bridge] CardKit unavailable for task=${id}; falling back to text`, error);
|
|
1084
|
+
fallbackCard = await productReply(replyToMessageId, `已接收任务 ${id},当前队列位置 ${estimatedPosition}。`, replyKey(seed, `accepted-${id}`), "task-accepted");
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const record = {
|
|
1088
|
+
id,
|
|
1089
|
+
conversationKey,
|
|
1090
|
+
ownerId,
|
|
1091
|
+
controllerId: ownerId,
|
|
1092
|
+
prompt,
|
|
1093
|
+
project,
|
|
1094
|
+
status: "queued",
|
|
1095
|
+
card,
|
|
1096
|
+
conversation,
|
|
1097
|
+
fallbackCard,
|
|
1098
|
+
progress,
|
|
1099
|
+
replyToMessageId,
|
|
1100
|
+
seed,
|
|
1101
|
+
attachments,
|
|
1102
|
+
allowedExternalActions,
|
|
1103
|
+
settings,
|
|
1104
|
+
allowThreadBinding: true,
|
|
1105
|
+
replyInThread,
|
|
1106
|
+
};
|
|
1107
|
+
if (card) {
|
|
1108
|
+
card.onSnapshot((snapshot) => {
|
|
1109
|
+
record.progress = snapshot.progress;
|
|
1110
|
+
record.status = snapshot.progress.phase;
|
|
1111
|
+
schedulePersistTaskRecord(record);
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
if (conversation) {
|
|
1115
|
+
conversation.onSnapshot((snapshot) => {
|
|
1116
|
+
record.progress = snapshot.progress;
|
|
1117
|
+
record.status = snapshot.progress.phase;
|
|
1118
|
+
schedulePersistTaskRecord(record);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
tasks.set(id, record);
|
|
1122
|
+
await persistTaskRecord(record);
|
|
1123
|
+
await audit(ownerId, "task.enqueue", "task", id, "allowed", `${project.name}; mode=${taskMode}`);
|
|
1124
|
+
pruneTaskRecords();
|
|
1125
|
+
try {
|
|
1126
|
+
scheduleTaskRecord(record);
|
|
1127
|
+
}
|
|
1128
|
+
catch (error) {
|
|
1129
|
+
record.status = "failed";
|
|
1130
|
+
const session = taskLiveSession(record);
|
|
1131
|
+
if (session) {
|
|
1132
|
+
await session.finishFailed("当前会话的任务队列已满,请稍后再试。");
|
|
1133
|
+
record.progress = session.progress;
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
record.progress = failTask(record.progress, "当前会话的任务队列已满,请稍后再试。");
|
|
1137
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 未能排队:当前会话的任务队列已满,请稍后再试。`, "task-queue-full", replyKey(record.seed, `queue-full-${record.id}`));
|
|
1138
|
+
}
|
|
1139
|
+
await persistTaskRecord(record);
|
|
1140
|
+
throw error;
|
|
1141
|
+
}
|
|
1142
|
+
return id;
|
|
1143
|
+
}
|
|
1144
|
+
function scheduleTaskRecord(record) {
|
|
1145
|
+
queue.enqueue({
|
|
1146
|
+
id: record.id,
|
|
1147
|
+
conversationKey: record.conversationKey,
|
|
1148
|
+
resourceKey: record.project.path,
|
|
1149
|
+
onCancel: async () => {
|
|
1150
|
+
if (record.status !== "queued")
|
|
1151
|
+
return;
|
|
1152
|
+
record.status = "cancelled";
|
|
1153
|
+
const session = taskLiveSession(record);
|
|
1154
|
+
if (session) {
|
|
1155
|
+
await session.finishCancelled("排队期间收到停止请求");
|
|
1156
|
+
record.progress = session.progress;
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
record.progress = cancelTaskProgress(record.progress, "排队期间收到停止请求");
|
|
1160
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 已取消:排队期间收到停止请求。`, "task-cancelled", replyKey(record.seed, `cancelled-${record.id}`));
|
|
1161
|
+
}
|
|
1162
|
+
await persistTaskRecord(record);
|
|
1163
|
+
},
|
|
1164
|
+
onPositionChange: async (queuePosition) => {
|
|
1165
|
+
if (record.status !== "queued")
|
|
1166
|
+
return;
|
|
1167
|
+
const session = taskLiveSession(record);
|
|
1168
|
+
if (session) {
|
|
1169
|
+
await session.updateQueuePosition(queuePosition);
|
|
1170
|
+
record.progress = session.progress;
|
|
1171
|
+
}
|
|
1172
|
+
else {
|
|
1173
|
+
record.progress = updateQueuePosition(record.progress, queuePosition);
|
|
1174
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 排队中,当前队列位置 ${queuePosition}。`, "task-queued", replyKey(record.seed, `queued-${record.id}-${queuePosition}`));
|
|
1175
|
+
}
|
|
1176
|
+
await persistTaskRecord(record);
|
|
1177
|
+
},
|
|
1178
|
+
run: () => executeTaskRecord(record),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
async function executeTaskRecord(record) {
|
|
1182
|
+
record.status = "running";
|
|
1183
|
+
try {
|
|
1184
|
+
const taskMode = taskModeOf(record.progress);
|
|
1185
|
+
const existingThreadId = state.getThread(record.conversationKey);
|
|
1186
|
+
const projectPolicy = await loadProjectPolicy(record.project.path);
|
|
1187
|
+
const actionDecision = decideProjectActions(projectPolicy, record.allowedExternalActions);
|
|
1188
|
+
if (actionDecision.blockedActions.length > 0) {
|
|
1189
|
+
throw new ProjectPolicyError(`当前仓库策略禁止:${actionDecision.blockedActions.map(externalActionLabel).join("、")}`);
|
|
1190
|
+
}
|
|
1191
|
+
const maximumSandbox = applyProjectSandboxMaximum(maxSandboxForActor(record.ownerId), projectPolicy);
|
|
1192
|
+
record.settings.sandboxMode = persistentSandboxMode(record.settings.sandboxMode, maximumSandbox);
|
|
1193
|
+
record.settings.sandboxMode = sandboxForTaskMode(taskMode, record.settings.sandboxMode);
|
|
1194
|
+
const allowsWrites = taskModeAllowsWrites(taskMode);
|
|
1195
|
+
const permissionLease = allowsWrites && maximumSandbox === "danger-full-access"
|
|
1196
|
+
? state.consumePermissionLease(record.conversationKey, record.ownerId, record.project.path, existingThreadId)
|
|
1197
|
+
: undefined;
|
|
1198
|
+
if (maximumSandbox !== "danger-full-access") {
|
|
1199
|
+
state.revokePermissionLease(record.conversationKey);
|
|
1200
|
+
}
|
|
1201
|
+
if (permissionLease) {
|
|
1202
|
+
record.settings.sandboxMode = "danger-full-access";
|
|
1203
|
+
await audit(record.ownerId, "permission_lease.consume", "task", record.id, "allowed", permissionLease.scope);
|
|
1204
|
+
}
|
|
1205
|
+
const reviewBaseline = taskModeCapturesReview(taskMode)
|
|
1206
|
+
? await captureTaskReviewBaseline(record.project.path, record.project.isGitRepository)
|
|
1207
|
+
: undefined;
|
|
1208
|
+
const taskPermissionLabel = permissionLease
|
|
1209
|
+
? "完全访问(临时)"
|
|
1210
|
+
: permissionLabel(record.settings.sandboxMode);
|
|
1211
|
+
const liveSession = taskLiveSession(record);
|
|
1212
|
+
if (liveSession) {
|
|
1213
|
+
await liveSession.markRunning(reviewBaseline, taskPermissionLabel);
|
|
1214
|
+
record.progress = liveSession.progress;
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
record.progress = startTask(record.progress, Date.now(), reviewBaseline, taskPermissionLabel);
|
|
1218
|
+
await updateTaskFallbackCard(record, `${taskModeLabel(taskMode)} ${record.id} 正在进行 · ${record.project.name}\n${runningActivity(taskMode)}。`, "task-running", replyKey(record.seed, `running-${record.id}`));
|
|
1219
|
+
}
|
|
1220
|
+
await persistTaskRecord(record);
|
|
1221
|
+
const result = await runner.run(record.conversationKey, record.id, record.prompt, record.project.path, record.project.isGitRepository, existingThreadId, (event) => {
|
|
1222
|
+
const session = taskLiveSession(record);
|
|
1223
|
+
if (session)
|
|
1224
|
+
session.handleCodexEvent(event);
|
|
1225
|
+
else
|
|
1226
|
+
record.progress = applyCodexEvent(record.progress, event);
|
|
1227
|
+
if (event.type === "thread.started") {
|
|
1228
|
+
void rememberActiveThread(record, event.thread_id).catch((error) => {
|
|
1229
|
+
console.error(`[bridge] unable to persist active thread task=${record.id}`, error);
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}, record.attachments, record.allowedExternalActions, {
|
|
1233
|
+
requestApproval: (request) => requestRuntimeApproval(record, request),
|
|
1234
|
+
requestUserInput: (request) => requestRuntimeUserInput(record, request),
|
|
1235
|
+
}, record.settings);
|
|
1236
|
+
if (!existingThreadId) {
|
|
1237
|
+
try {
|
|
1238
|
+
await runner.nameThread(result.threadId, sessionNameFromPrompt(record.prompt, record.project.name));
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
console.warn(`[bridge] unable to name new thread id=${logRef(result.threadId)}`, error);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
await state.setThread(record.conversationKey, result.threadId);
|
|
1245
|
+
await state.registerThreadAccess(result.threadId, record.conversationKey, record.ownerId, record.project.path);
|
|
1246
|
+
record.status = "succeeded";
|
|
1247
|
+
runtimeHealth.completedTasks += 1;
|
|
1248
|
+
if (record.conversation) {
|
|
1249
|
+
const updated = await record.conversation.finishSucceeded(result.finalResponse, result.usage, result.threadId);
|
|
1250
|
+
record.progress = record.conversation.progress;
|
|
1251
|
+
if (!updated) {
|
|
1252
|
+
await durableReply(record.replyToMessageId, result.finalResponse || "Codex 已完成,但没有返回文字说明。", replyKey(record.seed, `conversation-completed-${record.id}`));
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
else if (record.card) {
|
|
1256
|
+
const cardUpdated = await record.card.finishSucceeded(result.finalResponse, result.usage, result.threadId);
|
|
1257
|
+
record.progress = record.card.progress;
|
|
1258
|
+
if (!cardUpdated) {
|
|
1259
|
+
try {
|
|
1260
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 已完成 · ${record.project.name}\n\n${(result.finalResponse || "Codex 已完成").slice(0, 500)}`, "task-completed", replyKey(record.seed, `notify-completed-${record.id}`));
|
|
1261
|
+
}
|
|
1262
|
+
catch (error) {
|
|
1263
|
+
console.error(`[bridge] completion notification delayed task=${record.id}`, error);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
else if (config.completionNotifications) {
|
|
1267
|
+
try {
|
|
1268
|
+
await productReply(record.replyToMessageId, `任务 ${record.id} 已完成 · ${record.project.name}\n结果已更新到原任务卡。`, replyKey(record.seed, `notify-completed-${record.id}`), "task-completed");
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
console.error(`[bridge] completion notification delayed task=${record.id}`, error);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
record.progress = succeedTask(record.progress, result.finalResponse, result.usage, result.threadId);
|
|
1277
|
+
const usage = result.usage
|
|
1278
|
+
? `\n\n用量:累计输入 ${result.usage.input_tokens}(新增 ${Math.max(0, result.usage.input_tokens - result.usage.cached_input_tokens)},缓存 ${result.usage.cached_input_tokens}),输出 ${result.usage.output_tokens}${result.usage.model_calls ? `,模型调用 ${result.usage.model_calls} 次` : ""} tokens`
|
|
1279
|
+
: "";
|
|
1280
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 完成\n\n${result.finalResponse || "Codex 已完成,但没有返回文字说明。"}${usage}`, "task-completed", replyKey(record.seed, `completed-${record.id}`));
|
|
1281
|
+
}
|
|
1282
|
+
await persistTaskRecord(record);
|
|
1283
|
+
await audit(record.ownerId, "task.complete", "task", record.id, "allowed");
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (error instanceof CodexCancelledError) {
|
|
1287
|
+
if (error.reason === "shutdown") {
|
|
1288
|
+
record.status = "interrupted";
|
|
1289
|
+
const reason = "桥接服务在任务运行期间停止。为避免重复修改,这个任务没有自动重跑。";
|
|
1290
|
+
const session = taskLiveSession(record);
|
|
1291
|
+
if (session) {
|
|
1292
|
+
const updated = await session.finishInterrupted(reason);
|
|
1293
|
+
record.progress = session.progress;
|
|
1294
|
+
if (!updated) {
|
|
1295
|
+
await notifyTaskFallback(record, `任务 ${record.id} 已中断:${reason}`, "task-interrupted", replyKey(record.seed, `interrupted-${record.id}`));
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
record.progress = interruptTaskProgress(record.progress, reason);
|
|
1300
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 已中断:${reason}`, "task-interrupted", replyKey(record.seed, `interrupted-${record.id}`));
|
|
1301
|
+
}
|
|
1302
|
+
await persistTaskRecord(record);
|
|
1303
|
+
await audit(record.ownerId, "task.interrupt", "task", record.id, "failed", "service shutdown");
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if (error.reason === "timeout") {
|
|
1307
|
+
record.status = "failed";
|
|
1308
|
+
runtimeHealth.failedTasks += 1;
|
|
1309
|
+
const reason = `执行超时:任务超过 ${Math.ceil(config.codexTimeoutMs / 60_000)} 分钟运行时限。`;
|
|
1310
|
+
const session = taskLiveSession(record);
|
|
1311
|
+
if (session) {
|
|
1312
|
+
const updated = await session.finishFailed(reason);
|
|
1313
|
+
record.progress = session.progress;
|
|
1314
|
+
if (!updated) {
|
|
1315
|
+
await notifyTaskFallback(record, `任务 ${record.id} 执行失败:${reason}`, "task-timeout", replyKey(record.seed, `timeout-${record.id}`));
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
record.progress = failTask(record.progress, reason);
|
|
1320
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 执行失败:${reason}`, "task-timeout", replyKey(record.seed, `timeout-${record.id}`));
|
|
1321
|
+
}
|
|
1322
|
+
await persistTaskRecord(record);
|
|
1323
|
+
await audit(record.ownerId, "task.timeout", "task", record.id, "failed", reason);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
record.status = "cancelled";
|
|
1327
|
+
const reason = "用户取消";
|
|
1328
|
+
const session = taskLiveSession(record);
|
|
1329
|
+
if (session) {
|
|
1330
|
+
const updated = await session.finishCancelled(reason);
|
|
1331
|
+
record.progress = session.progress;
|
|
1332
|
+
if (!updated) {
|
|
1333
|
+
await notifyTaskFallback(record, `任务 ${record.id} 已取消:${reason}。`, "task-cancelled", replyKey(record.seed, `cancelled-${record.id}`));
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
record.progress = cancelTaskProgress(record.progress, reason);
|
|
1338
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 已取消:${reason}。`, "task-cancelled", replyKey(record.seed, `cancelled-${record.id}`));
|
|
1339
|
+
}
|
|
1340
|
+
await persistTaskRecord(record);
|
|
1341
|
+
await audit(record.ownerId, "task.cancel", "task", record.id, "allowed", reason);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (error instanceof CodexPolicyViolationError)
|
|
1345
|
+
runtimeHealth.blockedCommands += 1;
|
|
1346
|
+
record.status = "failed";
|
|
1347
|
+
runtimeHealth.failedTasks += 1;
|
|
1348
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1349
|
+
console.error(`[bridge] task failed id=${record.id}`, error);
|
|
1350
|
+
const session = taskLiveSession(record);
|
|
1351
|
+
if (session) {
|
|
1352
|
+
const updated = await session.finishFailed(message.slice(0, 2_000));
|
|
1353
|
+
record.progress = session.progress;
|
|
1354
|
+
if (!updated) {
|
|
1355
|
+
await notifyTaskFallback(record, `任务 ${record.id} 执行失败:${message.slice(0, 800)}`, "task-failed", replyKey(record.seed, `failed-${record.id}`));
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
else {
|
|
1359
|
+
record.progress = failTask(record.progress, message.slice(0, 2_000));
|
|
1360
|
+
await updateTaskFallbackCard(record, `任务 ${record.id} 执行失败:${message.slice(0, 800)}`, "task-failed", replyKey(record.seed, `failed-${record.id}`));
|
|
1361
|
+
}
|
|
1362
|
+
await persistTaskRecord(record);
|
|
1363
|
+
await audit(record.ownerId, "task.fail", "task", record.id, "failed", message.slice(0, 500));
|
|
1364
|
+
}
|
|
1365
|
+
finally {
|
|
1366
|
+
if (taskModeCapturesReview(taskModeOf(record.progress))) {
|
|
1367
|
+
await refreshTaskReview(record).catch((error) => {
|
|
1368
|
+
console.error(`[bridge] unable to finalize review task=${record.id}`, error);
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
await closeRuntimeInteractionsForTask(record.id);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
async function refreshTaskReview(record) {
|
|
1375
|
+
const review = await captureTaskReview(record.project.path, record.progress.reviewBaseline, record.progress.changedFiles);
|
|
1376
|
+
if (record.card) {
|
|
1377
|
+
await record.card.attachReview(review);
|
|
1378
|
+
record.progress = record.card.progress;
|
|
1379
|
+
}
|
|
1380
|
+
else {
|
|
1381
|
+
record.progress = attachTaskReview(record.progress, review);
|
|
1382
|
+
}
|
|
1383
|
+
await persistTaskRecord(record);
|
|
1384
|
+
}
|
|
1385
|
+
async function rememberActiveThread(record, threadId) {
|
|
1386
|
+
if (!record.allowThreadBinding)
|
|
1387
|
+
return;
|
|
1388
|
+
await state.setThread(record.conversationKey, threadId);
|
|
1389
|
+
await state.registerThreadAccess(threadId, record.conversationKey, record.ownerId, record.project.path);
|
|
1390
|
+
}
|
|
1391
|
+
async function recordAcceptedSteer(conversationKey) {
|
|
1392
|
+
const taskId = runner.getActiveTask(conversationKey);
|
|
1393
|
+
if (!taskId)
|
|
1394
|
+
return undefined;
|
|
1395
|
+
const record = tasks.get(taskId);
|
|
1396
|
+
if (!record)
|
|
1397
|
+
return taskId;
|
|
1398
|
+
const session = taskLiveSession(record);
|
|
1399
|
+
if (session) {
|
|
1400
|
+
await session.markSteered();
|
|
1401
|
+
record.progress = session.progress;
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
record.progress = noteSteer(record.progress);
|
|
1405
|
+
}
|
|
1406
|
+
await persistTaskRecord(record);
|
|
1407
|
+
return taskId;
|
|
1408
|
+
}
|
|
1409
|
+
function activeTaskTarget(actorId, chatId, preferredConversationKey, allowChatFallback = true) {
|
|
1410
|
+
const preferredTaskId = runner.getActiveTask(preferredConversationKey);
|
|
1411
|
+
const preferred = preferredTaskId ? tasks.get(preferredTaskId) : undefined;
|
|
1412
|
+
if (preferred && canControlTask(config, actorId, preferred)) {
|
|
1413
|
+
return { status: "found", record: preferred };
|
|
1414
|
+
}
|
|
1415
|
+
if (!allowChatFallback)
|
|
1416
|
+
return { status: "none" };
|
|
1417
|
+
const controlled = [...tasks.values()].filter((task) => task.controllerId === actorId &&
|
|
1418
|
+
task.status === "running" &&
|
|
1419
|
+
conversationBelongsToChat(task.conversationKey, chatId) &&
|
|
1420
|
+
runner.getActiveTask(task.conversationKey) === task.id);
|
|
1421
|
+
if (controlled.length === 1)
|
|
1422
|
+
return { status: "found", record: controlled[0] };
|
|
1423
|
+
if (controlled.length > 1)
|
|
1424
|
+
return { status: "ambiguous" };
|
|
1425
|
+
return { status: "none" };
|
|
1426
|
+
}
|
|
1427
|
+
function explicitSteerTarget(actorId, chatId, addition) {
|
|
1428
|
+
const match = addition.match(/^#([A-Za-z0-9]{1,16})\s+(.+)$/s);
|
|
1429
|
+
if (!match?.[1] || !match[2]?.trim())
|
|
1430
|
+
return null;
|
|
1431
|
+
const record = tasks.get(match[1]);
|
|
1432
|
+
if (!record ||
|
|
1433
|
+
record.status !== "running" ||
|
|
1434
|
+
!conversationBelongsToChat(record.conversationKey, chatId) ||
|
|
1435
|
+
!canControlTask(config, actorId, record)) {
|
|
1436
|
+
return "denied";
|
|
1437
|
+
}
|
|
1438
|
+
return { record, addition: match[2].trim() };
|
|
1439
|
+
}
|
|
1440
|
+
async function handleEvent(raw) {
|
|
1441
|
+
const event = parseFeishuEvent(raw);
|
|
1442
|
+
if (!event) {
|
|
1443
|
+
console.error("[bridge] ignored event with unexpected shape");
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (!isAuthorized(event, config)) {
|
|
1447
|
+
console.warn(`[bridge] rejected unauthorized ${eventSummary(event)}`);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (state.hasSeenEvent(event.event_id) || inFlightEvents.has(event.event_id)) {
|
|
1451
|
+
console.info(`[bridge] ignored duplicate event=${logRef(event.event_id)}`);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
inFlightEvents.add(event.event_id);
|
|
1455
|
+
try {
|
|
1456
|
+
await state.setChatType(event.chat_id, event.chat_type);
|
|
1457
|
+
await handleAuthorizedMessage(event);
|
|
1458
|
+
await state.markEventIfNew(event.event_id);
|
|
1459
|
+
}
|
|
1460
|
+
finally {
|
|
1461
|
+
inFlightEvents.delete(event.event_id);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
async function handleAuthorizedMessage(event) {
|
|
1465
|
+
console.info(`[bridge] accepted ${eventSummary(event)}`);
|
|
1466
|
+
const conversationKey = key(event);
|
|
1467
|
+
const actorId = event.sender_id;
|
|
1468
|
+
const directQuestionId = pendingQuestionByConversation.get(conversationKey);
|
|
1469
|
+
const directQuestion = directQuestionId
|
|
1470
|
+
? pendingRuntimeQuestions.get(directQuestionId)
|
|
1471
|
+
: undefined;
|
|
1472
|
+
const directTask = directQuestion ? tasks.get(directQuestion.taskId) : undefined;
|
|
1473
|
+
const directAuthorized = directQuestion && directTask
|
|
1474
|
+
? canControlTask(config, actorId, directTask)
|
|
1475
|
+
: false;
|
|
1476
|
+
const controlledQuestions = (isTopicConversationKey(conversationKey)
|
|
1477
|
+
? []
|
|
1478
|
+
: [...pendingRuntimeQuestions.values()]).filter((pending) => {
|
|
1479
|
+
if (pending.id === directQuestion?.id)
|
|
1480
|
+
return false;
|
|
1481
|
+
const task = tasks.get(pending.taskId);
|
|
1482
|
+
return Boolean(task &&
|
|
1483
|
+
conversationBelongsToChat(pending.conversationKey, event.chat_id) &&
|
|
1484
|
+
canControlTask(config, actorId, task));
|
|
1485
|
+
});
|
|
1486
|
+
const pendingQuestion = directAuthorized
|
|
1487
|
+
? directQuestion
|
|
1488
|
+
: controlledQuestions.length === 1
|
|
1489
|
+
? controlledQuestions[0]
|
|
1490
|
+
: undefined;
|
|
1491
|
+
const pendingQuestionId = pendingQuestion?.id;
|
|
1492
|
+
if (config.logMessageContent) {
|
|
1493
|
+
console.info(pendingQuestion?.question.isSecret ||
|
|
1494
|
+
directQuestion?.question.isSecret ||
|
|
1495
|
+
controlledQuestions.some((pending) => pending.question.isSecret)
|
|
1496
|
+
? "[bridge] content=[redacted: pending secret question]"
|
|
1497
|
+
: `[bridge] content=${JSON.stringify(event.content)}`);
|
|
1498
|
+
}
|
|
1499
|
+
if (isTextualMessageType(event.message_type) &&
|
|
1500
|
+
directQuestion &&
|
|
1501
|
+
!directAuthorized) {
|
|
1502
|
+
await productReply(event.message_id, "这个任务已经转交给新的控制者。请从任务卡点击“接管任务”收回后再回答。", replyKey(event.event_id, "runtime-question-transferred"), "task-question-transferred");
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (isTextualMessageType(event.message_type) &&
|
|
1506
|
+
!pendingQuestion &&
|
|
1507
|
+
controlledQuestions.length > 1) {
|
|
1508
|
+
await productReply(event.message_id, "你当前控制的多个任务都在等待回答。为避免答错任务,请直接使用对应的问题卡。", replyKey(event.event_id, "runtime-question-ambiguous"), "task-question-ambiguous");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (pendingQuestionId && isTextualMessageType(event.message_type)) {
|
|
1512
|
+
const answer = normalizePrompt(event.content, event.chat_type, config.botMentionNames);
|
|
1513
|
+
if (pendingQuestion && answer) {
|
|
1514
|
+
if (pendingQuestion.question.isSecret) {
|
|
1515
|
+
await productReply(event.message_id, "出于安全考虑,这条内容没有转发给 Codex,也没有写入本地任务记录。请回本机完成敏感输入,或在问题卡点击“取消回答”。", replyKey(event.event_id, "secret-answer-blocked"), "security-secret-denied");
|
|
1516
|
+
await audit(actorId, "runtime.question.secret_blocked", "task", pendingQuestion.taskId, "denied");
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const cancelAnswer = ["取消", "cancel", "/cancel", "stop", "/stop"].includes(answer.trim().toLocaleLowerCase());
|
|
1520
|
+
await resolveRuntimeQuestion(pendingQuestion, cancelAnswer ? "" : answer, cancelAnswer ? "cancelled" : "answered", actorId);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (isAttachmentMessageType(event.message_type)) {
|
|
1525
|
+
await maybeShowFirstRunOnboarding(event, conversationKey, actorId);
|
|
1526
|
+
if (!(await requireOperator(event, "attachment")))
|
|
1527
|
+
return;
|
|
1528
|
+
try {
|
|
1529
|
+
const workspaceSession = await preparePromptWorkspaceSession(event, actorId);
|
|
1530
|
+
const attachments = await prepareAttachments(event);
|
|
1531
|
+
if (attachments.length === 0) {
|
|
1532
|
+
await reply(event, "附件已收到,但当前只能直接处理图片和常见文本/代码文件;这个附件没有可安全读取的内容。", "attachment-unsupported");
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
await enqueuePrompt(event.message_id, workspaceSession.conversationKey, actorId, attachmentPrompt(event, attachments), event.event_id, attachments, [], { replyInThread: workspaceSession.replyInThread });
|
|
1536
|
+
}
|
|
1537
|
+
catch (error) {
|
|
1538
|
+
await replyQueueOrError(event, error, "attachment");
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (!isTextualMessageType(event.message_type)) {
|
|
1543
|
+
await reply(event, "目前支持文本、图片和常见文本/代码文件。请换一种格式发送。", "unsupported");
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
const prompt = normalizePrompt(event.content, event.chat_type, config.botMentionNames);
|
|
1547
|
+
if (!prompt) {
|
|
1548
|
+
await reply(event, HELP_TEXT, "empty");
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
const command = classifyCommand(prompt);
|
|
1552
|
+
if (command === "onboarding") {
|
|
1553
|
+
await openOnboarding(event);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
await maybeShowFirstRunOnboarding(event, conversationKey, actorId);
|
|
1557
|
+
switch (command) {
|
|
1558
|
+
case "help":
|
|
1559
|
+
await reply(event, HELP_TEXT, "help");
|
|
1560
|
+
return;
|
|
1561
|
+
case "status": {
|
|
1562
|
+
await replyDeviceCard(event);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
case "quota": {
|
|
1566
|
+
await replyQuotaCard(event);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
case "projects": {
|
|
1570
|
+
await projectRegistry.refresh();
|
|
1571
|
+
const project = await currentProject(conversationKey, actorId);
|
|
1572
|
+
await replyProjectCard(event, project);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
case "project_overview": {
|
|
1576
|
+
await replyProjectOverview(event);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
case "switch_project": {
|
|
1580
|
+
if (!(await requireOperator(event, "project.switch")))
|
|
1581
|
+
return;
|
|
1582
|
+
await projectRegistry.refresh();
|
|
1583
|
+
const selector = projectSelector(prompt);
|
|
1584
|
+
if (!selector) {
|
|
1585
|
+
const project = await currentProject(conversationKey, actorId);
|
|
1586
|
+
await replyProjectCard(event, project, "请从下拉列表选择要切换的项目。", "switch-help");
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const resolution = resolveAccessibleProject(selector, actorId);
|
|
1590
|
+
if (resolution.status !== "found") {
|
|
1591
|
+
await reply(event, projectResolutionError(selector, resolution, actorId), "switch-not-found");
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
const current = await currentProject(conversationKey, actorId);
|
|
1595
|
+
if (current.path === resolution.project.path) {
|
|
1596
|
+
await replyProjectCard(event, current, `当前已经是 ${current.name},无需切换。`, "switch-unchanged");
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const activeBeforeSwitch = runner.getActiveTask(conversationKey) ? 1 : 0;
|
|
1600
|
+
const queuedBeforeSwitch = queue.queuedForConversation(conversationKey);
|
|
1601
|
+
const hasSavedThread = Boolean(state.getThread(conversationKey));
|
|
1602
|
+
if (!isConfirmedProjectSwitch(prompt) &&
|
|
1603
|
+
(activeBeforeSwitch > 0 || queuedBeforeSwitch > 0 || hasSavedThread)) {
|
|
1604
|
+
const taskImpact = activeBeforeSwitch + queuedBeforeSwitch > 0
|
|
1605
|
+
? `会停止 ${activeBeforeSwitch} 个运行任务和 ${queuedBeforeSwitch} 个排队任务`
|
|
1606
|
+
: "当前没有未完成任务";
|
|
1607
|
+
await replyProjectCard(event, current, `尚未切换。切换到 ${resolution.project.name} ${taskImpact},并清空当前聊天保存的 Codex 上下文。请在下拉框确认,或发送“确认切换 ${resolution.project.name}”。`, "switch-confirm");
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
const cancelledActive = runner.cancel(conversationKey);
|
|
1611
|
+
const cancelledQueued = queue.cancelPending(conversationKey);
|
|
1612
|
+
await closeRuntimeInteractionsForConversation(conversationKey);
|
|
1613
|
+
const switched = await state.setProject(conversationKey, resolution.project.path);
|
|
1614
|
+
await state.recordProjectUse(actorId, resolution.project.path);
|
|
1615
|
+
await audit(actorId, "project.switch", "project", resolution.project.path, "allowed");
|
|
1616
|
+
await replyProjectCard(event, resolution.project, switchFeedback(resolution.project, cancelledActive ? 1 : 0, cancelledQueued, switched.threadReset), "switch-complete");
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
case "settings": {
|
|
1620
|
+
const change = settingsChange(prompt);
|
|
1621
|
+
if (!change) {
|
|
1622
|
+
await replyControlCenter(event);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (!(await requireOperator(event, "settings.change")))
|
|
1626
|
+
return;
|
|
1627
|
+
try {
|
|
1628
|
+
const feedback = await applySettingsChange(conversationKey, actorId, change);
|
|
1629
|
+
await replyControlCenter(event, feedback, "settings-changed");
|
|
1630
|
+
}
|
|
1631
|
+
catch (error) {
|
|
1632
|
+
await reply(event, `设置未修改:${error.message}`, "settings-invalid");
|
|
1633
|
+
}
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
case "sessions": {
|
|
1637
|
+
await replySessionCenter(event);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
case "tasks": {
|
|
1641
|
+
await replyTaskCenter(event);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
case "team": {
|
|
1645
|
+
await replyTeamDashboard(event);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
case "runbooks": {
|
|
1649
|
+
await replyRunbookCenter(event);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
case "runbook_run": {
|
|
1653
|
+
if (!(await requireOperator(event, "runbook.execute")))
|
|
1654
|
+
return;
|
|
1655
|
+
const invocation = parseRunbookInvocation(prompt);
|
|
1656
|
+
if (!invocation) {
|
|
1657
|
+
await reply(event, "运行格式不正确。请先发送“运行手册”查看模板,再使用 /run <手册ID> 参数=值。", "runbook-invocation-invalid");
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const project = await currentProject(conversationKey, actorId);
|
|
1661
|
+
const catalog = await loadProjectRunbooks(project.path);
|
|
1662
|
+
const runbook = catalog.runbooks.find((candidate) => candidate.id === invocation.id);
|
|
1663
|
+
if (!runbook) {
|
|
1664
|
+
await replyRunbookCenter(event, catalog.status === "invalid"
|
|
1665
|
+
? `配置未通过安全校验:${catalog.error ?? "未知错误"}`
|
|
1666
|
+
: `没有找到运行手册“${invocation.id}”。`, "runbook-not-found");
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
try {
|
|
1670
|
+
await launchRunbook(event.message_id, conversationKey, actorId, event.event_id, runbook, invocation.values);
|
|
1671
|
+
}
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
await replyRunbookCenter(event, `运行手册未启动:${error.message}`, "runbook-start-failed");
|
|
1674
|
+
}
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
case "compact": {
|
|
1678
|
+
if (!(await requireOperator(event, "session.compact")))
|
|
1679
|
+
return;
|
|
1680
|
+
const threadId = state.getThread(conversationKey);
|
|
1681
|
+
if (!threadId) {
|
|
1682
|
+
await reply(event, "当前还没有可压缩的 Codex 会话。", "compact-empty");
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
await runner.compactThread(threadId);
|
|
1686
|
+
await audit(actorId, "session.compact", "session", threadId, "allowed");
|
|
1687
|
+
await reply(event, "当前会话已完成上下文压缩,后续任务会继续沿用它。", "compact-complete");
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
case "audit": {
|
|
1691
|
+
const isAdmin = canAdminister(config, actorId);
|
|
1692
|
+
const entries = state.listAudit(20, isAdmin ? undefined : actorId);
|
|
1693
|
+
await reply(event, formatAuditLog(entries, isAdmin), "audit");
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
case "new": {
|
|
1697
|
+
if (!(await requireOperator(event, "session.new")))
|
|
1698
|
+
return;
|
|
1699
|
+
const cancelledActive = runner.cancel(conversationKey);
|
|
1700
|
+
const cancelledQueued = queue.cancelPending(conversationKey);
|
|
1701
|
+
await closeRuntimeInteractionsForConversation(conversationKey);
|
|
1702
|
+
const reset = await state.resetThread(conversationKey);
|
|
1703
|
+
await audit(actorId, "session.new", "session", conversationKey, "allowed");
|
|
1704
|
+
await reply(event, `已创建新会话。旧会话${reset ? "已清除" : "原本为空"};取消运行中任务 ${cancelledActive ? 1 : 0} 个、排队任务 ${cancelledQueued} 个。`, "new");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
case "cancel": {
|
|
1708
|
+
if (!(await requireOperator(event, "task.cancel")))
|
|
1709
|
+
return;
|
|
1710
|
+
const target = activeTaskTarget(actorId, event.chat_id, conversationKey);
|
|
1711
|
+
if (target.status === "found" && target.record.conversationKey !== conversationKey) {
|
|
1712
|
+
const cancelled = runner.cancel(target.record.conversationKey);
|
|
1713
|
+
if (cancelled)
|
|
1714
|
+
await closeRuntimeInteractionsForTask(target.record.id);
|
|
1715
|
+
await audit(actorId, "task.cancel_controlled", "task", target.record.id, cancelled ? "allowed" : "denied");
|
|
1716
|
+
await reply(event, cancelled
|
|
1717
|
+
? `已向你当前控制的任务 ${target.record.id} 发送停止请求。`
|
|
1718
|
+
: "该任务已经结束,无需停止。", "cancel-controlled");
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
if (target.status === "ambiguous") {
|
|
1722
|
+
await reply(event, "你当前控制多个运行任务。为避免停止错误任务,请在对应任务卡或任务中心点击“停止”。", "cancel-ambiguous");
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const cancelledActive = runner.cancel(conversationKey);
|
|
1726
|
+
const cancelledQueued = queue.cancelPending(conversationKey);
|
|
1727
|
+
await closeRuntimeInteractionsForConversation(conversationKey);
|
|
1728
|
+
await audit(actorId, "task.cancel_all", "task", conversationKey, "allowed");
|
|
1729
|
+
await reply(event, `取消请求已处理:运行中 ${cancelledActive ? 1 : 0} 个,排队 ${cancelledQueued} 个。`, "cancel");
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
case "steer": {
|
|
1733
|
+
if (!(await requireOperator(event, "task.steer")))
|
|
1734
|
+
return;
|
|
1735
|
+
const addition = steerPrompt(prompt);
|
|
1736
|
+
if (!addition) {
|
|
1737
|
+
await reply(event, "请写成:追加 <补充要求>。", "steer-empty");
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
const explicitTarget = explicitSteerTarget(actorId, event.chat_id, addition);
|
|
1741
|
+
if (explicitTarget === "denied") {
|
|
1742
|
+
await reply(event, "指定任务不存在、已经结束或不由你控制。请发送“任务”查看最新状态。", "steer-target-denied");
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
let targetRecord;
|
|
1746
|
+
if (explicitTarget) {
|
|
1747
|
+
targetRecord = explicitTarget.record;
|
|
1748
|
+
}
|
|
1749
|
+
else {
|
|
1750
|
+
const target = activeTaskTarget(actorId, event.chat_id, conversationKey, !isTopicConversationKey(conversationKey));
|
|
1751
|
+
if (target.status === "ambiguous") {
|
|
1752
|
+
await reply(event, "你当前控制多个运行任务。请写成:追加 #任务ID <补充要求>。", "steer-ambiguous");
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (target.status === "found")
|
|
1756
|
+
targetRecord = target.record;
|
|
1757
|
+
}
|
|
1758
|
+
const effectiveAddition = explicitTarget ? explicitTarget.addition : addition;
|
|
1759
|
+
const externalActions = externalActionsForPrompt(effectiveAddition);
|
|
1760
|
+
if (externalActions.length > 0) {
|
|
1761
|
+
await requestExternalConfirmation(event, effectiveAddition, externalActions);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
try {
|
|
1765
|
+
const targetConversation = targetRecord?.conversationKey ?? conversationKey;
|
|
1766
|
+
const accepted = await runner.steer(targetConversation, effectiveAddition);
|
|
1767
|
+
const taskId = accepted ? await recordAcceptedSteer(targetConversation) : undefined;
|
|
1768
|
+
if (accepted) {
|
|
1769
|
+
await audit(actorId, "task.steer", "task", taskId ?? "active", "allowed");
|
|
1770
|
+
}
|
|
1771
|
+
await reply(event, accepted
|
|
1772
|
+
? "补充要求已加入当前任务,Codex 会在本轮中继续处理。"
|
|
1773
|
+
: "当前没有可追加的运行任务;请直接发送这条要求,它会作为新任务进入队列。", accepted ? "steer-accepted" : "steer-unavailable");
|
|
1774
|
+
}
|
|
1775
|
+
catch (error) {
|
|
1776
|
+
console.error("[bridge] unable to steer active turn", error);
|
|
1777
|
+
await reply(event, "补充要求暂时未能加入当前任务,请直接作为新任务发送。", "steer-failed");
|
|
1778
|
+
}
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
case "queue_prompt": {
|
|
1782
|
+
if (!(await requireOperator(event, "task.enqueue")))
|
|
1783
|
+
return;
|
|
1784
|
+
const queued = queuedPrompt(prompt);
|
|
1785
|
+
if (!queued) {
|
|
1786
|
+
await reply(event, "请写成:排队 <独立任务>。", "queue-empty");
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
const externalActions = externalActionsForPrompt(queued);
|
|
1790
|
+
try {
|
|
1791
|
+
const workspaceSession = await preparePromptWorkspaceSession(event, actorId);
|
|
1792
|
+
if (externalActions.length > 0) {
|
|
1793
|
+
await requestExternalConfirmation(event, queued, externalActions, workspaceSession);
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
await enqueuePrompt(event.message_id, workspaceSession.conversationKey, actorId, queued, event.event_id, [], [], { replyInThread: workspaceSession.replyInThread });
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
catch (error) {
|
|
1800
|
+
await replyQueueOrError(event, error, "queue-prompt");
|
|
1801
|
+
}
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
case "prompt": {
|
|
1805
|
+
if (!(await requireOperator(event, "task.enqueue")))
|
|
1806
|
+
return;
|
|
1807
|
+
const externalActions = externalActionsForPrompt(prompt);
|
|
1808
|
+
try {
|
|
1809
|
+
const workspaceSession = await preparePromptWorkspaceSession(event, actorId);
|
|
1810
|
+
if (externalActions.length > 0) {
|
|
1811
|
+
await requestExternalConfirmation(event, prompt, externalActions, workspaceSession);
|
|
1812
|
+
}
|
|
1813
|
+
else {
|
|
1814
|
+
const target = workspaceSession.startsNewTopic
|
|
1815
|
+
? { status: "none" }
|
|
1816
|
+
: activeTaskTarget(actorId, event.chat_id, workspaceSession.conversationKey, !isTopicConversationKey(workspaceSession.conversationKey));
|
|
1817
|
+
const targetConversation = target.status === "found"
|
|
1818
|
+
? target.record.conversationKey
|
|
1819
|
+
: workspaceSession.conversationKey;
|
|
1820
|
+
const steered = target.status !== "ambiguous" &&
|
|
1821
|
+
await runner.steer(targetConversation, prompt);
|
|
1822
|
+
if (steered) {
|
|
1823
|
+
const taskId = await recordAcceptedSteer(targetConversation);
|
|
1824
|
+
await audit(actorId, "task.steer", "task", taskId ?? "active", "allowed");
|
|
1825
|
+
await reply(event, "补充要求已自动加入正在运行的任务。若要创建独立任务,请发送“排队 <任务>”。", "auto-steer");
|
|
1826
|
+
}
|
|
1827
|
+
else {
|
|
1828
|
+
await enqueuePrompt(event.message_id, workspaceSession.conversationKey, actorId, prompt, event.event_id, [], [], { replyInThread: workspaceSession.replyInThread });
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
catch (error) {
|
|
1833
|
+
await replyQueueOrError(event, error, "prompt");
|
|
1834
|
+
}
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
async function handleCardAction(raw) {
|
|
1840
|
+
const event = parseCardActionEvent(raw);
|
|
1841
|
+
if (!event) {
|
|
1842
|
+
console.error("[bridge] ignored card action with unexpected shape");
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (!isAuthorizedCardAction(event, config)) {
|
|
1846
|
+
console.warn(`[bridge] rejected unauthorized card action event=${logRef(event.event_id)} operator=${logRef(event.operator_id)}`);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (config.sandboxMode === "danger-full-access" &&
|
|
1850
|
+
!config.allowedChatIds.has(event.chat_id) &&
|
|
1851
|
+
state.getChatType(event.chat_id) !== "p2p") {
|
|
1852
|
+
console.warn(`[bridge] rejected full-access card action from an untrusted chat event=${logRef(event.event_id)}`);
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
if (state.hasSeenEvent(event.event_id) || inFlightEvents.has(event.event_id)) {
|
|
1856
|
+
console.info(`[bridge] ignored duplicate card action=${logRef(event.event_id)}`);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
inFlightEvents.add(event.event_id);
|
|
1860
|
+
runtimeHealth.lastCardActionAt = Date.now();
|
|
1861
|
+
try {
|
|
1862
|
+
await handleAuthorizedCardAction(event);
|
|
1863
|
+
await state.markEventIfNew(event.event_id);
|
|
1864
|
+
}
|
|
1865
|
+
finally {
|
|
1866
|
+
inFlightEvents.delete(event.event_id);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
async function handleAuthorizedCardAction(event) {
|
|
1870
|
+
const action = parseCardActionValue(event);
|
|
1871
|
+
if (!action) {
|
|
1872
|
+
console.warn(`[bridge] ignored unknown card action event=${logRef(event.event_id)}`);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (action.bridge === "feishu-codex-v7") {
|
|
1876
|
+
await handleRunbookCardAction(event, action);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (action.bridge === "feishu-codex-v6") {
|
|
1880
|
+
await handleReviewCardAction(event, action);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (action.bridge === "feishu-codex-v5") {
|
|
1884
|
+
await handleV5CardAction(event, action);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
if (action.bridge === "feishu-codex-v4") {
|
|
1888
|
+
if ("request_id" in action)
|
|
1889
|
+
await handleRuntimeCardAction(event, action);
|
|
1890
|
+
else
|
|
1891
|
+
await handleDeviceCardAction(event, action);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
if (action.action === "approve_external" || action.action === "reject_external") {
|
|
1895
|
+
await handleExternalConfirmation(event, action);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (action.action === "select_project") {
|
|
1899
|
+
await handleProjectCardSelection(event);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
if (action.action === "toggle_project_favorite") {
|
|
1903
|
+
await handleProjectFavoriteToggle(event);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (!("task_id" in action))
|
|
1907
|
+
return;
|
|
1908
|
+
const record = tasks.get(action.task_id);
|
|
1909
|
+
const isReadOnlyTaskAction = action.action === "review" ||
|
|
1910
|
+
action.action === "changes" ||
|
|
1911
|
+
action.action === "result" ||
|
|
1912
|
+
action.action === "result_back";
|
|
1913
|
+
const actionAuthorized = record
|
|
1914
|
+
? action.action === "takeover"
|
|
1915
|
+
? canTakeOverTask(config, event.operator_id, record)
|
|
1916
|
+
: isReadOnlyTaskAction
|
|
1917
|
+
? canViewTask(config, event.operator_id, record)
|
|
1918
|
+
: canControlTask(config, event.operator_id, record)
|
|
1919
|
+
: false;
|
|
1920
|
+
if (!record ||
|
|
1921
|
+
!conversationBelongsToChat(record.conversationKey, event.chat_id) ||
|
|
1922
|
+
!actionAuthorized) {
|
|
1923
|
+
console.warn(`[bridge] card action references unavailable task=${action.task_id}`);
|
|
1924
|
+
await productReply(event.message_id, "这条任务记录已过期或不属于当前聊天,请重新发送任务。", replyKey(event.event_id, "task-unavailable"), "task-unavailable");
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
console.info(`[bridge] card action=${action.action} task=${record.id}`);
|
|
1928
|
+
switch (action.action) {
|
|
1929
|
+
case "handoff": {
|
|
1930
|
+
if (state.getChatType(event.chat_id) !== "group") {
|
|
1931
|
+
await record.card?.addActionNote("任务转交只在团队群聊中开放;私聊任务始终由发起人控制。");
|
|
1932
|
+
await audit(event.operator_id, "task.handoff", "task", record.id, "denied", "not group");
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const target = event.option
|
|
1936
|
+
? resolveMemberSelector(config, event.option)
|
|
1937
|
+
: undefined;
|
|
1938
|
+
if (!target ||
|
|
1939
|
+
target.role === "viewer" ||
|
|
1940
|
+
!canAccessProject(config, target.id, record.project)) {
|
|
1941
|
+
await record.card?.addActionNote("转交失败:目标成员已失效、为只读角色或无权访问当前项目。");
|
|
1942
|
+
await audit(event.operator_id, "task.handoff", "task", record.id, "denied", "invalid target");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
await setTaskController(record, target.id, event.operator_id, "handoff");
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
case "takeover": {
|
|
1949
|
+
if (state.getChatType(event.chat_id) !== "group") {
|
|
1950
|
+
await audit(event.operator_id, "task.takeover", "task", record.id, "denied", "not group");
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
await setTaskController(record, event.operator_id, event.operator_id, "takeover");
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
case "cancel": {
|
|
1957
|
+
const cancelledQueued = queue.cancelTask(record.id);
|
|
1958
|
+
const cancelledActive = runner.getActiveTask(record.conversationKey) === record.id &&
|
|
1959
|
+
runner.cancel(record.conversationKey);
|
|
1960
|
+
if (cancelledActive)
|
|
1961
|
+
await closeRuntimeInteractionsForTask(record.id);
|
|
1962
|
+
if (!cancelledQueued && !cancelledActive) {
|
|
1963
|
+
await record.card?.addActionNote("任务已经结束,无需取消。");
|
|
1964
|
+
}
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
case "changes":
|
|
1968
|
+
case "review": {
|
|
1969
|
+
await replyReviewCard(event, record);
|
|
1970
|
+
await audit(event.operator_id, "task.review", "task", record.id, "allowed");
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
case "result": {
|
|
1974
|
+
if (!record.card || record.card.messageId !== event.message_id) {
|
|
1975
|
+
await productReply(event.message_id, "完整结果入口已过期,请从最新任务卡重新打开。", replyKey(event.event_id, `result-unavailable-${record.id}`), "task-result-unavailable");
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
reviewCards.delete(event.message_id);
|
|
1979
|
+
const updated = await record.card.showSurface(renderTaskResultCard(record.card.progress), "result-surface");
|
|
1980
|
+
if (!updated) {
|
|
1981
|
+
await productReply(event.message_id, record.progress.finalResponse || "任务已完成,但完整结果暂时无法展开。", replyKey(event.event_id, `result-fallback-${record.id}`), "task-result-fallback");
|
|
1982
|
+
}
|
|
1983
|
+
await audit(event.operator_id, "task.result", "task", record.id, "allowed");
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
case "result_back": {
|
|
1987
|
+
if (record.card?.messageId === event.message_id) {
|
|
1988
|
+
reviewCards.delete(event.message_id);
|
|
1989
|
+
await record.card.restoreTaskSurface();
|
|
1990
|
+
}
|
|
1991
|
+
await audit(event.operator_id, "task.result.back", "task", record.id, "allowed");
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
case "retry": {
|
|
1995
|
+
if (!canOperate(config, record.ownerId)) {
|
|
1996
|
+
await record.card?.addActionNote("任务发起人的执行权限已被撤销,不能重新执行;请由当前操作者重新发送任务。");
|
|
1997
|
+
await audit(event.operator_id, "task.retry", "task", record.id, "denied", "owner revoked");
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (record.status === "queued" || record.status === "running") {
|
|
2001
|
+
await record.card?.addActionNote("当前任务尚未结束,暂不能重新执行。");
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2005
|
+
if (project.path !== record.project.path) {
|
|
2006
|
+
await record.card?.addActionNote(`原任务属于 ${record.project.name},当前项目是 ${project.name};请先切回原项目再重试。`);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (record.allowedExternalActions.length > 0) {
|
|
2010
|
+
try {
|
|
2011
|
+
await createExternalConfirmation(event.message_id, record.conversationKey, record.ownerId, record.prompt, record.allowedExternalActions, event.event_id, record.attachments, record.replyInThread);
|
|
2012
|
+
await record.card?.addActionNote("已创建新的外部动作确认卡;确认后才会重新执行。");
|
|
2013
|
+
}
|
|
2014
|
+
catch (error) {
|
|
2015
|
+
await record.card?.addActionNote(`无法创建确认卡:${error.message}`);
|
|
2016
|
+
}
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
try {
|
|
2020
|
+
const retryId = await enqueuePrompt(event.message_id, record.conversationKey, record.ownerId, record.prompt, event.event_id, record.attachments, [], { replyInThread: record.replyInThread });
|
|
2021
|
+
await record.card?.addActionNote(`已创建重试任务 ${retryId}。`);
|
|
2022
|
+
}
|
|
2023
|
+
catch (error) {
|
|
2024
|
+
const message = error instanceof QueueCapacityError
|
|
2025
|
+
? "当前会话队列已满,请稍后再试。"
|
|
2026
|
+
: `无法创建重试任务:${error.message}`;
|
|
2027
|
+
await record.card?.addActionNote(message);
|
|
2028
|
+
}
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
case "new": {
|
|
2032
|
+
const cancelledActive = runner.cancel(record.conversationKey);
|
|
2033
|
+
const cancelledQueued = queue.cancelPending(record.conversationKey);
|
|
2034
|
+
await closeRuntimeInteractionsForConversation(record.conversationKey);
|
|
2035
|
+
await state.resetThread(record.conversationKey);
|
|
2036
|
+
await audit(event.operator_id, "session.new", "session", record.conversationKey, "allowed");
|
|
2037
|
+
await record.card?.addActionNote(`已开启新会话;取消运行中 ${cancelledActive ? 1 : 0} 个、排队 ${cancelledQueued} 个。`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
async function handleReviewCardAction(event, action) {
|
|
2042
|
+
const task = tasks.get(action.task_id);
|
|
2043
|
+
if (!task ||
|
|
2044
|
+
!conversationBelongsToChat(task.conversationKey, event.chat_id) ||
|
|
2045
|
+
!canViewTask(config, event.operator_id, task)) {
|
|
2046
|
+
await productReply(event.message_id, "这份审阅已过期或不属于当前聊天。请从最新任务卡重新打开。", replyKey(event.event_id, "review-unavailable"), "task-review-expired");
|
|
2047
|
+
await audit(event.operator_id, action.action, "task", action.task_id, "denied", "task unavailable or owner mismatch");
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
const cardRecord = reviewCards.get(event.message_id);
|
|
2051
|
+
if (action.action === "review_close" && task.card?.messageId === event.message_id) {
|
|
2052
|
+
reviewCards.delete(event.message_id);
|
|
2053
|
+
await task.card.restoreTaskSurface();
|
|
2054
|
+
await audit(event.operator_id, action.action, "task", task.id, "allowed");
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (cardRecord &&
|
|
2058
|
+
(cardRecord.taskId !== task.id ||
|
|
2059
|
+
!conversationBelongsToChat(cardRecord.conversationKey, event.chat_id) ||
|
|
2060
|
+
!canViewTask(config, event.operator_id, task))) {
|
|
2061
|
+
await productReply(event.message_id, "这张审阅卡与任务不匹配,请从最新任务卡重新打开。", replyKey(event.event_id, "review-card-mismatch"), "task-review-invalid");
|
|
2062
|
+
await audit(event.operator_id, action.action, "task", task.id, "denied", "card mismatch");
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (!cardRecord) {
|
|
2066
|
+
await replyReviewCard(event, task, "原审阅卡已失效,已根据当前工作区重新创建。", "review-recreated", action.page ?? 0, action.action === "review_file" ||
|
|
2067
|
+
action.action === "review_diff_page" ||
|
|
2068
|
+
action.action === "review_refresh"
|
|
2069
|
+
? action.file_index
|
|
2070
|
+
: undefined, action.diff_page ?? 0);
|
|
2071
|
+
await audit(event.operator_id, action.action, "task", task.id, "allowed", "recreated");
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
let page = cardRecord.page;
|
|
2075
|
+
let selectedFileIndex = cardRecord.selectedFileIndex;
|
|
2076
|
+
let diffPage = cardRecord.diffPage ?? 0;
|
|
2077
|
+
let feedback = "";
|
|
2078
|
+
if (action.action === "review_page") {
|
|
2079
|
+
page = action.page ?? page;
|
|
2080
|
+
selectedFileIndex = undefined;
|
|
2081
|
+
diffPage = 0;
|
|
2082
|
+
}
|
|
2083
|
+
else if (action.action === "review_file") {
|
|
2084
|
+
selectedFileIndex = action.file_index;
|
|
2085
|
+
diffPage = 0;
|
|
2086
|
+
}
|
|
2087
|
+
else if (action.action === "review_back") {
|
|
2088
|
+
page = action.page ?? page;
|
|
2089
|
+
selectedFileIndex = undefined;
|
|
2090
|
+
diffPage = 0;
|
|
2091
|
+
}
|
|
2092
|
+
else if (action.action === "review_diff_page") {
|
|
2093
|
+
diffPage = action.diff_page ?? diffPage;
|
|
2094
|
+
}
|
|
2095
|
+
else if (action.action === "review_refresh") {
|
|
2096
|
+
const selectedPath = selectedFileIndex === undefined
|
|
2097
|
+
? undefined
|
|
2098
|
+
: task.progress.review?.files[selectedFileIndex]?.path;
|
|
2099
|
+
await refreshTaskReview(task);
|
|
2100
|
+
selectedFileIndex = selectedPath
|
|
2101
|
+
? task.progress.review?.files.findIndex((file) => file.path === selectedPath)
|
|
2102
|
+
: undefined;
|
|
2103
|
+
if (selectedFileIndex !== undefined && selectedFileIndex < 0)
|
|
2104
|
+
selectedFileIndex = undefined;
|
|
2105
|
+
feedback = "审阅快照已刷新;若文件在任务后继续变化,会单独标记。";
|
|
2106
|
+
}
|
|
2107
|
+
if (selectedFileIndex !== undefined &&
|
|
2108
|
+
(!Number.isInteger(selectedFileIndex) ||
|
|
2109
|
+
selectedFileIndex < 0 ||
|
|
2110
|
+
selectedFileIndex >= (task.progress.review?.files.length ?? 0))) {
|
|
2111
|
+
selectedFileIndex = undefined;
|
|
2112
|
+
feedback = "该文件已不在当前审阅快照中,已返回文件列表。";
|
|
2113
|
+
}
|
|
2114
|
+
const snapshot = await buildReviewCardSnapshot(task, page, selectedFileIndex, feedback, diffPage, cardRecord.embedded ?? false);
|
|
2115
|
+
try {
|
|
2116
|
+
if (cardRecord.embedded &&
|
|
2117
|
+
task.card?.messageId === event.message_id &&
|
|
2118
|
+
task.card.cardId === cardRecord.cardId) {
|
|
2119
|
+
const updated = await task.card.showSurface(renderReviewCard(snapshot), "review-action");
|
|
2120
|
+
if (!updated)
|
|
2121
|
+
throw new Error("unable to update embedded review surface");
|
|
2122
|
+
cardRecord.sequence = task.card.sequenceNumber;
|
|
2123
|
+
}
|
|
2124
|
+
else {
|
|
2125
|
+
cardRecord.sequence += 1;
|
|
2126
|
+
await lark.updateCard(cardRecord.cardId, renderReviewCard(snapshot), cardRecord.sequence);
|
|
2127
|
+
}
|
|
2128
|
+
cardRecord.page = snapshot.page;
|
|
2129
|
+
if (snapshot.selectedFileIndex === undefined)
|
|
2130
|
+
delete cardRecord.selectedFileIndex;
|
|
2131
|
+
else
|
|
2132
|
+
cardRecord.selectedFileIndex = snapshot.selectedFileIndex;
|
|
2133
|
+
if (snapshot.diffPage === undefined)
|
|
2134
|
+
delete cardRecord.diffPage;
|
|
2135
|
+
else
|
|
2136
|
+
cardRecord.diffPage = snapshot.diffPage;
|
|
2137
|
+
}
|
|
2138
|
+
catch (error) {
|
|
2139
|
+
console.error(`[bridge] review card update failed task=${task.id}`, error);
|
|
2140
|
+
reviewCards.delete(event.message_id);
|
|
2141
|
+
if (cardRecord.embedded && task.card?.messageId === event.message_id) {
|
|
2142
|
+
await task.card.restoreTaskSurface();
|
|
2143
|
+
}
|
|
2144
|
+
else {
|
|
2145
|
+
await replyReviewCard(event, task, feedback || "原审阅卡更新失败,已创建新卡。", "review-update-fallback", snapshot.page, snapshot.selectedFileIndex, snapshot.diffPage ?? 0);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
await audit(event.operator_id, action.action, "task", task.id, "allowed");
|
|
2149
|
+
}
|
|
2150
|
+
async function handleRunbookCardAction(event, action) {
|
|
2151
|
+
const record = runbookCards.get(event.message_id);
|
|
2152
|
+
const isRun = action.action === "runbook_run";
|
|
2153
|
+
const authorized = record && conversationBelongsToChat(record.conversationKey, event.chat_id) &&
|
|
2154
|
+
(isRun
|
|
2155
|
+
? event.operator_id === record.ownerId && canOperate(config, event.operator_id)
|
|
2156
|
+
: canViewOwnedResource(config, event.operator_id, record.ownerId));
|
|
2157
|
+
if (!record || !authorized) {
|
|
2158
|
+
await productReply(event.message_id, "这张运行手册卡已过期,或不属于你。请重新发送“运行手册”。", replyKey(event.event_id, "runbook-card-unavailable"), "runbook-unavailable");
|
|
2159
|
+
await audit(event.operator_id, action.action, "security", event.message_id, "denied");
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
if (action.action === "runbook_projects") {
|
|
2163
|
+
await projectRegistry.refresh();
|
|
2164
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2165
|
+
await replyProjectCard(event, project, "从运行手册打开。", "runbook-projects", record);
|
|
2166
|
+
await updateRunbookCenterCard(event, record, "项目工作台已发送到下方。");
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
if (action.action === "runbook_team") {
|
|
2170
|
+
await replyTeamDashboard(event, "从运行手册打开。", "runbook-team", record);
|
|
2171
|
+
await updateRunbookCenterCard(event, record, "团队工作台已发送到下方。");
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
if (action.action === "runbook_refresh") {
|
|
2175
|
+
await updateRunbookCenterCard(event, record, "运行手册已重新校验。");
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2179
|
+
const catalog = await loadProjectRunbooks(project.path);
|
|
2180
|
+
const runbook = action.runbook_id
|
|
2181
|
+
? catalog.runbooks.find((candidate) => candidate.id === action.runbook_id)
|
|
2182
|
+
: undefined;
|
|
2183
|
+
if (!runbook) {
|
|
2184
|
+
await updateRunbookCenterCard(event, record, catalog.status === "invalid"
|
|
2185
|
+
? `配置未通过安全校验:${catalog.error ?? "未知错误"}`
|
|
2186
|
+
: "这个运行手册已经删除或改名,请刷新后重试。");
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
try {
|
|
2190
|
+
const taskId = await launchRunbook(event.message_id, record.conversationKey, record.ownerId, event.event_id, runbook);
|
|
2191
|
+
await updateRunbookCenterCard(event, record, `已创建任务 ${taskId} · ${runbook.name}。`);
|
|
2192
|
+
}
|
|
2193
|
+
catch (error) {
|
|
2194
|
+
await updateRunbookCenterCard(event, record, `运行手册未启动:${error.message}`);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
async function handleProjectCardSelection(event) {
|
|
2198
|
+
if (!canOperate(config, event.operator_id)) {
|
|
2199
|
+
await audit(event.operator_id, "project.switch", "project", event.option ?? "unknown", "denied");
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const cardRecord = projectCards.get(event.message_id);
|
|
2203
|
+
if (cardRecord &&
|
|
2204
|
+
(!conversationBelongsToChat(cardRecord.conversationKey, event.chat_id) ||
|
|
2205
|
+
!canControlOwnedResource(config, event.operator_id, cardRecord.ownerId))) {
|
|
2206
|
+
console.warn(`[bridge] project card chat mismatch message=${logRef(event.message_id)}`);
|
|
2207
|
+
await audit(event.operator_id, "project.switch", "project", event.option ?? "unknown", "denied");
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
if (!event.option) {
|
|
2211
|
+
await productReply(event.message_id, "没有识别到所选项目,请重新发送“项目”后再试。", replyKey(event.event_id, "project-option-missing"), "project-invalid");
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
await projectRegistry.refresh();
|
|
2215
|
+
const selected = projectRegistry.getByPath(event.option);
|
|
2216
|
+
if (!selected || !canAccessProject(config, event.operator_id, selected)) {
|
|
2217
|
+
await productReply(event.message_id, "这个项目已不在 Codex 项目列表中,请重新发送“项目”刷新。", replyKey(event.event_id, "project-option-stale"), "project-stale");
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
const conversationKey = cardRecord?.conversationKey ?? cardKey(event);
|
|
2221
|
+
const ownerId = cardRecord?.ownerId ?? event.operator_id;
|
|
2222
|
+
const current = await currentProject(conversationKey, ownerId);
|
|
2223
|
+
let feedback;
|
|
2224
|
+
if (current.path === selected.path) {
|
|
2225
|
+
feedback = `当前已经是 ${selected.name},无需切换。`;
|
|
2226
|
+
}
|
|
2227
|
+
else {
|
|
2228
|
+
const cancelledActive = runner.cancel(conversationKey);
|
|
2229
|
+
const cancelledQueued = queue.cancelPending(conversationKey);
|
|
2230
|
+
await closeRuntimeInteractionsForConversation(conversationKey);
|
|
2231
|
+
const switched = await state.setProject(conversationKey, selected.path);
|
|
2232
|
+
await state.recordProjectUse(ownerId, selected.path);
|
|
2233
|
+
feedback = switchFeedback(selected, cancelledActive ? 1 : 0, cancelledQueued, switched.threadReset);
|
|
2234
|
+
}
|
|
2235
|
+
console.info(`[bridge] selected project=${logRef(selected.path)} chat=${logRef(event.chat_id)}`);
|
|
2236
|
+
await audit(event.operator_id, "project.switch", "project", selected.path, "allowed");
|
|
2237
|
+
if (!cardRecord) {
|
|
2238
|
+
await productReply(event.message_id, feedback, replyKey(event.event_id, "project-selected-fallback"), "project-switched");
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
try {
|
|
2242
|
+
const workspace = await buildProjectWorkspace(conversationKey, ownerId, selected);
|
|
2243
|
+
cardRecord.sequence += 1;
|
|
2244
|
+
await lark.updateCard(cardRecord.cardId, renderProjectCard(workspace.projects, selected, feedback, workspace.context), cardRecord.sequence);
|
|
2245
|
+
await state.upsertProjectCard({
|
|
2246
|
+
messageId: event.message_id,
|
|
2247
|
+
cardId: cardRecord.cardId,
|
|
2248
|
+
conversationKey: cardRecord.conversationKey,
|
|
2249
|
+
ownerId: cardRecord.ownerId,
|
|
2250
|
+
sequence: cardRecord.sequence,
|
|
2251
|
+
createdAt: cardRecord.createdAt,
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
catch (error) {
|
|
2255
|
+
console.error(`[bridge] project card update failed message=${logRef(event.message_id)}`, error);
|
|
2256
|
+
await productReply(event.message_id, feedback, replyKey(event.event_id, "project-selected-update-fallback"), "project-switched");
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
async function handleProjectFavoriteToggle(event) {
|
|
2260
|
+
const cardRecord = projectCards.get(event.message_id);
|
|
2261
|
+
if (!cardRecord ||
|
|
2262
|
+
!conversationBelongsToChat(cardRecord.conversationKey, event.chat_id) ||
|
|
2263
|
+
event.operator_id !== cardRecord.ownerId) {
|
|
2264
|
+
await audit(event.operator_id, "project.favorite", "project", "unknown", "denied");
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
const current = await currentProject(cardRecord.conversationKey, cardRecord.ownerId);
|
|
2268
|
+
if (!canAccessProject(config, cardRecord.ownerId, current))
|
|
2269
|
+
return;
|
|
2270
|
+
const favorite = await state.toggleProjectFavorite(cardRecord.ownerId, current.path);
|
|
2271
|
+
const workspace = await buildProjectWorkspace(cardRecord.conversationKey, cardRecord.ownerId, current);
|
|
2272
|
+
const feedback = favorite
|
|
2273
|
+
? `已收藏 ${current.name},它会固定在项目列表前面。`
|
|
2274
|
+
: `已取消收藏 ${current.name}。`;
|
|
2275
|
+
try {
|
|
2276
|
+
cardRecord.sequence += 1;
|
|
2277
|
+
await lark.updateCard(cardRecord.cardId, renderProjectCard(workspace.projects, current, feedback, workspace.context), cardRecord.sequence);
|
|
2278
|
+
await state.upsertProjectCard({
|
|
2279
|
+
messageId: event.message_id,
|
|
2280
|
+
cardId: cardRecord.cardId,
|
|
2281
|
+
conversationKey: cardRecord.conversationKey,
|
|
2282
|
+
ownerId: cardRecord.ownerId,
|
|
2283
|
+
sequence: cardRecord.sequence,
|
|
2284
|
+
createdAt: cardRecord.createdAt,
|
|
2285
|
+
});
|
|
2286
|
+
await audit(event.operator_id, favorite ? "project.favorite" : "project.unfavorite", "project", current.path, "allowed");
|
|
2287
|
+
}
|
|
2288
|
+
catch (error) {
|
|
2289
|
+
console.error("[bridge] project favorite card update failed", error);
|
|
2290
|
+
await productReply(event.message_id, feedback, replyKey(event.event_id, "project-favorite-fallback"), "project-changed");
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
async function handleDeviceCardAction(event, action) {
|
|
2294
|
+
const isReadOnlyNavigation = [
|
|
2295
|
+
"device_refresh",
|
|
2296
|
+
"device_quota",
|
|
2297
|
+
"device_projects",
|
|
2298
|
+
"device_settings",
|
|
2299
|
+
"device_sessions",
|
|
2300
|
+
"device_tasks",
|
|
2301
|
+
"quota_refresh",
|
|
2302
|
+
"quota_device",
|
|
2303
|
+
].includes(action.action);
|
|
2304
|
+
if (!isReadOnlyNavigation &&
|
|
2305
|
+
!canOperate(config, event.operator_id)) {
|
|
2306
|
+
await audit(event.operator_id, action.action, "device", config.instanceId, "denied");
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
const cardRecord = deviceCards.get(event.message_id);
|
|
2310
|
+
if (cardRecord &&
|
|
2311
|
+
(!conversationBelongsToChat(cardRecord.conversationKey, event.chat_id) ||
|
|
2312
|
+
!(isReadOnlyNavigation
|
|
2313
|
+
? canViewOwnedResource(config, event.operator_id, cardRecord.ownerId)
|
|
2314
|
+
: canControlOwnedResource(config, event.operator_id, cardRecord.ownerId)))) {
|
|
2315
|
+
console.warn(`[bridge] device card chat mismatch message=${logRef(event.message_id)}`);
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
const conversationKey = cardRecord?.conversationKey ?? cardKey(event);
|
|
2319
|
+
const ownerId = cardRecord?.ownerId ?? event.operator_id;
|
|
2320
|
+
if ((action.action === "remote_ready_enable" || action.action === "remote_ready_disable") &&
|
|
2321
|
+
!canAdminister(config, event.operator_id)) {
|
|
2322
|
+
await updateDeviceCard(event, "只有管理员可以切换整台设备的远程就绪状态。", cardRecord);
|
|
2323
|
+
await audit(event.operator_id, action.action, "device", config.instanceId, "denied");
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
let feedback = "状态已刷新。";
|
|
2327
|
+
switch (action.action) {
|
|
2328
|
+
case "device_refresh":
|
|
2329
|
+
break;
|
|
2330
|
+
case "device_quota":
|
|
2331
|
+
case "quota_refresh": {
|
|
2332
|
+
const detail = action.action === "quota_refresh" ? "额度已刷新。" : "";
|
|
2333
|
+
await audit(event.operator_id, action.action, "device", config.instanceId, "allowed");
|
|
2334
|
+
await updateQuotaCard(event, detail, cardRecord);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
case "quota_device":
|
|
2338
|
+
await audit(event.operator_id, action.action, "device", config.instanceId, "allowed");
|
|
2339
|
+
await updateDeviceCard(event, "已返回设备控制台。", cardRecord);
|
|
2340
|
+
return;
|
|
2341
|
+
case "device_projects": {
|
|
2342
|
+
await projectRegistry.refresh();
|
|
2343
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
2344
|
+
await replyProjectCard(event, project, "", "device-projects", { conversationKey, ownerId });
|
|
2345
|
+
feedback = "项目工作台已发送到下方。";
|
|
2346
|
+
break;
|
|
2347
|
+
}
|
|
2348
|
+
case "device_settings":
|
|
2349
|
+
await replyControlCenter(event, "", "device-settings", { conversationKey, ownerId });
|
|
2350
|
+
feedback = "模型与权限设置已发送到下方。";
|
|
2351
|
+
break;
|
|
2352
|
+
case "device_sessions":
|
|
2353
|
+
await replySessionCenter(event, "", "device-sessions", { conversationKey, ownerId });
|
|
2354
|
+
feedback = "历史会话已发送到下方。";
|
|
2355
|
+
break;
|
|
2356
|
+
case "device_tasks":
|
|
2357
|
+
await replyTaskCenter(event, "device-tasks", { conversationKey, ownerId });
|
|
2358
|
+
feedback = "任务中心已发送到下方。";
|
|
2359
|
+
break;
|
|
2360
|
+
case "device_new_session": {
|
|
2361
|
+
const cancelledActive = runner.cancel(conversationKey);
|
|
2362
|
+
const cancelledQueued = queue.cancelPending(conversationKey);
|
|
2363
|
+
await closeRuntimeInteractionsForConversation(conversationKey);
|
|
2364
|
+
const reset = await state.resetThread(conversationKey);
|
|
2365
|
+
feedback = `已开启新会话 · 旧上下文${reset ? "已清除" : "原本为空"} · 停止 ${cancelledActive ? 1 : 0} 个运行任务、${cancelledQueued} 个排队任务。`;
|
|
2366
|
+
break;
|
|
2367
|
+
}
|
|
2368
|
+
case "device_stop": {
|
|
2369
|
+
const activeTask = runner.getActiveTask(conversationKey);
|
|
2370
|
+
const cancelled = runner.cancel(conversationKey);
|
|
2371
|
+
if (activeTask && cancelled)
|
|
2372
|
+
await closeRuntimeInteractionsForTask(activeTask);
|
|
2373
|
+
feedback = cancelled ? `已向任务 ${activeTask ?? "当前任务"} 发送停止请求。` : "当前没有运行中的任务。";
|
|
2374
|
+
break;
|
|
2375
|
+
}
|
|
2376
|
+
case "device_reconnect": {
|
|
2377
|
+
try {
|
|
2378
|
+
const models = await runner.listModels();
|
|
2379
|
+
feedback = `Codex 已重新连接,可用模型 ${models.length} 个。`;
|
|
2380
|
+
}
|
|
2381
|
+
catch (error) {
|
|
2382
|
+
feedback = `Codex 重连失败:${error instanceof Error ? error.message : String(error)}`;
|
|
2383
|
+
}
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
case "remote_ready_enable": {
|
|
2387
|
+
await state.setRemoteReady(true);
|
|
2388
|
+
try {
|
|
2389
|
+
const status = await remoteReady.setEnabled(true);
|
|
2390
|
+
if (!status.supported) {
|
|
2391
|
+
await state.setRemoteReady(false);
|
|
2392
|
+
await remoteReady.setEnabled(false);
|
|
2393
|
+
feedback = "当前系统不支持 Remote Ready;这项能力仅在 macOS 上可用。";
|
|
2394
|
+
}
|
|
2395
|
+
else {
|
|
2396
|
+
feedback = status.active
|
|
2397
|
+
? "远程就绪已开启;桥接服务运行期间会阻止 macOS 因空闲自动睡眠。"
|
|
2398
|
+
: `远程就绪未能启动:${status.lastError ?? "未知原因"}`;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
catch (error) {
|
|
2402
|
+
feedback = `远程就绪未能启动:${error instanceof Error ? error.message : String(error)}`;
|
|
2403
|
+
}
|
|
2404
|
+
break;
|
|
2405
|
+
}
|
|
2406
|
+
case "remote_ready_disable":
|
|
2407
|
+
await state.setRemoteReady(false);
|
|
2408
|
+
await remoteReady.setEnabled(false);
|
|
2409
|
+
feedback = "远程就绪已关闭;macOS 将恢复原有睡眠策略。";
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
await audit(event.operator_id, action.action, "device", config.instanceId, "allowed", feedback);
|
|
2413
|
+
await updateDeviceCard(event, feedback, cardRecord);
|
|
2414
|
+
}
|
|
2415
|
+
async function updateDeviceCard(event, feedback, cardRecord = deviceCards.get(event.message_id)) {
|
|
2416
|
+
if (!cardRecord) {
|
|
2417
|
+
await replyDeviceCard(event, feedback, "device-recreated");
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
const snapshot = await buildDeviceSnapshot(cardRecord.conversationKey, cardRecord.ownerId, feedback);
|
|
2421
|
+
try {
|
|
2422
|
+
cardRecord.sequence += 1;
|
|
2423
|
+
await lark.updateCard(cardRecord.cardId, renderDeviceCard(snapshot), cardRecord.sequence);
|
|
2424
|
+
await state.upsertDeviceCard({
|
|
2425
|
+
messageId: event.message_id,
|
|
2426
|
+
cardId: cardRecord.cardId,
|
|
2427
|
+
conversationKey: cardRecord.conversationKey,
|
|
2428
|
+
ownerId: cardRecord.ownerId,
|
|
2429
|
+
sequence: cardRecord.sequence,
|
|
2430
|
+
createdAt: cardRecord.createdAt,
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
catch (error) {
|
|
2434
|
+
console.error(`[bridge] device card update failed message=${logRef(event.message_id)}`, error);
|
|
2435
|
+
deviceCards.delete(event.message_id);
|
|
2436
|
+
await state.removeDeviceCard(event.message_id);
|
|
2437
|
+
await replyDeviceCard(event, feedback, "device-update-fallback", {
|
|
2438
|
+
conversationKey: cardRecord.conversationKey,
|
|
2439
|
+
ownerId: cardRecord.ownerId,
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
async function updateQuotaCard(event, feedback, cardRecord = deviceCards.get(event.message_id)) {
|
|
2444
|
+
if (!cardRecord) {
|
|
2445
|
+
await replyQuotaCard(event, feedback, "quota-recreated");
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
const quota = await readAccountQuota();
|
|
2449
|
+
try {
|
|
2450
|
+
cardRecord.sequence += 1;
|
|
2451
|
+
await lark.updateCard(cardRecord.cardId, renderQuotaCard({
|
|
2452
|
+
deviceName: hostname() || "本地设备",
|
|
2453
|
+
quota,
|
|
2454
|
+
...(feedback ? { feedback } : {}),
|
|
2455
|
+
}), cardRecord.sequence);
|
|
2456
|
+
await state.upsertDeviceCard({
|
|
2457
|
+
messageId: event.message_id,
|
|
2458
|
+
cardId: cardRecord.cardId,
|
|
2459
|
+
conversationKey: cardRecord.conversationKey,
|
|
2460
|
+
ownerId: cardRecord.ownerId,
|
|
2461
|
+
sequence: cardRecord.sequence,
|
|
2462
|
+
createdAt: cardRecord.createdAt,
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
catch (error) {
|
|
2466
|
+
console.error(`[bridge] quota card update failed message=${logRef(event.message_id)}`, error);
|
|
2467
|
+
deviceCards.delete(event.message_id);
|
|
2468
|
+
await state.removeDeviceCard(event.message_id);
|
|
2469
|
+
await replyQuotaCard(event, feedback, "quota-update-fallback", {
|
|
2470
|
+
conversationKey: cardRecord.conversationKey,
|
|
2471
|
+
ownerId: cardRecord.ownerId,
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
async function handleV5CardAction(event, action) {
|
|
2476
|
+
if (action.action.startsWith("onboarding_")) {
|
|
2477
|
+
await handleOnboardingCardAction(event, action);
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
const source = action.action.startsWith("session_") || action.action === "select_session"
|
|
2481
|
+
? sessionCards
|
|
2482
|
+
: action.action.startsWith("tasks_")
|
|
2483
|
+
? taskCenterCards
|
|
2484
|
+
: action.action.startsWith("team_")
|
|
2485
|
+
? teamCards
|
|
2486
|
+
: controlCards;
|
|
2487
|
+
const record = source.get(event.message_id);
|
|
2488
|
+
const readOnlyActions = new Set([
|
|
2489
|
+
"settings_refresh",
|
|
2490
|
+
"settings_sessions",
|
|
2491
|
+
"settings_tasks",
|
|
2492
|
+
"session_settings",
|
|
2493
|
+
"session_refresh",
|
|
2494
|
+
"tasks_settings",
|
|
2495
|
+
"tasks_refresh",
|
|
2496
|
+
"tasks_review",
|
|
2497
|
+
"team_tasks",
|
|
2498
|
+
"team_runbooks",
|
|
2499
|
+
"team_projects",
|
|
2500
|
+
"team_refresh",
|
|
2501
|
+
]);
|
|
2502
|
+
if (!record ||
|
|
2503
|
+
!conversationBelongsToChat(record.conversationKey, event.chat_id) ||
|
|
2504
|
+
!(readOnlyActions.has(action.action)
|
|
2505
|
+
? canViewOwnedResource(config, event.operator_id, record.ownerId)
|
|
2506
|
+
: canControlOwnedResource(config, event.operator_id, record.ownerId))) {
|
|
2507
|
+
await productReply(event.message_id, "这张控制卡已过期,或你没有权限操作它。请重新发送“设置”“会话”或“任务”。", replyKey(event.event_id, "v5-card-unavailable"), "settings-unavailable");
|
|
2508
|
+
await audit(event.operator_id, action.action, "security", event.message_id, "denied");
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
switch (action.action) {
|
|
2512
|
+
case "lease_full_once":
|
|
2513
|
+
case "lease_full_30m":
|
|
2514
|
+
case "lease_full_session":
|
|
2515
|
+
case "lease_full_revoke": {
|
|
2516
|
+
const revoking = action.action === "lease_full_revoke";
|
|
2517
|
+
if (!revoking && event.operator_id !== record.ownerId) {
|
|
2518
|
+
await audit(event.operator_id, action.action, "settings", record.conversationKey, "denied", "full access must be self-granted");
|
|
2519
|
+
await updateControlCard(event, record, "管理员可以撤销他人的临时权限,但不能代替成员授予完全访问。");
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
if (revoking) {
|
|
2523
|
+
const revoked = state.revokePermissionLease(record.conversationKey);
|
|
2524
|
+
await audit(event.operator_id, "permission_lease.revoke", "settings", record.conversationKey, revoked ? "allowed" : "denied", revoked ? "manual revoke" : "no active lease");
|
|
2525
|
+
await updateControlCard(event, record, revoked
|
|
2526
|
+
? "临时完全访问已撤销;已运行任务不受影响,后续任务恢复默认权限。"
|
|
2527
|
+
: "当前没有可撤销的临时完全访问。");
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
const scope = action.action === "lease_full_once"
|
|
2531
|
+
? "next-task"
|
|
2532
|
+
: action.action === "lease_full_session"
|
|
2533
|
+
? "session"
|
|
2534
|
+
: "timed";
|
|
2535
|
+
const feedback = await grantFullAccessLease(record.conversationKey, record.ownerId, scope);
|
|
2536
|
+
await updateControlCard(event, record, feedback);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
case "select_model":
|
|
2540
|
+
case "select_effort":
|
|
2541
|
+
case "select_sandbox": {
|
|
2542
|
+
if (!event.option) {
|
|
2543
|
+
await updateControlCard(event, record, "没有识别到所选值,请刷新后重试。");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const kind = action.action === "select_model"
|
|
2547
|
+
? "model"
|
|
2548
|
+
: action.action === "select_effort"
|
|
2549
|
+
? "effort"
|
|
2550
|
+
: "sandbox";
|
|
2551
|
+
try {
|
|
2552
|
+
const feedback = await applySettingsChange(record.conversationKey, record.ownerId, {
|
|
2553
|
+
kind,
|
|
2554
|
+
value: event.option,
|
|
2555
|
+
});
|
|
2556
|
+
await audit(event.operator_id, action.action, "settings", record.conversationKey, "allowed");
|
|
2557
|
+
await updateControlCard(event, record, feedback);
|
|
2558
|
+
}
|
|
2559
|
+
catch (error) {
|
|
2560
|
+
await updateControlCard(event, record, `设置未修改:${error.message}`);
|
|
2561
|
+
}
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
case "settings_refresh":
|
|
2565
|
+
await updateControlCard(event, record, "设置已刷新。");
|
|
2566
|
+
return;
|
|
2567
|
+
case "settings_sessions":
|
|
2568
|
+
await replySessionCenter(event, "", "settings-sessions", record);
|
|
2569
|
+
await updateControlCard(event, record, "会话中心已发送到下方。");
|
|
2570
|
+
return;
|
|
2571
|
+
case "settings_tasks":
|
|
2572
|
+
await replyTaskCenter(event, "settings-tasks", record);
|
|
2573
|
+
await updateControlCard(event, record, "任务中心已发送到下方。");
|
|
2574
|
+
return;
|
|
2575
|
+
case "settings_new":
|
|
2576
|
+
case "session_new":
|
|
2577
|
+
case "tasks_new": {
|
|
2578
|
+
const active = runner.cancel(record.conversationKey);
|
|
2579
|
+
const queued = queue.cancelPending(record.conversationKey);
|
|
2580
|
+
await closeRuntimeInteractionsForConversation(record.conversationKey);
|
|
2581
|
+
const reset = await state.resetThread(record.conversationKey);
|
|
2582
|
+
const feedback = `已开启新会话 · 旧上下文${reset ? "已清除" : "原本为空"} · 停止 ${active ? 1 : 0} 个运行任务、${queued} 个排队任务。`;
|
|
2583
|
+
await audit(event.operator_id, "session.new", "session", record.conversationKey, "allowed");
|
|
2584
|
+
if (source === controlCards)
|
|
2585
|
+
await updateControlCard(event, record, feedback);
|
|
2586
|
+
else if (source === sessionCards)
|
|
2587
|
+
await updateSessionCard(event, record, feedback);
|
|
2588
|
+
else
|
|
2589
|
+
await updateTaskCenterCard(event, record);
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
case "select_session": {
|
|
2593
|
+
if (!event.option) {
|
|
2594
|
+
await updateSessionCard(event, record, "没有识别到所选会话,请刷新后重试。");
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2598
|
+
const sessions = visibleSessions(record.ownerId, project.path, state.getThread(record.conversationKey), await runner.listThreads(project.path, 50));
|
|
2599
|
+
const selected = sessions.find((session) => session.id === event.option);
|
|
2600
|
+
if (!selected) {
|
|
2601
|
+
await updateSessionCard(event, record, "该会话已不存在,或不属于当前项目。");
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
const active = runner.cancel(record.conversationKey);
|
|
2605
|
+
const queued = queue.cancelPending(record.conversationKey);
|
|
2606
|
+
await closeRuntimeInteractionsForConversation(record.conversationKey);
|
|
2607
|
+
await state.setThread(record.conversationKey, selected.id);
|
|
2608
|
+
await state.registerThreadAccess(selected.id, record.conversationKey, record.ownerId, project.path);
|
|
2609
|
+
await audit(event.operator_id, "session.resume", "session", selected.id, "allowed");
|
|
2610
|
+
await updateSessionCard(event, record, `已恢复“${selected.name || selected.preview || selected.id.slice(0, 8)}” · 停止 ${active ? 1 : 0} 个运行任务、${queued} 个排队任务。`);
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
case "session_compact": {
|
|
2614
|
+
const threadId = state.getThread(record.conversationKey);
|
|
2615
|
+
if (!threadId) {
|
|
2616
|
+
await updateSessionCard(event, record, "当前没有可压缩的会话。");
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
await runner.compactThread(threadId);
|
|
2620
|
+
await audit(event.operator_id, "session.compact", "session", threadId, "allowed");
|
|
2621
|
+
await updateSessionCard(event, record, "当前会话已完成上下文压缩。");
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
case "session_settings":
|
|
2625
|
+
await replyControlCenter(event, "", "session-settings", record);
|
|
2626
|
+
await updateSessionCard(event, record, "控制中心已发送到下方。");
|
|
2627
|
+
return;
|
|
2628
|
+
case "session_refresh":
|
|
2629
|
+
await updateSessionCard(event, record, "会话列表已刷新。");
|
|
2630
|
+
return;
|
|
2631
|
+
case "tasks_stop": {
|
|
2632
|
+
const active = runner.cancel(record.conversationKey);
|
|
2633
|
+
const queued = queue.cancelPending(record.conversationKey);
|
|
2634
|
+
await closeRuntimeInteractionsForConversation(record.conversationKey);
|
|
2635
|
+
await audit(event.operator_id, "task.cancel_all", "task", record.conversationKey, "allowed");
|
|
2636
|
+
await updateTaskCenterCard(event, record);
|
|
2637
|
+
if (!active && queued === 0) {
|
|
2638
|
+
await productReply(event.message_id, "当前没有属于这张卡的运行或排队任务。", replyKey(event.event_id, "tasks-stop-empty"), "task-stop-empty");
|
|
2639
|
+
}
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
case "tasks_cancel_one": {
|
|
2643
|
+
const task = action.task_id ? tasks.get(action.task_id) : undefined;
|
|
2644
|
+
if (!task ||
|
|
2645
|
+
!canControlTask(config, event.operator_id, task)) {
|
|
2646
|
+
await productReply(event.message_id, "这个任务已结束、已过期或不属于你,请刷新任务中心。", replyKey(event.event_id, "task-cancel-one-unavailable"), "task-unavailable");
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
const queued = queue.cancelTask(task.id);
|
|
2650
|
+
const running = runner.getActiveTask(task.conversationKey) === task.id &&
|
|
2651
|
+
runner.cancel(task.conversationKey);
|
|
2652
|
+
if (running)
|
|
2653
|
+
await closeRuntimeInteractionsForTask(task.id);
|
|
2654
|
+
await audit(event.operator_id, "task.cancel", "task", task.id, queued || running ? "allowed" : "denied", queued ? "queued" : running ? "running" : "already finished");
|
|
2655
|
+
await updateTaskCenterCard(event, record);
|
|
2656
|
+
if (!queued && !running) {
|
|
2657
|
+
await productReply(event.message_id, "任务已经结束,无需停止。", replyKey(event.event_id, "task-cancel-one-finished"), "task-finished");
|
|
2658
|
+
}
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
case "tasks_review": {
|
|
2662
|
+
const task = action.task_id ? tasks.get(action.task_id) : undefined;
|
|
2663
|
+
if (!task ||
|
|
2664
|
+
!conversationBelongsToChat(task.conversationKey, event.chat_id) ||
|
|
2665
|
+
!canViewTask(config, event.operator_id, task)) {
|
|
2666
|
+
await productReply(event.message_id, "这个任务已过期或不属于当前聊天,请刷新任务中心。", replyKey(event.event_id, "task-review-unavailable"), "task-review-unavailable");
|
|
2667
|
+
await audit(event.operator_id, "task.review", "task", action.task_id ?? "unknown", "denied");
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
await replyReviewCard(event, task, "从任务中心打开。", "task-center-review");
|
|
2671
|
+
await audit(event.operator_id, "task.review", "task", task.id, "allowed");
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
case "tasks_settings":
|
|
2675
|
+
await replyControlCenter(event, "", "tasks-settings", record);
|
|
2676
|
+
await updateTaskCenterCard(event, record);
|
|
2677
|
+
return;
|
|
2678
|
+
case "tasks_refresh":
|
|
2679
|
+
await updateTaskCenterCard(event, record);
|
|
2680
|
+
return;
|
|
2681
|
+
case "team_tasks":
|
|
2682
|
+
await replyTaskCenter(event, "team-tasks", record);
|
|
2683
|
+
await updateTeamDashboardCard(event, record, "任务中心已发送到下方。");
|
|
2684
|
+
return;
|
|
2685
|
+
case "team_projects": {
|
|
2686
|
+
await projectRegistry.refresh();
|
|
2687
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2688
|
+
await replyProjectCard(event, project, "从团队工作台打开。", "team-projects", record);
|
|
2689
|
+
await updateTeamDashboardCard(event, record, "项目工作台已发送到下方。");
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
case "team_runbooks":
|
|
2693
|
+
await replyRunbookCenter(event, "从团队工作台打开。", "team-runbooks", record);
|
|
2694
|
+
await updateTeamDashboardCard(event, record, "运行手册已发送到下方。");
|
|
2695
|
+
return;
|
|
2696
|
+
case "team_refresh":
|
|
2697
|
+
await updateTeamDashboardCard(event, record, "团队数据已刷新。");
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
async function grantFullAccessLease(conversationKey, ownerId, scope) {
|
|
2702
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
2703
|
+
let projectPolicy = defaultProjectPolicy();
|
|
2704
|
+
try {
|
|
2705
|
+
projectPolicy = await loadProjectPolicy(project.path);
|
|
2706
|
+
}
|
|
2707
|
+
catch (error) {
|
|
2708
|
+
await audit(ownerId, "permission_lease.grant", "project", project.path, "denied", "invalid project policy");
|
|
2709
|
+
return `仓库策略无效,临时完全访问没有启用:${error.message}`;
|
|
2710
|
+
}
|
|
2711
|
+
const maximumSandbox = applyProjectSandboxMaximum(maxSandboxForActor(ownerId), projectPolicy);
|
|
2712
|
+
if (maximumSandbox !== "danger-full-access") {
|
|
2713
|
+
await audit(ownerId, "permission_lease.grant", "settings", conversationKey, "denied", "role or service maximum does not allow full access");
|
|
2714
|
+
return projectPolicy.maximumSandbox
|
|
2715
|
+
? "当前仓库策略不允许完全访问,设置没有修改。"
|
|
2716
|
+
: "当前角色或服务上限不允许完全访问,设置没有修改。";
|
|
2717
|
+
}
|
|
2718
|
+
const threadId = state.getThread(conversationKey);
|
|
2719
|
+
if (scope === "session" && !threadId) {
|
|
2720
|
+
await audit(ownerId, "permission_lease.grant", "settings", conversationKey, "denied", "no active thread");
|
|
2721
|
+
return "当前还没有 Codex 会话;请先完成一个任务,再授权“当前会话”。";
|
|
2722
|
+
}
|
|
2723
|
+
const ttlMinutes = scope === "next-task"
|
|
2724
|
+
? FULL_ACCESS_ONCE_TTL_MINUTES
|
|
2725
|
+
: scope === "session"
|
|
2726
|
+
? FULL_ACCESS_SESSION_TTL_MINUTES
|
|
2727
|
+
: FULL_ACCESS_TIMED_TTL_MINUTES;
|
|
2728
|
+
const lease = await state.setPermissionLease({
|
|
2729
|
+
conversationKey,
|
|
2730
|
+
ownerId,
|
|
2731
|
+
projectPath: project.path,
|
|
2732
|
+
scope,
|
|
2733
|
+
remainingUses: 1,
|
|
2734
|
+
expiresAt: new Date(Date.now() + ttlMinutes * 60_000).toISOString(),
|
|
2735
|
+
...(scope === "session" && threadId ? { threadId } : {}),
|
|
2736
|
+
});
|
|
2737
|
+
await audit(ownerId, "permission_lease.grant", "settings", conversationKey, "allowed", `${scope}:${lease.expiresAt}`);
|
|
2738
|
+
return `临时完全访问已启用:${permissionLeaseLabel(lease)}。已运行任务不变。`;
|
|
2739
|
+
}
|
|
2740
|
+
async function handleOnboardingCardAction(event, action) {
|
|
2741
|
+
const record = onboardingCards.get(event.message_id);
|
|
2742
|
+
if (!record ||
|
|
2743
|
+
!conversationBelongsToChat(record.conversationKey, event.chat_id) ||
|
|
2744
|
+
!canViewOwnedResource(config, event.operator_id, record.ownerId)) {
|
|
2745
|
+
await productReply(event.message_id, "这张引导卡已过期,或不属于你。请发送“新手引导”重新打开。", replyKey(event.event_id, "onboarding-unavailable"), "onboarding-unavailable");
|
|
2746
|
+
await audit(event.operator_id, action.action, "security", event.message_id, "denied");
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
const stateKey = onboardingStateKey(record.conversationKey, record.ownerId);
|
|
2750
|
+
let onboarding = state.getOnboarding(stateKey);
|
|
2751
|
+
if (!onboarding) {
|
|
2752
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "active", 1);
|
|
2753
|
+
}
|
|
2754
|
+
let feedback = "";
|
|
2755
|
+
switch (action.action) {
|
|
2756
|
+
case "onboarding_start":
|
|
2757
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "active", 1);
|
|
2758
|
+
feedback = "";
|
|
2759
|
+
break;
|
|
2760
|
+
case "onboarding_next":
|
|
2761
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "completed", 4);
|
|
2762
|
+
feedback = "";
|
|
2763
|
+
break;
|
|
2764
|
+
case "onboarding_back":
|
|
2765
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "active", 1);
|
|
2766
|
+
feedback = "";
|
|
2767
|
+
break;
|
|
2768
|
+
case "onboarding_finish":
|
|
2769
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "completed", 4);
|
|
2770
|
+
feedback = "";
|
|
2771
|
+
break;
|
|
2772
|
+
case "onboarding_dismiss":
|
|
2773
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "dismissed", onboarding.step);
|
|
2774
|
+
feedback = "已暂停自动引导,不影响继续使用。";
|
|
2775
|
+
break;
|
|
2776
|
+
case "onboarding_restart":
|
|
2777
|
+
onboarding = await state.setOnboarding(stateKey, record.ownerId, "active", 1);
|
|
2778
|
+
feedback = "已重新开始。";
|
|
2779
|
+
break;
|
|
2780
|
+
case "onboarding_device":
|
|
2781
|
+
await replyDeviceCard(event, "", "onboarding-device", record);
|
|
2782
|
+
feedback = "";
|
|
2783
|
+
break;
|
|
2784
|
+
case "onboarding_projects": {
|
|
2785
|
+
try {
|
|
2786
|
+
await projectRegistry.refresh();
|
|
2787
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2788
|
+
await replyProjectCard(event, project, "", "onboarding-projects", record);
|
|
2789
|
+
feedback = "";
|
|
2790
|
+
}
|
|
2791
|
+
catch {
|
|
2792
|
+
feedback = "当前没有可选择的授权项目,请先让管理员完成项目配置。";
|
|
2793
|
+
}
|
|
2794
|
+
break;
|
|
2795
|
+
}
|
|
2796
|
+
case "onboarding_settings":
|
|
2797
|
+
await replyControlCenter(event, "", "onboarding-settings", record);
|
|
2798
|
+
feedback = "";
|
|
2799
|
+
break;
|
|
2800
|
+
case "onboarding_sessions":
|
|
2801
|
+
await replySessionCenter(event, "", "onboarding-sessions", record);
|
|
2802
|
+
feedback = "";
|
|
2803
|
+
break;
|
|
2804
|
+
case "onboarding_tasks":
|
|
2805
|
+
await replyTaskCenter(event, "onboarding-tasks", record);
|
|
2806
|
+
feedback = "";
|
|
2807
|
+
break;
|
|
2808
|
+
default:
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
await audit(event.operator_id, action.action, "onboarding", record.conversationKey, "allowed", `${onboarding.status}:${onboarding.step}`);
|
|
2812
|
+
await updateOnboardingCard(event, record, onboarding, feedback);
|
|
2813
|
+
}
|
|
2814
|
+
async function updateOnboardingCard(event, record, onboarding, feedback = "") {
|
|
2815
|
+
const snapshot = await buildOnboardingSnapshot(onboarding, record.conversationKey, record.ownerId, feedback);
|
|
2816
|
+
try {
|
|
2817
|
+
record.sequence += 1;
|
|
2818
|
+
await lark.updateCard(record.cardId, renderOnboardingCard(snapshot), record.sequence);
|
|
2819
|
+
}
|
|
2820
|
+
catch (error) {
|
|
2821
|
+
console.error("[bridge] onboarding card update failed", error);
|
|
2822
|
+
onboardingCards.delete(event.message_id);
|
|
2823
|
+
await replyOnboardingCard(event, onboarding, feedback, "onboarding-recreated", record);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
async function updateControlCard(event, record, feedback = "") {
|
|
2827
|
+
const snapshot = await buildControlSnapshot(record.conversationKey, record.ownerId, feedback);
|
|
2828
|
+
try {
|
|
2829
|
+
record.sequence += 1;
|
|
2830
|
+
await lark.updateCard(record.cardId, renderControlCenterCard(snapshot), record.sequence);
|
|
2831
|
+
}
|
|
2832
|
+
catch (error) {
|
|
2833
|
+
console.error("[bridge] control card update failed", error);
|
|
2834
|
+
controlCards.delete(event.message_id);
|
|
2835
|
+
await replyControlCenter(event, feedback, "settings-recreated", record);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
async function updateSessionCard(event, record, feedback = "") {
|
|
2839
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2840
|
+
const currentThreadId = state.getThread(record.conversationKey);
|
|
2841
|
+
const sessions = visibleSessions(record.ownerId, project.path, currentThreadId, await runner.listThreads(project.path, 50)).slice(0, 12);
|
|
2842
|
+
const snapshot = {
|
|
2843
|
+
projectName: project.name,
|
|
2844
|
+
sessions,
|
|
2845
|
+
...(currentThreadId ? { currentThreadId } : {}),
|
|
2846
|
+
...(feedback ? { feedback } : {}),
|
|
2847
|
+
};
|
|
2848
|
+
try {
|
|
2849
|
+
record.sequence += 1;
|
|
2850
|
+
await lark.updateCard(record.cardId, renderSessionCenterCard(snapshot), record.sequence);
|
|
2851
|
+
}
|
|
2852
|
+
catch (error) {
|
|
2853
|
+
console.error("[bridge] session card update failed", error);
|
|
2854
|
+
sessionCards.delete(event.message_id);
|
|
2855
|
+
await replySessionCenter(event, feedback, "sessions-recreated", record);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
async function updateTaskCenterCard(event, record) {
|
|
2859
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2860
|
+
const snapshot = buildTaskCenterSnapshot(record.ownerId, project);
|
|
2861
|
+
try {
|
|
2862
|
+
record.sequence += 1;
|
|
2863
|
+
await lark.updateCard(record.cardId, renderTaskCenterCard(snapshot), record.sequence);
|
|
2864
|
+
}
|
|
2865
|
+
catch (error) {
|
|
2866
|
+
console.error("[bridge] task center card update failed", error);
|
|
2867
|
+
taskCenterCards.delete(event.message_id);
|
|
2868
|
+
await replyTaskCenter(event, "tasks-recreated", record);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
async function updateTeamDashboardCard(event, record, feedback = "") {
|
|
2872
|
+
const snapshot = buildTeamDashboardSnapshot(record.ownerId, feedback);
|
|
2873
|
+
try {
|
|
2874
|
+
record.sequence += 1;
|
|
2875
|
+
await lark.updateCard(record.cardId, renderTeamDashboardCard(snapshot), record.sequence);
|
|
2876
|
+
}
|
|
2877
|
+
catch (error) {
|
|
2878
|
+
console.error("[bridge] team dashboard card update failed", error);
|
|
2879
|
+
teamCards.delete(event.message_id);
|
|
2880
|
+
await replyTeamDashboard(event, feedback, "team-recreated", record);
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
async function updateRunbookCenterCard(event, record, feedback = "") {
|
|
2884
|
+
const project = await currentProject(record.conversationKey, record.ownerId);
|
|
2885
|
+
const snapshot = {
|
|
2886
|
+
projectName: project.name,
|
|
2887
|
+
catalog: await loadProjectRunbooks(project.path),
|
|
2888
|
+
canOperate: canOperate(config, record.ownerId),
|
|
2889
|
+
...(feedback ? { feedback } : {}),
|
|
2890
|
+
};
|
|
2891
|
+
try {
|
|
2892
|
+
record.sequence += 1;
|
|
2893
|
+
await lark.updateCard(record.cardId, renderRunbookCenterCard(snapshot), record.sequence);
|
|
2894
|
+
}
|
|
2895
|
+
catch (error) {
|
|
2896
|
+
console.error("[bridge] runbook center card update failed", error);
|
|
2897
|
+
runbookCards.delete(event.message_id);
|
|
2898
|
+
await replyRunbookCenter(event, feedback, "runbooks-recreated", record);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
async function requestRuntimeApproval(record, request) {
|
|
2902
|
+
const requestId = runtimeInteractionId("approval", record.id, request.id);
|
|
2903
|
+
const replyToMessageId = record.card?.messageId || record.conversation?.messageId || record.replyToMessageId;
|
|
2904
|
+
try {
|
|
2905
|
+
const cardId = await lark.createCard(renderRuntimeApprovalCard(requestId, request, record.project.name, "pending", config.confirmationTtlMinutes));
|
|
2906
|
+
const messageId = await lark.replyCard(replyToMessageId, cardId, replyKey(record.seed, `runtime-approval-${requestId}`));
|
|
2907
|
+
if (!messageId)
|
|
2908
|
+
throw new Error("飞书没有返回运行时确认卡消息 ID");
|
|
2909
|
+
await taskLiveSession(record)?.addActionNote("Codex 正在等待你的运行时权限确认。");
|
|
2910
|
+
return await new Promise((resolve) => {
|
|
2911
|
+
const timer = setTimeout(() => {
|
|
2912
|
+
const pending = pendingRuntimeApprovals.get(requestId);
|
|
2913
|
+
if (pending)
|
|
2914
|
+
void resolveRuntimeApproval(pending, "decline", "expired");
|
|
2915
|
+
}, config.confirmationTtlMinutes * 60_000);
|
|
2916
|
+
timer.unref();
|
|
2917
|
+
pendingRuntimeApprovals.set(requestId, {
|
|
2918
|
+
id: requestId,
|
|
2919
|
+
taskId: record.id,
|
|
2920
|
+
conversationKey: record.conversationKey,
|
|
2921
|
+
request,
|
|
2922
|
+
projectLabel: record.project.name,
|
|
2923
|
+
cardId,
|
|
2924
|
+
messageId,
|
|
2925
|
+
sequence: 0,
|
|
2926
|
+
timer,
|
|
2927
|
+
resolve,
|
|
2928
|
+
});
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
catch (error) {
|
|
2932
|
+
console.error(`[bridge] unable to create runtime approval task=${record.id}`, error);
|
|
2933
|
+
await taskLiveSession(record)?.addActionNote("运行时确认卡创建失败,本次权限请求已安全拒绝。");
|
|
2934
|
+
return "decline";
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
async function requestRuntimeUserInput(record, request) {
|
|
2938
|
+
const answers = {};
|
|
2939
|
+
for (let index = 0; index < request.questions.length; index += 1) {
|
|
2940
|
+
const question = request.questions[index];
|
|
2941
|
+
if (!question)
|
|
2942
|
+
continue;
|
|
2943
|
+
answers[question.id] = await requestSingleRuntimeQuestion(record, request, question, { index: index + 1, total: request.questions.length });
|
|
2944
|
+
}
|
|
2945
|
+
return answers;
|
|
2946
|
+
}
|
|
2947
|
+
async function requestSingleRuntimeQuestion(record, request, question, position) {
|
|
2948
|
+
const requestId = runtimeInteractionId("question", record.id, `${request.id}-${question.id}-${position.index}`);
|
|
2949
|
+
const replyToMessageId = record.card?.messageId || record.conversation?.messageId || record.replyToMessageId;
|
|
2950
|
+
const previousId = pendingQuestionByConversation.get(record.conversationKey);
|
|
2951
|
+
const previous = previousId ? pendingRuntimeQuestions.get(previousId) : undefined;
|
|
2952
|
+
if (previous)
|
|
2953
|
+
await resolveRuntimeQuestion(previous, "", "expired");
|
|
2954
|
+
const requestedTimeout = request.autoResolutionMs ?? config.confirmationTtlMinutes * 60_000;
|
|
2955
|
+
const timeoutMs = Math.max(1_000, Math.min(requestedTimeout, config.confirmationTtlMinutes * 60_000));
|
|
2956
|
+
const ttlMinutes = Math.max(1, Math.ceil(timeoutMs / 60_000));
|
|
2957
|
+
try {
|
|
2958
|
+
const cardId = await lark.createCard(renderRuntimeQuestionCard(requestId, question, position, "pending", "", ttlMinutes));
|
|
2959
|
+
const messageId = await lark.replyCard(replyToMessageId, cardId, replyKey(record.seed, `runtime-question-${requestId}`));
|
|
2960
|
+
if (!messageId)
|
|
2961
|
+
throw new Error("飞书没有返回实时追问卡消息 ID");
|
|
2962
|
+
await taskLiveSession(record)?.addActionNote(`Codex 正在等待你的回答:${question.header}`);
|
|
2963
|
+
return await new Promise((resolve) => {
|
|
2964
|
+
const timer = setTimeout(() => {
|
|
2965
|
+
const pending = pendingRuntimeQuestions.get(requestId);
|
|
2966
|
+
if (pending)
|
|
2967
|
+
void resolveRuntimeQuestion(pending, "", "expired");
|
|
2968
|
+
}, timeoutMs);
|
|
2969
|
+
timer.unref();
|
|
2970
|
+
pendingRuntimeQuestions.set(requestId, {
|
|
2971
|
+
id: requestId,
|
|
2972
|
+
taskId: record.id,
|
|
2973
|
+
conversationKey: record.conversationKey,
|
|
2974
|
+
question,
|
|
2975
|
+
position,
|
|
2976
|
+
ttlMinutes,
|
|
2977
|
+
cardId,
|
|
2978
|
+
messageId,
|
|
2979
|
+
sequence: 0,
|
|
2980
|
+
timer,
|
|
2981
|
+
resolve,
|
|
2982
|
+
});
|
|
2983
|
+
pendingQuestionByConversation.set(record.conversationKey, requestId);
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
catch (error) {
|
|
2987
|
+
console.error(`[bridge] unable to create runtime question task=${record.id}`, error);
|
|
2988
|
+
await taskLiveSession(record)?.addActionNote("实时追问卡创建失败,Codex 将在没有回答的情况下继续。");
|
|
2989
|
+
return [];
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
async function handleRuntimeCardAction(event, action) {
|
|
2993
|
+
if (action.action === "answer_runtime" ||
|
|
2994
|
+
action.action === "cancel_runtime_question") {
|
|
2995
|
+
const pending = pendingRuntimeQuestions.get(action.request_id);
|
|
2996
|
+
const task = pending ? tasks.get(pending.taskId) : undefined;
|
|
2997
|
+
if (!pending ||
|
|
2998
|
+
!task ||
|
|
2999
|
+
!conversationBelongsToChat(pending.conversationKey, event.chat_id) ||
|
|
3000
|
+
!canControlTask(config, event.operator_id, task)) {
|
|
3001
|
+
await audit(event.operator_id, `runtime.question.${action.action}`, "task", pending?.taskId ?? action.request_id, "denied", pending ? "owner or chat mismatch" : "request unavailable");
|
|
3002
|
+
await productReply(event.message_id, "这个问题已经回答或失效。发送“任务”打开任务中心,查看最新状态。", replyKey(event.event_id, "runtime-question-unavailable"), "task-question-unavailable");
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
if (action.question_id && action.question_id !== pending.question.id) {
|
|
3006
|
+
await audit(event.operator_id, "runtime.question.answer", "task", pending.taskId, "denied", "question id mismatch");
|
|
3007
|
+
await productReply(event.message_id, "答案没有提交:问题标识不匹配。请使用最新的问题卡。", replyKey(event.event_id, "runtime-question-mismatch"), "task-question-invalid");
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
if (action.action === "cancel_runtime_question") {
|
|
3011
|
+
await resolveRuntimeQuestion(pending, "", "cancelled", event.operator_id);
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
if (pending.question.isSecret) {
|
|
3015
|
+
await productReply(event.message_id, "敏感问题不能通过飞书回答。请回本机处理,或点击“取消回答”。", replyKey(event.event_id, "secret-card-answer-blocked"), "security-secret-denied");
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
const answer = action.answer || event.option || "";
|
|
3019
|
+
if (!answer) {
|
|
3020
|
+
await productReply(event.message_id, "没有收到有效答案,问题仍在等待处理。请重新选择或直接发送下一条消息。", replyKey(event.event_id, "runtime-question-empty"), "task-question-waiting");
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
await resolveRuntimeQuestion(pending, answer, "answered", event.operator_id);
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
const pending = pendingRuntimeApprovals.get(action.request_id);
|
|
3027
|
+
const task = pending ? tasks.get(pending.taskId) : undefined;
|
|
3028
|
+
if (!pending ||
|
|
3029
|
+
!task ||
|
|
3030
|
+
!conversationBelongsToChat(pending.conversationKey, event.chat_id) ||
|
|
3031
|
+
!canControlTask(config, event.operator_id, task)) {
|
|
3032
|
+
await audit(event.operator_id, `runtime.approval.${action.action}`, "task", pending?.taskId ?? action.request_id, "denied", pending ? "owner or chat mismatch" : "request unavailable");
|
|
3033
|
+
await productReply(event.message_id, "这个权限请求已经处理或失效。发送“任务”打开任务中心,查看最新状态。", replyKey(event.event_id, "runtime-approval-unavailable"), "permission-unavailable");
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
if (action.action === "approve_runtime_once") {
|
|
3037
|
+
await resolveRuntimeApproval(pending, "accept", "accepted", event.operator_id);
|
|
3038
|
+
}
|
|
3039
|
+
else if (action.action === "approve_runtime_session") {
|
|
3040
|
+
await resolveRuntimeApproval(pending, "acceptForSession", "session", event.operator_id);
|
|
3041
|
+
}
|
|
3042
|
+
else {
|
|
3043
|
+
await resolveRuntimeApproval(pending, "decline", "declined", event.operator_id);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
async function resolveRuntimeApproval(pending, decision, stateName, actorId) {
|
|
3047
|
+
if (!pendingRuntimeApprovals.delete(pending.id))
|
|
3048
|
+
return;
|
|
3049
|
+
clearTimeout(pending.timer);
|
|
3050
|
+
pending.resolve(decision);
|
|
3051
|
+
try {
|
|
3052
|
+
pending.sequence += 1;
|
|
3053
|
+
await lark.updateCard(pending.cardId, renderRuntimeApprovalCard(pending.id, pending.request, pending.projectLabel, stateName, config.confirmationTtlMinutes), pending.sequence);
|
|
3054
|
+
}
|
|
3055
|
+
catch (error) {
|
|
3056
|
+
console.error(`[bridge] runtime approval card update failed id=${pending.id}`, error);
|
|
3057
|
+
}
|
|
3058
|
+
const task = tasks.get(pending.taskId);
|
|
3059
|
+
await audit(actorId ?? task?.ownerId ?? "system", `runtime.approval.${stateName}`, "task", pending.taskId, stateName === "expired" ? "failed" : "allowed", `${pending.request.kind}:${decision}`).catch((error) => {
|
|
3060
|
+
console.error(`[bridge] unable to audit runtime approval id=${pending.id}`, error);
|
|
3061
|
+
});
|
|
3062
|
+
}
|
|
3063
|
+
async function resolveRuntimeQuestion(pending, answer, stateName, actorId) {
|
|
3064
|
+
if (!pendingRuntimeQuestions.delete(pending.id))
|
|
3065
|
+
return;
|
|
3066
|
+
clearTimeout(pending.timer);
|
|
3067
|
+
if (pendingQuestionByConversation.get(pending.conversationKey) === pending.id) {
|
|
3068
|
+
pendingQuestionByConversation.delete(pending.conversationKey);
|
|
3069
|
+
}
|
|
3070
|
+
pending.resolve(answer ? [answer] : []);
|
|
3071
|
+
try {
|
|
3072
|
+
pending.sequence += 1;
|
|
3073
|
+
await lark.updateCard(pending.cardId, renderRuntimeQuestionCard(pending.id, pending.question, pending.position, stateName, answer, pending.ttlMinutes), pending.sequence);
|
|
3074
|
+
}
|
|
3075
|
+
catch (error) {
|
|
3076
|
+
console.error(`[bridge] runtime question card update failed id=${pending.id}`, error);
|
|
3077
|
+
}
|
|
3078
|
+
const task = tasks.get(pending.taskId);
|
|
3079
|
+
await audit(actorId ?? task?.ownerId ?? "system", `runtime.question.${stateName}`, "task", pending.taskId, stateName === "expired" ? "failed" : "allowed", pending.question.id).catch((error) => {
|
|
3080
|
+
console.error(`[bridge] unable to audit runtime question id=${pending.id}`, error);
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
async function closeRuntimeInteractionsForTask(taskId) {
|
|
3084
|
+
const task = tasks.get(taskId);
|
|
3085
|
+
if (task)
|
|
3086
|
+
task.allowThreadBinding = false;
|
|
3087
|
+
const approvals = [...pendingRuntimeApprovals.values()].filter((pending) => pending.taskId === taskId);
|
|
3088
|
+
const questions = [...pendingRuntimeQuestions.values()].filter((pending) => pending.taskId === taskId);
|
|
3089
|
+
await Promise.all([
|
|
3090
|
+
...approvals.map((pending) => resolveRuntimeApproval(pending, "decline", "expired")),
|
|
3091
|
+
...questions.map((pending) => resolveRuntimeQuestion(pending, "", "expired")),
|
|
3092
|
+
]);
|
|
3093
|
+
}
|
|
3094
|
+
async function closeRuntimeInteractionsForConversation(conversationKey) {
|
|
3095
|
+
for (const task of tasks.values()) {
|
|
3096
|
+
if (task.conversationKey === conversationKey)
|
|
3097
|
+
task.allowThreadBinding = false;
|
|
3098
|
+
}
|
|
3099
|
+
const taskIds = new Set([
|
|
3100
|
+
...[...pendingRuntimeApprovals.values()]
|
|
3101
|
+
.filter((pending) => pending.conversationKey === conversationKey)
|
|
3102
|
+
.map((pending) => pending.taskId),
|
|
3103
|
+
...[...pendingRuntimeQuestions.values()]
|
|
3104
|
+
.filter((pending) => pending.conversationKey === conversationKey)
|
|
3105
|
+
.map((pending) => pending.taskId),
|
|
3106
|
+
]);
|
|
3107
|
+
await Promise.all([...taskIds].map((taskId) => closeRuntimeInteractionsForTask(taskId)));
|
|
3108
|
+
}
|
|
3109
|
+
function runtimeInteractionId(kind, taskId, requestId) {
|
|
3110
|
+
return `${kind}-${taskId}-${requestId}`.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
|
|
3111
|
+
}
|
|
3112
|
+
async function requestExternalConfirmation(event, prompt, actions, workspaceSession = workspaceSessionForEvent(event, config)) {
|
|
3113
|
+
try {
|
|
3114
|
+
await createExternalConfirmation(event.message_id, workspaceSession.conversationKey, event.sender_id, prompt, actions, event.event_id, [], workspaceSession.replyInThread);
|
|
3115
|
+
}
|
|
3116
|
+
catch (error) {
|
|
3117
|
+
if (!(error instanceof ProjectPolicyError))
|
|
3118
|
+
throw error;
|
|
3119
|
+
await audit(event.sender_id, "project_policy.external_action", "project", (await currentProject(workspaceSession.conversationKey, event.sender_id)).path, "denied", error.message);
|
|
3120
|
+
await reply(event, `任务没有执行:${error.message}。`, "project-policy-denied");
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
async function createExternalConfirmation(replyToMessageId, conversationKey, ownerId, prompt, actions, seed, attachments = [], replyInThread = false) {
|
|
3124
|
+
const existing = [...pendingConfirmations.values()].find((confirmation) => confirmation.seed === seed &&
|
|
3125
|
+
confirmation.conversationKey === conversationKey &&
|
|
3126
|
+
Date.parse(confirmation.expiresAt) > Date.now());
|
|
3127
|
+
if (existing)
|
|
3128
|
+
return;
|
|
3129
|
+
const project = await currentProject(conversationKey, ownerId);
|
|
3130
|
+
const projectPolicy = await loadProjectPolicy(project.path);
|
|
3131
|
+
const actionDecision = decideProjectActions(projectPolicy, actions);
|
|
3132
|
+
if (actionDecision.blockedActions.length > 0) {
|
|
3133
|
+
throw new ProjectPolicyError(`当前仓库策略禁止:${actionDecision.blockedActions.map(externalActionLabel).join("、")}`);
|
|
3134
|
+
}
|
|
3135
|
+
const requestId = `confirm-${makeTaskId(seed)}`;
|
|
3136
|
+
const createdAt = new Date().toISOString();
|
|
3137
|
+
const expiresAt = new Date(Date.now() + config.confirmationTtlMinutes * 60_000).toISOString();
|
|
3138
|
+
const cardId = await lark.createCard(renderExternalConfirmationCard(requestId, prompt, project.name, actions, "pending", config.confirmationTtlMinutes));
|
|
3139
|
+
const messageId = await lark.replyCard(replyToMessageId, cardId, replyKey(seed, "external-confirmation"), replyInThread);
|
|
3140
|
+
if (!messageId)
|
|
3141
|
+
throw new Error("飞书没有返回确认卡消息 ID");
|
|
3142
|
+
const pending = {
|
|
3143
|
+
id: requestId,
|
|
3144
|
+
seed,
|
|
3145
|
+
conversationKey,
|
|
3146
|
+
ownerId,
|
|
3147
|
+
prompt,
|
|
3148
|
+
project,
|
|
3149
|
+
actions,
|
|
3150
|
+
attachments,
|
|
3151
|
+
cardId,
|
|
3152
|
+
messageId,
|
|
3153
|
+
sequence: 0,
|
|
3154
|
+
...(replyInThread ? { replyInThread: true } : {}),
|
|
3155
|
+
createdAt,
|
|
3156
|
+
expiresAt,
|
|
3157
|
+
};
|
|
3158
|
+
pendingConfirmations.set(requestId, pending);
|
|
3159
|
+
await state.upsertConfirmation(pending);
|
|
3160
|
+
while (pendingConfirmations.size > 50) {
|
|
3161
|
+
const oldest = pendingConfirmations.keys().next().value;
|
|
3162
|
+
if (!oldest)
|
|
3163
|
+
break;
|
|
3164
|
+
pendingConfirmations.delete(oldest);
|
|
3165
|
+
await state.removeConfirmation(oldest);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
async function handleExternalConfirmation(event, action) {
|
|
3169
|
+
const pending = pendingConfirmations.get(action.request_id);
|
|
3170
|
+
if (!pending ||
|
|
3171
|
+
!conversationBelongsToChat(pending.conversationKey, event.chat_id) ||
|
|
3172
|
+
!canControlOwnedResource(config, event.operator_id, pending.ownerId)) {
|
|
3173
|
+
await productReply(event.message_id, "这张确认卡已失效,请重新发送原任务。", replyKey(event.event_id, "confirmation-expired"), "task-confirmation-expired");
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
if (Date.parse(pending.expiresAt) <= Date.now()) {
|
|
3177
|
+
pendingConfirmations.delete(action.request_id);
|
|
3178
|
+
await state.removeConfirmation(action.request_id);
|
|
3179
|
+
await updateConfirmationCard(pending, "expired", event.event_id);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (action.action === "approve_external") {
|
|
3183
|
+
const project = await currentProject(pending.conversationKey, pending.ownerId);
|
|
3184
|
+
if (project.path !== pending.project.path) {
|
|
3185
|
+
await productReply(event.message_id, `当前项目已切换为 ${project.name},为避免误操作,本次确认未执行。请在新项目中重新发送任务。`, replyKey(event.event_id, "confirmation-project-changed"), "project-confirmation-changed");
|
|
3186
|
+
pendingConfirmations.delete(action.request_id);
|
|
3187
|
+
await state.removeConfirmation(action.request_id);
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
try {
|
|
3191
|
+
await enqueuePrompt(event.message_id, pending.conversationKey, pending.ownerId, pending.prompt, event.event_id, pending.attachments, pending.actions, { replyInThread: pending.replyInThread ?? false });
|
|
3192
|
+
}
|
|
3193
|
+
catch (error) {
|
|
3194
|
+
if (error instanceof QueueCapacityError) {
|
|
3195
|
+
await productReply(event.message_id, "当前会话队列已满;确认仍然有效,请稍后再次点击“确认并执行”。", replyKey(event.event_id, "confirmation-queue-full"), "task-queue-full");
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
throw error;
|
|
3199
|
+
}
|
|
3200
|
+
pendingConfirmations.delete(action.request_id);
|
|
3201
|
+
await state.removeConfirmation(action.request_id);
|
|
3202
|
+
await updateConfirmationCard(pending, "approved", event.event_id);
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
pendingConfirmations.delete(action.request_id);
|
|
3206
|
+
await state.removeConfirmation(action.request_id);
|
|
3207
|
+
await updateConfirmationCard(pending, "rejected", event.event_id);
|
|
3208
|
+
}
|
|
3209
|
+
async function updateConfirmationCard(pending, stateName, eventId) {
|
|
3210
|
+
try {
|
|
3211
|
+
pending.sequence += 1;
|
|
3212
|
+
await lark.updateCard(pending.cardId, renderExternalConfirmationCard(pending.id, pending.prompt, pending.project.name, pending.actions, stateName, config.confirmationTtlMinutes), pending.sequence);
|
|
3213
|
+
}
|
|
3214
|
+
catch (error) {
|
|
3215
|
+
console.error(`[bridge] confirmation card update failed id=${pending.id}`, error);
|
|
3216
|
+
await productReply(pending.messageId, stateName === "approved"
|
|
3217
|
+
? "已确认,任务已进入执行队列。"
|
|
3218
|
+
: stateName === "expired"
|
|
3219
|
+
? "确认已过期,请重新发送原任务。"
|
|
3220
|
+
: "已取消,本次任务未执行。", replyKey(eventId, `confirmation-${stateName}-fallback`), `task-confirmation-${stateName}`);
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
async function prepareAttachments(event) {
|
|
3224
|
+
const resources = await lark.downloadMessageResources(event.message_id);
|
|
3225
|
+
const attachments = [];
|
|
3226
|
+
for (const resource of resources) {
|
|
3227
|
+
if (resource.sizeBytes > config.maxAttachmentBytes) {
|
|
3228
|
+
console.warn(`[bridge] ignored oversized attachment key=${logRef(resource.key)} bytes=${resource.sizeBytes}`);
|
|
3229
|
+
continue;
|
|
3230
|
+
}
|
|
3231
|
+
const name = basename(resource.localPath);
|
|
3232
|
+
if (resource.type === "image") {
|
|
3233
|
+
if (!(await isSupportedImageFile(resource.localPath))) {
|
|
3234
|
+
console.warn(`[bridge] ignored invalid image attachment key=${logRef(resource.key)}`);
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
attachments.push({
|
|
3238
|
+
kind: "image",
|
|
3239
|
+
path: resource.localPath,
|
|
3240
|
+
name,
|
|
3241
|
+
sizeBytes: resource.sizeBytes,
|
|
3242
|
+
});
|
|
3243
|
+
continue;
|
|
3244
|
+
}
|
|
3245
|
+
const extension = extname(resource.localPath).toLocaleLowerCase();
|
|
3246
|
+
if (!TEXT_ATTACHMENT_EXTENSIONS.has(extension) ||
|
|
3247
|
+
resource.sizeBytes > config.maxTextAttachmentBytes) {
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
if (!(await isUtf8TextFile(resource.localPath)))
|
|
3251
|
+
continue;
|
|
3252
|
+
attachments.push({
|
|
3253
|
+
kind: "text",
|
|
3254
|
+
path: resource.localPath,
|
|
3255
|
+
name,
|
|
3256
|
+
sizeBytes: resource.sizeBytes,
|
|
3257
|
+
});
|
|
3258
|
+
}
|
|
3259
|
+
return attachments;
|
|
3260
|
+
}
|
|
3261
|
+
function attachmentPrompt(event, attachments) {
|
|
3262
|
+
const imageCount = attachments.filter((attachment) => attachment.kind === "image").length;
|
|
3263
|
+
const textCount = attachments.length - imageCount;
|
|
3264
|
+
const kinds = [
|
|
3265
|
+
imageCount > 0 ? `${imageCount} 张图片` : "",
|
|
3266
|
+
textCount > 0 ? `${textCount} 个文本文件` : "",
|
|
3267
|
+
].filter(Boolean);
|
|
3268
|
+
return [
|
|
3269
|
+
`用户通过飞书发送了${kinds.join("和")}。`,
|
|
3270
|
+
"请读取附件,结合当前项目完成其中明确表达的任务。",
|
|
3271
|
+
"如果附件没有表达明确任务,先概括你看到的关键信息,并提出一个最关键的下一步问题。",
|
|
3272
|
+
event.content && !/^\[?(?:image|file|audio|media|video)\]?$/i.test(event.content.trim())
|
|
3273
|
+
? `飞书消息说明:${event.content.trim()}`
|
|
3274
|
+
: "",
|
|
3275
|
+
]
|
|
3276
|
+
.filter(Boolean)
|
|
3277
|
+
.join("\n");
|
|
3278
|
+
}
|
|
3279
|
+
async function replyQueueOrError(event, error, phase) {
|
|
3280
|
+
if (error instanceof QueueCapacityError) {
|
|
3281
|
+
await reply(event, `当前会话已有 ${config.maxQueuedPerConversation} 个任务在排队,请等一个任务完成或发送“取消”后再试。`, `${phase}-queue-full`);
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
if (error instanceof ProjectPolicyError) {
|
|
3285
|
+
await reply(event, `任务没有执行:${error.message}。请由仓库维护者检查 .feishu-codex-policy.json。`, `${phase}-project-policy`);
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
console.error(`[bridge] unable to accept ${phase} task`, error);
|
|
3289
|
+
await reply(event, "任务暂时未能进入队列,请检查服务状态后重试。外部动作没有被执行。", `${phase}-accept-failed`);
|
|
3290
|
+
}
|
|
3291
|
+
function permissionLabel(mode) {
|
|
3292
|
+
if (mode === "danger-full-access")
|
|
3293
|
+
return "完全访问";
|
|
3294
|
+
if (mode === "read-only")
|
|
3295
|
+
return "只读";
|
|
3296
|
+
return "工作区写入";
|
|
3297
|
+
}
|
|
3298
|
+
function maxSandboxForActor(actorId) {
|
|
3299
|
+
if (canAdminister(config, actorId))
|
|
3300
|
+
return config.sandboxMode;
|
|
3301
|
+
if (canOperate(config, actorId))
|
|
3302
|
+
return config.operatorSandboxMode;
|
|
3303
|
+
return "read-only";
|
|
3304
|
+
}
|
|
3305
|
+
function visibleSessions(ownerId, projectPath, currentThreadId, sessions) {
|
|
3306
|
+
if (canAdminister(config, ownerId))
|
|
3307
|
+
return sessions;
|
|
3308
|
+
const owned = state.listOwnedThreadIds(ownerId, projectPath);
|
|
3309
|
+
if (currentThreadId)
|
|
3310
|
+
owned.add(currentThreadId);
|
|
3311
|
+
return sessions.filter((session) => owned.has(session.id));
|
|
3312
|
+
}
|
|
3313
|
+
function executionSettings(conversationKey, actorId) {
|
|
3314
|
+
const preferences = state.getPreferences(conversationKey);
|
|
3315
|
+
const maximum = maxSandboxForActor(actorId);
|
|
3316
|
+
const requested = preferences?.sandboxMode;
|
|
3317
|
+
const sandboxMode = persistentSandboxMode(requested, maximum);
|
|
3318
|
+
const selectedModel = resolveModelPreference(preferences?.model, config.model);
|
|
3319
|
+
return {
|
|
3320
|
+
sandboxMode,
|
|
3321
|
+
...(selectedModel ? { model: selectedModel } : {}),
|
|
3322
|
+
...(preferences?.reasoningEffort ?? config.reasoningEffort
|
|
3323
|
+
? { reasoningEffort: preferences?.reasoningEffort ?? config.reasoningEffort }
|
|
3324
|
+
: {}),
|
|
3325
|
+
};
|
|
3326
|
+
}
|
|
3327
|
+
async function applySettingsChange(conversationKey, actorId, change) {
|
|
3328
|
+
const current = state.getPreferences(conversationKey);
|
|
3329
|
+
const next = {};
|
|
3330
|
+
if (current?.model)
|
|
3331
|
+
next.model = current.model;
|
|
3332
|
+
if (current?.reasoningEffort)
|
|
3333
|
+
next.reasoningEffort = current.reasoningEffort;
|
|
3334
|
+
if (current?.sandboxMode && current.sandboxMode !== "danger-full-access") {
|
|
3335
|
+
next.sandboxMode = current.sandboxMode;
|
|
3336
|
+
}
|
|
3337
|
+
if (change.kind === "model") {
|
|
3338
|
+
const models = await runner.listModels();
|
|
3339
|
+
const normalized = change.value.trim().toLocaleLowerCase();
|
|
3340
|
+
if (["__default__", "默认", "default", "auto", "自动"].includes(normalized)) {
|
|
3341
|
+
// Keep an explicit sentinel so a conversation can override a globally pinned model.
|
|
3342
|
+
next.model = "__default__";
|
|
3343
|
+
await state.setPreferences(conversationKey, next);
|
|
3344
|
+
await audit(actorId, "settings.model", "settings", conversationKey, "allowed", "default");
|
|
3345
|
+
return "已恢复 Codex 默认模型;下一轮生效。";
|
|
3346
|
+
}
|
|
3347
|
+
const matches = models.filter((model) => model.model.toLocaleLowerCase() === normalized ||
|
|
3348
|
+
model.id.toLocaleLowerCase() === normalized ||
|
|
3349
|
+
model.displayName.toLocaleLowerCase() === normalized);
|
|
3350
|
+
const fuzzy = matches.length > 0
|
|
3351
|
+
? matches
|
|
3352
|
+
: models.filter((model) => model.model.toLocaleLowerCase().includes(normalized) ||
|
|
3353
|
+
model.displayName.toLocaleLowerCase().includes(normalized));
|
|
3354
|
+
if (fuzzy.length !== 1 || !fuzzy[0]) {
|
|
3355
|
+
throw new Error(fuzzy.length > 1
|
|
3356
|
+
? `“${change.value}”匹配到多个模型,请使用完整模型名。`
|
|
3357
|
+
: `没有找到模型“${change.value}”。`);
|
|
3358
|
+
}
|
|
3359
|
+
next.model = fuzzy[0].model;
|
|
3360
|
+
if (next.reasoningEffort &&
|
|
3361
|
+
!fuzzy[0].supportedReasoningEfforts.includes(next.reasoningEffort)) {
|
|
3362
|
+
next.reasoningEffort = fuzzy[0].defaultReasoningEffort;
|
|
3363
|
+
}
|
|
3364
|
+
await state.setPreferences(conversationKey, next);
|
|
3365
|
+
await audit(actorId, "settings.model", "settings", conversationKey, "allowed", next.model);
|
|
3366
|
+
return `模型已切换为 ${fuzzy[0].displayName};下一轮生效。`;
|
|
3367
|
+
}
|
|
3368
|
+
if (change.kind === "effort") {
|
|
3369
|
+
const effort = normalizeEffort(change.value);
|
|
3370
|
+
if (!effort)
|
|
3371
|
+
throw new Error("推理强度支持最低、轻度、中、高、极高或最高。");
|
|
3372
|
+
const models = await runner.listModels();
|
|
3373
|
+
const effective = executionSettings(conversationKey, actorId);
|
|
3374
|
+
const model = models.find((candidate) => candidate.model === effective.model) ??
|
|
3375
|
+
models.find((candidate) => candidate.isDefault);
|
|
3376
|
+
if (model && !model.supportedReasoningEfforts.includes(effort)) {
|
|
3377
|
+
throw new Error(`${model.displayName} 不支持“${reasoningEffortLabel(effort)}”推理强度。`);
|
|
3378
|
+
}
|
|
3379
|
+
next.reasoningEffort = effort;
|
|
3380
|
+
await state.setPreferences(conversationKey, next);
|
|
3381
|
+
await audit(actorId, "settings.effort", "settings", conversationKey, "allowed", effort);
|
|
3382
|
+
return `推理强度已切换为“${reasoningEffortLabel(effort)}”;下一轮生效。`;
|
|
3383
|
+
}
|
|
3384
|
+
const sandbox = normalizeSandboxMode(change.value);
|
|
3385
|
+
if (!sandbox)
|
|
3386
|
+
throw new Error("权限支持只读、工作区写入、完全访问。 ");
|
|
3387
|
+
const maximum = maxSandboxForActor(actorId);
|
|
3388
|
+
if (!isSandboxModeAllowed(sandbox, maximum)) {
|
|
3389
|
+
throw new Error(`你的角色最高只能使用“${permissionLabel(maximum)}”。`);
|
|
3390
|
+
}
|
|
3391
|
+
if (sandbox === "danger-full-access") {
|
|
3392
|
+
await audit(actorId, "settings.sandbox.persistent_full_access", "settings", conversationKey, "denied", "temporary lease required");
|
|
3393
|
+
return "完全访问不会永久保存。请在下方选择“下一任务”“30 分钟”或“当前会话”临时授权。";
|
|
3394
|
+
}
|
|
3395
|
+
next.sandboxMode = sandbox;
|
|
3396
|
+
await state.setPreferences(conversationKey, next);
|
|
3397
|
+
await audit(actorId, "settings.sandbox", "settings", conversationKey, "allowed", sandbox);
|
|
3398
|
+
return `任务权限已切换为${permissionLabel(sandbox)};下一轮生效。`;
|
|
3399
|
+
}
|
|
3400
|
+
function normalizeEffort(value) {
|
|
3401
|
+
const normalized = value.trim().toLocaleLowerCase();
|
|
3402
|
+
const aliases = {
|
|
3403
|
+
"最低": "minimal",
|
|
3404
|
+
"极快": "minimal",
|
|
3405
|
+
"轻度": "low",
|
|
3406
|
+
"快速": "low",
|
|
3407
|
+
"中": "medium",
|
|
3408
|
+
"均衡": "medium",
|
|
3409
|
+
"高": "high",
|
|
3410
|
+
"深入": "high",
|
|
3411
|
+
"极高": "xhigh",
|
|
3412
|
+
"极深": "xhigh",
|
|
3413
|
+
"最高": "ultra",
|
|
3414
|
+
"自主": "ultra",
|
|
3415
|
+
"自主协作": "ultra",
|
|
3416
|
+
};
|
|
3417
|
+
const candidate = aliases[normalized] ?? normalized;
|
|
3418
|
+
return ["minimal", "low", "medium", "high", "xhigh", "ultra"].includes(candidate)
|
|
3419
|
+
? candidate
|
|
3420
|
+
: null;
|
|
3421
|
+
}
|
|
3422
|
+
function normalizeSandboxMode(value) {
|
|
3423
|
+
const normalized = value.trim().toLocaleLowerCase();
|
|
3424
|
+
if (["只读", "read-only", "readonly", "read"].includes(normalized))
|
|
3425
|
+
return "read-only";
|
|
3426
|
+
if (["工作区", "工作区写入", "workspace-write", "write"].includes(normalized)) {
|
|
3427
|
+
return "workspace-write";
|
|
3428
|
+
}
|
|
3429
|
+
if (["完全访问", "full", "danger-full-access", "danger"].includes(normalized)) {
|
|
3430
|
+
return "danger-full-access";
|
|
3431
|
+
}
|
|
3432
|
+
return null;
|
|
3433
|
+
}
|
|
3434
|
+
async function requireOperator(event, action) {
|
|
3435
|
+
if (canOperate(config, event.sender_id))
|
|
3436
|
+
return true;
|
|
3437
|
+
await audit(event.sender_id, action, "security", key(event), "denied", "viewer role");
|
|
3438
|
+
await reply(event, "你的团队角色是只读成员,可以查看状态、项目、会话和任务,但不能执行或修改代码。请联系管理员调整角色。", `viewer-denied-${action}`);
|
|
3439
|
+
return false;
|
|
3440
|
+
}
|
|
3441
|
+
async function audit(actorId, action, resourceType, resourceId, outcome, detail) {
|
|
3442
|
+
await state.appendAudit({
|
|
3443
|
+
occurredAt: new Date().toISOString(),
|
|
3444
|
+
actorId,
|
|
3445
|
+
action,
|
|
3446
|
+
resourceType,
|
|
3447
|
+
resourceId,
|
|
3448
|
+
outcome,
|
|
3449
|
+
...(detail ? { detail: redactSensitiveText(detail, 500) } : {}),
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
3452
|
+
function formatAuditLog(entries, global) {
|
|
3453
|
+
if (entries.length === 0)
|
|
3454
|
+
return "审计日志为空。";
|
|
3455
|
+
return [
|
|
3456
|
+
`最近审计记录(${global ? "全团队" : "仅本人"})`,
|
|
3457
|
+
...entries.map((entry) => {
|
|
3458
|
+
const time = new Date(entry.occurredAt).toLocaleString("zh-CN", { hour12: false });
|
|
3459
|
+
const actor = global ? ` · ${logRef(entry.actorId)}` : "";
|
|
3460
|
+
return `- ${time} · ${entry.action} · ${entry.outcome}${actor}${entry.detail ? ` · ${redactSensitiveText(entry.detail, 120)}` : ""}`;
|
|
3461
|
+
}),
|
|
3462
|
+
].join("\n");
|
|
3463
|
+
}
|
|
3464
|
+
function conversationBelongsToChat(conversationKey, chatId) {
|
|
3465
|
+
return conversationKey === chatId || conversationKey.startsWith(`${chatId}::`);
|
|
3466
|
+
}
|
|
3467
|
+
function taskChatId(conversationKey) {
|
|
3468
|
+
const memberBoundary = conversationKey.indexOf("::");
|
|
3469
|
+
return memberBoundary >= 0 ? conversationKey.slice(0, memberBoundary) : conversationKey;
|
|
3470
|
+
}
|
|
3471
|
+
function taskCollaborationContext(ownerId, controllerId, project, conversationKey) {
|
|
3472
|
+
const teamMode = state.getChatType(taskChatId(conversationKey)) === "group";
|
|
3473
|
+
const handoffOptions = teamMode
|
|
3474
|
+
? operatingTeamMembers(config, project).map((member) => ({
|
|
3475
|
+
label: teamMemberOptionLabel(member),
|
|
3476
|
+
value: member.selector,
|
|
3477
|
+
}))
|
|
3478
|
+
: [];
|
|
3479
|
+
return {
|
|
3480
|
+
initiatorLabel: memberLabel(config, ownerId),
|
|
3481
|
+
controllerLabel: memberLabel(config, controllerId),
|
|
3482
|
+
controllerSelector: memberSelector(controllerId),
|
|
3483
|
+
handoffOptions,
|
|
3484
|
+
teamMode,
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
async function setTaskController(record, controllerId, actorId, action) {
|
|
3488
|
+
const previousController = record.controllerId;
|
|
3489
|
+
record.controllerId = controllerId;
|
|
3490
|
+
const note = previousController === controllerId
|
|
3491
|
+
? `${memberLabel(config, controllerId)} 已经是当前控制者。`
|
|
3492
|
+
: action === "handoff"
|
|
3493
|
+
? `任务已转交给 ${memberLabel(config, controllerId)};发起人与执行权限保持不变。`
|
|
3494
|
+
: `${memberLabel(config, controllerId)} 已显式接管任务。`;
|
|
3495
|
+
const context = taskCollaborationContext(record.ownerId, controllerId, record.project, record.conversationKey);
|
|
3496
|
+
const session = taskLiveSession(record);
|
|
3497
|
+
if (session) {
|
|
3498
|
+
await session.updateCollaboration(context, note);
|
|
3499
|
+
record.progress = session.progress;
|
|
3500
|
+
}
|
|
3501
|
+
else {
|
|
3502
|
+
record.progress = updateTaskCollaboration(record.progress, context, note);
|
|
3503
|
+
}
|
|
3504
|
+
await persistTaskRecord(record);
|
|
3505
|
+
await audit(actorId, `task.${action}`, "task", record.id, previousController === controllerId ? "denied" : "allowed", `${memberSelector(previousController)} -> ${memberSelector(controllerId)}`);
|
|
3506
|
+
}
|
|
3507
|
+
async function persistTaskRecord(record) {
|
|
3508
|
+
const scheduled = taskPersistTimers.get(record.id);
|
|
3509
|
+
if (scheduled) {
|
|
3510
|
+
clearTimeout(scheduled);
|
|
3511
|
+
taskPersistTimers.delete(record.id);
|
|
3512
|
+
}
|
|
3513
|
+
const card = record.card;
|
|
3514
|
+
const conversation = record.conversation;
|
|
3515
|
+
const fallbackCard = record.fallbackCard;
|
|
3516
|
+
const persisted = {
|
|
3517
|
+
id: record.id,
|
|
3518
|
+
conversationKey: record.conversationKey,
|
|
3519
|
+
ownerId: record.ownerId,
|
|
3520
|
+
controllerId: record.controllerId,
|
|
3521
|
+
prompt: record.prompt,
|
|
3522
|
+
replyToMessageId: record.replyToMessageId,
|
|
3523
|
+
seed: record.seed,
|
|
3524
|
+
project: { ...record.project },
|
|
3525
|
+
status: record.status,
|
|
3526
|
+
progress: structuredClone(record.progress),
|
|
3527
|
+
...(card?.cardId ? { cardId: card.cardId } : {}),
|
|
3528
|
+
...(card?.messageId ? { cardMessageId: card.messageId } : {}),
|
|
3529
|
+
cardSequence: card?.sequenceNumber ?? 0,
|
|
3530
|
+
...(fallbackCard?.cardId ? { fallbackCardId: fallbackCard.cardId } : {}),
|
|
3531
|
+
...(fallbackCard?.messageId
|
|
3532
|
+
? { fallbackCardMessageId: fallbackCard.messageId }
|
|
3533
|
+
: {}),
|
|
3534
|
+
...(fallbackCard
|
|
3535
|
+
? { fallbackCardSequence: fallbackCard.sequenceNumber }
|
|
3536
|
+
: {}),
|
|
3537
|
+
...(conversation?.messageId
|
|
3538
|
+
? { conversationMessageId: conversation.messageId }
|
|
3539
|
+
: {}),
|
|
3540
|
+
...(conversation
|
|
3541
|
+
? { conversationMessageSequence: conversation.sequenceNumber }
|
|
3542
|
+
: {}),
|
|
3543
|
+
...(record.replyInThread ? { replyInThread: true } : {}),
|
|
3544
|
+
attachments: structuredClone(record.attachments),
|
|
3545
|
+
allowedExternalActions: [...record.allowedExternalActions],
|
|
3546
|
+
settings: { ...record.settings },
|
|
3547
|
+
createdAt: new Date(record.progress.createdAt).toISOString(),
|
|
3548
|
+
updatedAt: new Date().toISOString(),
|
|
3549
|
+
};
|
|
3550
|
+
await state.upsertTask(persisted);
|
|
3551
|
+
}
|
|
3552
|
+
function schedulePersistTaskRecord(record) {
|
|
3553
|
+
if (taskPersistTimers.has(record.id))
|
|
3554
|
+
return;
|
|
3555
|
+
const timer = setTimeout(() => {
|
|
3556
|
+
taskPersistTimers.delete(record.id);
|
|
3557
|
+
void persistTaskRecord(record).catch((error) => {
|
|
3558
|
+
console.error(`[bridge] unable to persist task snapshot id=${record.id}`, error);
|
|
3559
|
+
});
|
|
3560
|
+
}, 2_000);
|
|
3561
|
+
timer.unref();
|
|
3562
|
+
taskPersistTimers.set(record.id, timer);
|
|
3563
|
+
}
|
|
3564
|
+
async function flushScheduledTaskPersists() {
|
|
3565
|
+
const ids = [...taskPersistTimers.keys()];
|
|
3566
|
+
for (const id of ids) {
|
|
3567
|
+
const timer = taskPersistTimers.get(id);
|
|
3568
|
+
if (timer)
|
|
3569
|
+
clearTimeout(timer);
|
|
3570
|
+
taskPersistTimers.delete(id);
|
|
3571
|
+
const record = tasks.get(id);
|
|
3572
|
+
if (record)
|
|
3573
|
+
await persistTaskRecord(record);
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
function confirmationFromPersisted(saved) {
|
|
3577
|
+
return structuredClone(saved);
|
|
3578
|
+
}
|
|
3579
|
+
async function restorePersistedRecords() {
|
|
3580
|
+
for (const saved of state.listProjectCards()) {
|
|
3581
|
+
if (!roleForSender(config, saved.ownerId)) {
|
|
3582
|
+
await state.removeProjectCard(saved.messageId);
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
projectCards.set(saved.messageId, {
|
|
3586
|
+
cardId: saved.cardId,
|
|
3587
|
+
conversationKey: saved.conversationKey,
|
|
3588
|
+
ownerId: saved.ownerId,
|
|
3589
|
+
sequence: saved.sequence,
|
|
3590
|
+
createdAt: saved.createdAt,
|
|
3591
|
+
});
|
|
3592
|
+
}
|
|
3593
|
+
for (const saved of state.listDeviceCards()) {
|
|
3594
|
+
if (!roleForSender(config, saved.ownerId)) {
|
|
3595
|
+
await state.removeDeviceCard(saved.messageId);
|
|
3596
|
+
continue;
|
|
3597
|
+
}
|
|
3598
|
+
deviceCards.set(saved.messageId, {
|
|
3599
|
+
cardId: saved.cardId,
|
|
3600
|
+
conversationKey: saved.conversationKey,
|
|
3601
|
+
ownerId: saved.ownerId,
|
|
3602
|
+
sequence: saved.sequence,
|
|
3603
|
+
createdAt: saved.createdAt,
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
for (const saved of state.listConfirmations()) {
|
|
3607
|
+
const project = projectRegistry.getByPath(saved.project.path);
|
|
3608
|
+
if (Date.parse(saved.expiresAt) <= Date.now() ||
|
|
3609
|
+
!canOperate(config, saved.ownerId) ||
|
|
3610
|
+
!project ||
|
|
3611
|
+
!canAccessProject(config, saved.ownerId, project)) {
|
|
3612
|
+
await state.removeConfirmation(saved.id);
|
|
3613
|
+
continue;
|
|
3614
|
+
}
|
|
3615
|
+
pendingConfirmations.set(saved.id, confirmationFromPersisted(saved));
|
|
3616
|
+
}
|
|
3617
|
+
const savedTasks = state
|
|
3618
|
+
.listTasks()
|
|
3619
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
3620
|
+
for (const savedTask of savedTasks) {
|
|
3621
|
+
const reconciliation = reconcilePersistedTask(savedTask);
|
|
3622
|
+
const saved = reconciliation.task;
|
|
3623
|
+
if (reconciliation.changed)
|
|
3624
|
+
await state.upsertTask(saved);
|
|
3625
|
+
const controllerAuthorized = canOperate(config, saved.controllerId) &&
|
|
3626
|
+
canAccessProject(config, saved.controllerId, saved.project);
|
|
3627
|
+
const controllerId = controllerAuthorized ? saved.controllerId : saved.ownerId;
|
|
3628
|
+
const restoredProgress = updateTaskCollaboration(saved.progress, taskCollaborationContext(saved.ownerId, controllerId, saved.project, saved.conversationKey), controllerAuthorized
|
|
3629
|
+
? undefined
|
|
3630
|
+
: "原控制者的团队权限已变化,任务控制权已安全回到发起人。");
|
|
3631
|
+
const card = saved.cardId
|
|
3632
|
+
? TaskCardSession.restore(lark, saved.cardId, saved.cardMessageId ?? "", restoredProgress, saved.cardSequence)
|
|
3633
|
+
: null;
|
|
3634
|
+
const fallbackCard = saved.fallbackCardId && saved.fallbackCardMessageId
|
|
3635
|
+
? FallbackCardSession.restore(lark, saved.fallbackCardId, saved.fallbackCardMessageId, saved.fallbackCardSequence ?? 0)
|
|
3636
|
+
: null;
|
|
3637
|
+
const conversation = saved.conversationMessageId
|
|
3638
|
+
? ConversationTurnSession.restore(lark, saved.conversationMessageId, restoredProgress, saved.conversationMessageSequence ?? 0)
|
|
3639
|
+
: null;
|
|
3640
|
+
const record = {
|
|
3641
|
+
id: saved.id,
|
|
3642
|
+
conversationKey: saved.conversationKey,
|
|
3643
|
+
ownerId: saved.ownerId,
|
|
3644
|
+
controllerId,
|
|
3645
|
+
prompt: saved.prompt,
|
|
3646
|
+
project: saved.project,
|
|
3647
|
+
status: saved.status,
|
|
3648
|
+
card,
|
|
3649
|
+
conversation,
|
|
3650
|
+
fallbackCard,
|
|
3651
|
+
progress: restoredProgress,
|
|
3652
|
+
replyToMessageId: saved.replyToMessageId,
|
|
3653
|
+
seed: saved.seed,
|
|
3654
|
+
attachments: saved.attachments,
|
|
3655
|
+
allowedExternalActions: saved.allowedExternalActions,
|
|
3656
|
+
settings: saved.settings,
|
|
3657
|
+
allowThreadBinding: saved.status === "queued" || saved.status === "running",
|
|
3658
|
+
replyInThread: saved.replyInThread ?? false,
|
|
3659
|
+
};
|
|
3660
|
+
if (card) {
|
|
3661
|
+
card.onSnapshot((snapshot) => {
|
|
3662
|
+
record.progress = snapshot.progress;
|
|
3663
|
+
record.status = snapshot.progress.phase;
|
|
3664
|
+
schedulePersistTaskRecord(record);
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
3667
|
+
if (conversation) {
|
|
3668
|
+
conversation.onSnapshot((snapshot) => {
|
|
3669
|
+
record.progress = snapshot.progress;
|
|
3670
|
+
record.status = snapshot.progress.phase;
|
|
3671
|
+
schedulePersistTaskRecord(record);
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
tasks.set(record.id, record);
|
|
3675
|
+
if (!controllerAuthorized && saved.controllerId !== saved.ownerId) {
|
|
3676
|
+
await persistTaskRecord(record);
|
|
3677
|
+
await audit(record.ownerId, "task.controller_recover", "task", record.id, "allowed", "controller authorization changed; returned to initiator");
|
|
3678
|
+
}
|
|
3679
|
+
if (reconciliation.changed &&
|
|
3680
|
+
taskLiveSession(record) &&
|
|
3681
|
+
["succeeded", "failed", "cancelled", "interrupted"].includes(record.status)) {
|
|
3682
|
+
const session = taskLiveSession(record);
|
|
3683
|
+
await session.addActionNote(reconciliation.reason ?? "服务启动时已完成状态对账。");
|
|
3684
|
+
record.progress = session.progress;
|
|
3685
|
+
await persistTaskRecord(record);
|
|
3686
|
+
}
|
|
3687
|
+
if (reconciliation.changed) {
|
|
3688
|
+
await audit(record.ownerId, "task.reconcile", "task", record.id, "allowed", reconciliation.reason);
|
|
3689
|
+
}
|
|
3690
|
+
if (record.status === "running") {
|
|
3691
|
+
const message = "检测到桥接服务在任务运行期间停止。为避免重复执行命令或重复修改文件,任务没有自动重跑;请先查看变更,再决定是否重新执行。";
|
|
3692
|
+
record.status = "interrupted";
|
|
3693
|
+
if (record.progress.threadId) {
|
|
3694
|
+
await rememberActiveThread(record, record.progress.threadId);
|
|
3695
|
+
}
|
|
3696
|
+
const session = taskLiveSession(record);
|
|
3697
|
+
if (session) {
|
|
3698
|
+
await session.finishInterrupted(message);
|
|
3699
|
+
record.progress = session.progress;
|
|
3700
|
+
}
|
|
3701
|
+
else {
|
|
3702
|
+
record.progress = interruptTaskProgress(record.progress, message);
|
|
3703
|
+
}
|
|
3704
|
+
await persistTaskRecord(record);
|
|
3705
|
+
await audit(record.ownerId, "task.recover", "task", record.id, "failed", "running task interrupted; automatic replay blocked");
|
|
3706
|
+
continue;
|
|
3707
|
+
}
|
|
3708
|
+
if (record.status === "queued") {
|
|
3709
|
+
const project = projectRegistry.getByPath(record.project.path);
|
|
3710
|
+
const maximum = maxSandboxForActor(record.ownerId);
|
|
3711
|
+
const recoveryBlocked = !canOperate(config, record.ownerId) ||
|
|
3712
|
+
!project ||
|
|
3713
|
+
!canAccessProject(config, record.ownerId, project) ||
|
|
3714
|
+
!isSandboxModeAllowed(record.settings.sandboxMode, maximum);
|
|
3715
|
+
if (recoveryBlocked) {
|
|
3716
|
+
const message = "服务重启后检测到成员、项目或权限上限已经变化;为安全起见,旧任务未自动恢复,请重新发送。";
|
|
3717
|
+
record.status = "failed";
|
|
3718
|
+
const session = taskLiveSession(record);
|
|
3719
|
+
if (session) {
|
|
3720
|
+
await session.finishFailed(message);
|
|
3721
|
+
record.progress = session.progress;
|
|
3722
|
+
}
|
|
3723
|
+
else {
|
|
3724
|
+
record.progress = failTask(record.progress, message);
|
|
3725
|
+
}
|
|
3726
|
+
await persistTaskRecord(record);
|
|
3727
|
+
await audit(record.ownerId, "task.recover", "task", record.id, "denied", "authorization changed");
|
|
3728
|
+
continue;
|
|
3729
|
+
}
|
|
3730
|
+
record.project = project;
|
|
3731
|
+
record.status = "queued";
|
|
3732
|
+
const queuePosition = queue.nextPosition(record.conversationKey);
|
|
3733
|
+
const session = taskLiveSession(record);
|
|
3734
|
+
if (session) {
|
|
3735
|
+
await session.markRecovered("服务重启后已从 SQLite 可靠队列恢复,将自动继续执行。", queuePosition);
|
|
3736
|
+
record.progress = session.progress;
|
|
3737
|
+
}
|
|
3738
|
+
else {
|
|
3739
|
+
record.progress = recoverTaskProgress(record.progress, queuePosition, "服务重启后已从 SQLite 可靠队列恢复,将自动继续执行。");
|
|
3740
|
+
}
|
|
3741
|
+
await persistTaskRecord(record);
|
|
3742
|
+
try {
|
|
3743
|
+
scheduleTaskRecord(record);
|
|
3744
|
+
}
|
|
3745
|
+
catch (error) {
|
|
3746
|
+
record.status = "failed";
|
|
3747
|
+
const message = `恢复任务失败:${error.message}`;
|
|
3748
|
+
const failedSession = taskLiveSession(record);
|
|
3749
|
+
if (failedSession) {
|
|
3750
|
+
await failedSession.finishFailed(message);
|
|
3751
|
+
record.progress = failedSession.progress;
|
|
3752
|
+
}
|
|
3753
|
+
else {
|
|
3754
|
+
record.progress = failTask(record.progress, message);
|
|
3755
|
+
}
|
|
3756
|
+
await persistTaskRecord(record);
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
async function currentProject(conversationKey, actorId) {
|
|
3762
|
+
const savedPath = state.getProject(conversationKey);
|
|
3763
|
+
const accessible = () => visibleProjects(config, actorId, projectRegistry.list());
|
|
3764
|
+
if (!savedPath) {
|
|
3765
|
+
const preferred = projectRegistry.defaultProject();
|
|
3766
|
+
if (canAccessProject(config, actorId, preferred))
|
|
3767
|
+
return preferred;
|
|
3768
|
+
const fallback = accessible()[0];
|
|
3769
|
+
if (!fallback)
|
|
3770
|
+
throw new Error("你的账号没有被授权访问任何 Codex 项目。");
|
|
3771
|
+
await state.setProject(conversationKey, fallback.path);
|
|
3772
|
+
return fallback;
|
|
3773
|
+
}
|
|
3774
|
+
let project = projectRegistry.getByPath(savedPath);
|
|
3775
|
+
if (!project) {
|
|
3776
|
+
await projectRegistry.refresh();
|
|
3777
|
+
project = projectRegistry.getByPath(savedPath);
|
|
3778
|
+
}
|
|
3779
|
+
if (project && canAccessProject(config, actorId, project))
|
|
3780
|
+
return project;
|
|
3781
|
+
const preferred = projectRegistry.defaultProject();
|
|
3782
|
+
const fallback = canAccessProject(config, actorId, preferred) ? preferred : accessible()[0];
|
|
3783
|
+
if (!fallback)
|
|
3784
|
+
throw new Error("你的账号没有被授权访问任何 Codex 项目。");
|
|
3785
|
+
await state.setProject(conversationKey, fallback.path);
|
|
3786
|
+
return fallback;
|
|
3787
|
+
}
|
|
3788
|
+
function formatProjectList(current, projects = projectRegistry.list()) {
|
|
3789
|
+
const visible = projects.slice(0, 50);
|
|
3790
|
+
const lines = [
|
|
3791
|
+
`可用项目(${projects.length})`,
|
|
3792
|
+
...visible.map((project, index) => {
|
|
3793
|
+
const marker = project.path === current.path ? " ← 当前" : "";
|
|
3794
|
+
return `${index + 1}. ${project.name}${marker}\n ${project.displayPath}`;
|
|
3795
|
+
}),
|
|
3796
|
+
];
|
|
3797
|
+
if (projects.length > visible.length)
|
|
3798
|
+
lines.push(`其余 ${projects.length - visible.length} 个未显示。`);
|
|
3799
|
+
lines.push("", "切换方式:/use 2 或 切换 FastGPT");
|
|
3800
|
+
return lines.join("\n");
|
|
3801
|
+
}
|
|
3802
|
+
function projectResolutionError(selector, resolution, actorId) {
|
|
3803
|
+
const projects = rankedProjectsForActor(actorId);
|
|
3804
|
+
if (resolution.status === "ambiguous") {
|
|
3805
|
+
const options = resolution.projects
|
|
3806
|
+
.slice(0, 10)
|
|
3807
|
+
.map((project) => {
|
|
3808
|
+
const index = projects.findIndex((candidate) => candidate.path === project.path);
|
|
3809
|
+
return `${index + 1}. ${project.name} — ${project.displayPath}`;
|
|
3810
|
+
});
|
|
3811
|
+
return [`“${selector}”匹配到多个项目:`, ...options, "请使用编号切换,例如 /use 2。"].join("\n");
|
|
3812
|
+
}
|
|
3813
|
+
return `没有找到项目“${selector}”。发送“项目”查看可用列表。`;
|
|
3814
|
+
}
|
|
3815
|
+
function resolveAccessibleProject(selector, actorId) {
|
|
3816
|
+
return resolveProjectSelector(rankedProjectsForActor(actorId), selector);
|
|
3817
|
+
}
|
|
3818
|
+
function rankedProjectsForActor(actorId) {
|
|
3819
|
+
const projects = visibleProjects(config, actorId, projectRegistry.list());
|
|
3820
|
+
return rankProjectWorkspace(projects, state.listProjectUsage(actorId)).projects;
|
|
3821
|
+
}
|
|
3822
|
+
function parseCardActionValue(event) {
|
|
3823
|
+
try {
|
|
3824
|
+
const value = JSON.parse(event.action_value);
|
|
3825
|
+
if (value.bridge === "feishu-codex-v7") {
|
|
3826
|
+
const action = String(value.action ?? "");
|
|
3827
|
+
if (!["runbook_run", "runbook_refresh", "runbook_projects", "runbook_team"].includes(action)) {
|
|
3828
|
+
return null;
|
|
3829
|
+
}
|
|
3830
|
+
if (action === "runbook_run" && typeof value.runbook_id !== "string")
|
|
3831
|
+
return null;
|
|
3832
|
+
return {
|
|
3833
|
+
bridge: "feishu-codex-v7",
|
|
3834
|
+
action: action,
|
|
3835
|
+
...(typeof value.runbook_id === "string" ? { runbook_id: value.runbook_id } : {}),
|
|
3836
|
+
};
|
|
3837
|
+
}
|
|
3838
|
+
if (value.bridge === "feishu-codex-v6") {
|
|
3839
|
+
const action = String(value.action ?? "");
|
|
3840
|
+
if (![
|
|
3841
|
+
"review_page",
|
|
3842
|
+
"review_file",
|
|
3843
|
+
"review_refresh",
|
|
3844
|
+
"review_back",
|
|
3845
|
+
"review_diff_page",
|
|
3846
|
+
"review_close",
|
|
3847
|
+
].includes(action) ||
|
|
3848
|
+
typeof value.task_id !== "string") {
|
|
3849
|
+
return null;
|
|
3850
|
+
}
|
|
3851
|
+
const page = cardInteger(value.page);
|
|
3852
|
+
const fileIndex = cardInteger(value.file_index);
|
|
3853
|
+
const diffPage = cardInteger(value.diff_page);
|
|
3854
|
+
if (action === "review_page" && page === undefined)
|
|
3855
|
+
return null;
|
|
3856
|
+
if (action === "review_file" && fileIndex === undefined)
|
|
3857
|
+
return null;
|
|
3858
|
+
if (action === "review_diff_page" && diffPage === undefined)
|
|
3859
|
+
return null;
|
|
3860
|
+
return {
|
|
3861
|
+
bridge: "feishu-codex-v6",
|
|
3862
|
+
action: action,
|
|
3863
|
+
task_id: value.task_id,
|
|
3864
|
+
...(page === undefined ? {} : { page }),
|
|
3865
|
+
...(fileIndex === undefined ? {} : { file_index: fileIndex }),
|
|
3866
|
+
...(diffPage === undefined ? {} : { diff_page: diffPage }),
|
|
3867
|
+
};
|
|
3868
|
+
}
|
|
3869
|
+
if (value.bridge === "feishu-codex-v5") {
|
|
3870
|
+
const action = String(value.action ?? "");
|
|
3871
|
+
if ([
|
|
3872
|
+
"select_model",
|
|
3873
|
+
"select_effort",
|
|
3874
|
+
"select_sandbox",
|
|
3875
|
+
"settings_refresh",
|
|
3876
|
+
"settings_sessions",
|
|
3877
|
+
"settings_tasks",
|
|
3878
|
+
"settings_new",
|
|
3879
|
+
"lease_full_once",
|
|
3880
|
+
"lease_full_30m",
|
|
3881
|
+
"lease_full_session",
|
|
3882
|
+
"lease_full_revoke",
|
|
3883
|
+
"select_session",
|
|
3884
|
+
"session_compact",
|
|
3885
|
+
"session_new",
|
|
3886
|
+
"session_settings",
|
|
3887
|
+
"session_refresh",
|
|
3888
|
+
"tasks_stop",
|
|
3889
|
+
"tasks_cancel_one",
|
|
3890
|
+
"tasks_review",
|
|
3891
|
+
"tasks_new",
|
|
3892
|
+
"tasks_settings",
|
|
3893
|
+
"tasks_refresh",
|
|
3894
|
+
"team_tasks",
|
|
3895
|
+
"team_runbooks",
|
|
3896
|
+
"team_projects",
|
|
3897
|
+
"team_refresh",
|
|
3898
|
+
"onboarding_start",
|
|
3899
|
+
"onboarding_device",
|
|
3900
|
+
"onboarding_dismiss",
|
|
3901
|
+
"onboarding_projects",
|
|
3902
|
+
"onboarding_settings",
|
|
3903
|
+
"onboarding_next",
|
|
3904
|
+
"onboarding_sessions",
|
|
3905
|
+
"onboarding_tasks",
|
|
3906
|
+
"onboarding_back",
|
|
3907
|
+
"onboarding_finish",
|
|
3908
|
+
"onboarding_restart",
|
|
3909
|
+
].includes(action)) {
|
|
3910
|
+
if ((action === "tasks_cancel_one" || action === "tasks_review") &&
|
|
3911
|
+
typeof value.task_id !== "string")
|
|
3912
|
+
return null;
|
|
3913
|
+
return {
|
|
3914
|
+
bridge: "feishu-codex-v5",
|
|
3915
|
+
action: action,
|
|
3916
|
+
...(typeof value.task_id === "string" ? { task_id: value.task_id } : {}),
|
|
3917
|
+
};
|
|
3918
|
+
}
|
|
3919
|
+
return null;
|
|
3920
|
+
}
|
|
3921
|
+
if (value.bridge === "feishu-codex-v4") {
|
|
3922
|
+
const action = String(value.action ?? "");
|
|
3923
|
+
if ([
|
|
3924
|
+
"device_refresh",
|
|
3925
|
+
"device_quota",
|
|
3926
|
+
"device_projects",
|
|
3927
|
+
"device_settings",
|
|
3928
|
+
"device_sessions",
|
|
3929
|
+
"device_tasks",
|
|
3930
|
+
"device_new_session",
|
|
3931
|
+
"device_stop",
|
|
3932
|
+
"device_reconnect",
|
|
3933
|
+
"quota_refresh",
|
|
3934
|
+
"quota_device",
|
|
3935
|
+
"remote_ready_enable",
|
|
3936
|
+
"remote_ready_disable",
|
|
3937
|
+
].includes(action)) {
|
|
3938
|
+
return {
|
|
3939
|
+
bridge: "feishu-codex-v4",
|
|
3940
|
+
action: action,
|
|
3941
|
+
};
|
|
3942
|
+
}
|
|
3943
|
+
if (![
|
|
3944
|
+
"approve_runtime_once",
|
|
3945
|
+
"approve_runtime_session",
|
|
3946
|
+
"reject_runtime",
|
|
3947
|
+
"answer_runtime",
|
|
3948
|
+
"cancel_runtime_question",
|
|
3949
|
+
].includes(action) ||
|
|
3950
|
+
typeof value.request_id !== "string") {
|
|
3951
|
+
return null;
|
|
3952
|
+
}
|
|
3953
|
+
return {
|
|
3954
|
+
bridge: "feishu-codex-v4",
|
|
3955
|
+
action: action,
|
|
3956
|
+
request_id: value.request_id,
|
|
3957
|
+
...(typeof value.question_id === "string" ? { question_id: value.question_id } : {}),
|
|
3958
|
+
...(typeof value.answer === "string" ? { answer: value.answer } : {}),
|
|
3959
|
+
};
|
|
3960
|
+
}
|
|
3961
|
+
if (value.bridge !== "feishu-codex-v2" && value.bridge !== "feishu-codex-v3")
|
|
3962
|
+
return null;
|
|
3963
|
+
if (value.action === "select_project" || value.action === "toggle_project_favorite") {
|
|
3964
|
+
return {
|
|
3965
|
+
bridge: value.bridge,
|
|
3966
|
+
action: value.action,
|
|
3967
|
+
};
|
|
3968
|
+
}
|
|
3969
|
+
if (value.bridge === "feishu-codex-v3" &&
|
|
3970
|
+
(value.action === "approve_external" || value.action === "reject_external") &&
|
|
3971
|
+
typeof value.request_id === "string") {
|
|
3972
|
+
return {
|
|
3973
|
+
bridge: "feishu-codex-v3",
|
|
3974
|
+
action: value.action,
|
|
3975
|
+
request_id: value.request_id,
|
|
3976
|
+
};
|
|
3977
|
+
}
|
|
3978
|
+
if (![
|
|
3979
|
+
"cancel",
|
|
3980
|
+
"retry",
|
|
3981
|
+
"new",
|
|
3982
|
+
"changes",
|
|
3983
|
+
"review",
|
|
3984
|
+
"result",
|
|
3985
|
+
"result_back",
|
|
3986
|
+
"handoff",
|
|
3987
|
+
"takeover",
|
|
3988
|
+
].includes(String(value.action ?? "")) ||
|
|
3989
|
+
typeof value.task_id !== "string") {
|
|
3990
|
+
return null;
|
|
3991
|
+
}
|
|
3992
|
+
return {
|
|
3993
|
+
bridge: value.bridge,
|
|
3994
|
+
action: value.action,
|
|
3995
|
+
task_id: value.task_id,
|
|
3996
|
+
};
|
|
3997
|
+
}
|
|
3998
|
+
catch {
|
|
3999
|
+
return null;
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
function cardInteger(value) {
|
|
4003
|
+
const parsed = typeof value === "number"
|
|
4004
|
+
? value
|
|
4005
|
+
: typeof value === "string" && /^\d+$/.test(value)
|
|
4006
|
+
? Number.parseInt(value, 10)
|
|
4007
|
+
: Number.NaN;
|
|
4008
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
4009
|
+
}
|
|
4010
|
+
function switchFeedback(project, cancelledActive, cancelledQueued, threadReset) {
|
|
4011
|
+
const cancelled = cancelledActive + cancelledQueued;
|
|
4012
|
+
const session = threadReset ? "已开启新会话" : "将从新会话开始";
|
|
4013
|
+
return `已切换到 ${project.name} · ${session}${cancelled > 0 ? ` · 已取消 ${cancelled} 个未完成任务` : ""}`;
|
|
4014
|
+
}
|
|
4015
|
+
function formatDuration(milliseconds) {
|
|
4016
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
4017
|
+
if (seconds < 60)
|
|
4018
|
+
return `${seconds} 秒`;
|
|
4019
|
+
const minutes = Math.floor(seconds / 60);
|
|
4020
|
+
if (minutes < 60)
|
|
4021
|
+
return `${minutes} 分钟`;
|
|
4022
|
+
const hours = Math.floor(minutes / 60);
|
|
4023
|
+
const remainder = minutes % 60;
|
|
4024
|
+
return `${hours} 小时${remainder > 0 ? ` ${remainder} 分钟` : ""}`;
|
|
4025
|
+
}
|
|
4026
|
+
function pruneTaskRecords() {
|
|
4027
|
+
if (tasks.size <= 100)
|
|
4028
|
+
return;
|
|
4029
|
+
for (const [id, record] of tasks) {
|
|
4030
|
+
if (record.status === "queued" || record.status === "running")
|
|
4031
|
+
continue;
|
|
4032
|
+
const scheduled = taskPersistTimers.get(id);
|
|
4033
|
+
if (scheduled)
|
|
4034
|
+
clearTimeout(scheduled);
|
|
4035
|
+
taskPersistTimers.delete(id);
|
|
4036
|
+
tasks.delete(id);
|
|
4037
|
+
void state.removeTask(id);
|
|
4038
|
+
if (tasks.size <= 80)
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
function protectedAttachmentPaths() {
|
|
4043
|
+
return [
|
|
4044
|
+
...[...tasks.values()]
|
|
4045
|
+
.filter((task) => task.status === "queued" || task.status === "running")
|
|
4046
|
+
.flatMap((task) => task.attachments.map((attachment) => attachment.path)),
|
|
4047
|
+
...[...pendingConfirmations.values()].flatMap((confirmation) => confirmation.attachments.map((attachment) => attachment.path)),
|
|
4048
|
+
];
|
|
4049
|
+
}
|
|
4050
|
+
async function runMaintenance() {
|
|
4051
|
+
const cleanup = await cleanupAttachmentCache(join(config.projectDir, "lark-im-resources"), config.attachmentRetentionHours * 60 * 60_000, protectedAttachmentPaths());
|
|
4052
|
+
const trimmedLogs = await trimRuntimeLogs(join(config.dataDir, "log"), config.maxLogBytes);
|
|
4053
|
+
if (cleanup.removedFiles > 0 || trimmedLogs > 0) {
|
|
4054
|
+
console.info(`[bridge] maintenance removed_files=${cleanup.removedFiles} reclaimed_bytes=${cleanup.reclaimedBytes} trimmed_logs=${trimmedLogs}`);
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
function startMaintenanceTimer() {
|
|
4058
|
+
maintenanceTimer = setInterval(() => {
|
|
4059
|
+
void runMaintenance().catch((error) => {
|
|
4060
|
+
console.error("[bridge] runtime maintenance failed", error);
|
|
4061
|
+
});
|
|
4062
|
+
}, 6 * 60 * 60_000);
|
|
4063
|
+
maintenanceTimer.unref();
|
|
4064
|
+
}
|
|
4065
|
+
function replayOutbox() {
|
|
4066
|
+
if (outboxReplayPromise)
|
|
4067
|
+
return outboxReplayPromise;
|
|
4068
|
+
const operation = replayOutboxOnce().finally(() => {
|
|
4069
|
+
if (outboxReplayPromise === operation)
|
|
4070
|
+
outboxReplayPromise = null;
|
|
4071
|
+
});
|
|
4072
|
+
outboxReplayPromise = operation;
|
|
4073
|
+
return operation;
|
|
4074
|
+
}
|
|
4075
|
+
async function replayOutboxOnce() {
|
|
4076
|
+
const pending = state.listDueOutbox();
|
|
4077
|
+
for (const item of pending) {
|
|
4078
|
+
try {
|
|
4079
|
+
await deliverTextFallback(item.payload.messageId, redactSensitiveText(item.payload.text), item.payload.idempotencyKey);
|
|
4080
|
+
await state.markOutboxSent(item.id);
|
|
4081
|
+
}
|
|
4082
|
+
catch (error) {
|
|
4083
|
+
await state.markOutboxFailed(item.id, safeErrorText(error));
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
if (pending.length > 0) {
|
|
4087
|
+
console.info(`[bridge] replayed durable outbox candidates=${pending.length}`);
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
function startOutboxTimer() {
|
|
4091
|
+
outboxTimer = setInterval(() => {
|
|
4092
|
+
void replayOutbox().catch((error) => {
|
|
4093
|
+
console.error("[bridge] durable outbox replay failed", error);
|
|
4094
|
+
});
|
|
4095
|
+
}, 30_000);
|
|
4096
|
+
outboxTimer.unref();
|
|
4097
|
+
}
|
|
4098
|
+
function pruneProjectCards() {
|
|
4099
|
+
if (projectCards.size <= 50)
|
|
4100
|
+
return;
|
|
4101
|
+
const oldest = [...projectCards.entries()].sort(([, left], [, right]) => left.createdAt - right.createdAt);
|
|
4102
|
+
for (const [messageId] of oldest.slice(0, projectCards.size - 40)) {
|
|
4103
|
+
projectCards.delete(messageId);
|
|
4104
|
+
void state.removeProjectCard(messageId);
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
function pruneDeviceCards() {
|
|
4108
|
+
if (deviceCards.size <= 30)
|
|
4109
|
+
return;
|
|
4110
|
+
const oldest = [...deviceCards.entries()].sort(([, left], [, right]) => left.createdAt - right.createdAt);
|
|
4111
|
+
for (const [messageId] of oldest.slice(0, deviceCards.size - 20)) {
|
|
4112
|
+
deviceCards.delete(messageId);
|
|
4113
|
+
void state.removeDeviceCard(messageId);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
function pruneControlCards(records) {
|
|
4117
|
+
const cutoff = Date.now() - 24 * 60 * 60_000;
|
|
4118
|
+
for (const [messageId, record] of records) {
|
|
4119
|
+
if (record.createdAt < cutoff)
|
|
4120
|
+
records.delete(messageId);
|
|
4121
|
+
}
|
|
4122
|
+
while (records.size > 100) {
|
|
4123
|
+
const oldest = records.keys().next().value;
|
|
4124
|
+
if (!oldest)
|
|
4125
|
+
break;
|
|
4126
|
+
records.delete(oldest);
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
async function shutdown(signal) {
|
|
4130
|
+
if (shutdownController.signal.aborted)
|
|
4131
|
+
return;
|
|
4132
|
+
console.info(`[bridge] shutting down after ${signal}`);
|
|
4133
|
+
shutdownController.abort();
|
|
4134
|
+
if (healthTimer) {
|
|
4135
|
+
clearInterval(healthTimer);
|
|
4136
|
+
healthTimer = null;
|
|
4137
|
+
}
|
|
4138
|
+
await publishBridgeHealth("stopping").catch(() => undefined);
|
|
4139
|
+
if (maintenanceTimer) {
|
|
4140
|
+
clearInterval(maintenanceTimer);
|
|
4141
|
+
maintenanceTimer = null;
|
|
4142
|
+
}
|
|
4143
|
+
if (outboxTimer) {
|
|
4144
|
+
clearInterval(outboxTimer);
|
|
4145
|
+
outboxTimer = null;
|
|
4146
|
+
}
|
|
4147
|
+
queue.pause();
|
|
4148
|
+
runner.cancelAll("shutdown");
|
|
4149
|
+
remoteReady.close();
|
|
4150
|
+
await Promise.all([...tasks.keys()].map((taskId) => closeRuntimeInteractionsForTask(taskId)));
|
|
4151
|
+
await runner.close();
|
|
4152
|
+
await queue.waitForIdle();
|
|
4153
|
+
if (outboxReplayPromise)
|
|
4154
|
+
await outboxReplayPromise.catch(() => undefined);
|
|
4155
|
+
await flushScheduledTaskPersists();
|
|
4156
|
+
await state.flush();
|
|
4157
|
+
state.close();
|
|
4158
|
+
await removeBridgeHealth(config.dataDir, process.pid).catch(() => undefined);
|
|
4159
|
+
}
|
|
4160
|
+
function bridgeHealthStatus() {
|
|
4161
|
+
const consumers = lark.getConsumerHealth();
|
|
4162
|
+
if (consumers.length < 2)
|
|
4163
|
+
return "starting";
|
|
4164
|
+
return consumers.every((consumer) => consumer.ready) && lark.getApiHealth().state !== "degraded"
|
|
4165
|
+
? "ready"
|
|
4166
|
+
: "degraded";
|
|
4167
|
+
}
|
|
4168
|
+
async function publishBridgeHealth(status = bridgeHealthStatus()) {
|
|
4169
|
+
const configFile = process.env.DOTENV_CONFIG_PATH?.trim();
|
|
4170
|
+
const api = lark.getApiHealth();
|
|
4171
|
+
await writeBridgeHealth(config.dataDir, {
|
|
4172
|
+
status,
|
|
4173
|
+
instanceId: config.instanceId,
|
|
4174
|
+
pid: process.pid,
|
|
4175
|
+
startedAt: bridgeStartedAt,
|
|
4176
|
+
configFile: configFile ? resolve(configFile) : null,
|
|
4177
|
+
productVersion: PACKAGE_VERSION,
|
|
4178
|
+
packageRoot: PACKAGE_ROOT,
|
|
4179
|
+
activeTasks: queue.activeCount,
|
|
4180
|
+
queuedTasks: queue.pendingCount,
|
|
4181
|
+
consumers: lark.getConsumerHealth().map((consumer) => ({
|
|
4182
|
+
eventKey: consumer.eventKey,
|
|
4183
|
+
ready: consumer.ready,
|
|
4184
|
+
restartCount: consumer.restartCount,
|
|
4185
|
+
...(consumer.lastReadyAt
|
|
4186
|
+
? { lastReadyAt: new Date(consumer.lastReadyAt).toISOString() }
|
|
4187
|
+
: {}),
|
|
4188
|
+
...(consumer.lastExitAt
|
|
4189
|
+
? { lastExitAt: new Date(consumer.lastExitAt).toISOString() }
|
|
4190
|
+
: {}),
|
|
4191
|
+
})),
|
|
4192
|
+
api: {
|
|
4193
|
+
state: api.state,
|
|
4194
|
+
consecutiveFailures: api.consecutiveFailures,
|
|
4195
|
+
...(api.lastSuccessAt
|
|
4196
|
+
? { lastSuccessAt: new Date(api.lastSuccessAt).toISOString() }
|
|
4197
|
+
: {}),
|
|
4198
|
+
...(api.lastFailureAt
|
|
4199
|
+
? { lastFailureAt: new Date(api.lastFailureAt).toISOString() }
|
|
4200
|
+
: {}),
|
|
4201
|
+
...(api.lastError ? { lastError: api.lastError } : {}),
|
|
4202
|
+
},
|
|
4203
|
+
});
|
|
4204
|
+
}
|
|
4205
|
+
function startHealthTimer() {
|
|
4206
|
+
if (healthTimer)
|
|
4207
|
+
return;
|
|
4208
|
+
healthTimer = setInterval(() => {
|
|
4209
|
+
void publishBridgeHealth().catch((error) => {
|
|
4210
|
+
console.error("[bridge] unable to update health marker", error);
|
|
4211
|
+
});
|
|
4212
|
+
}, 10_000);
|
|
4213
|
+
healthTimer.unref();
|
|
4214
|
+
}
|
|
4215
|
+
async function refreshPersistedDeviceCards(recovery) {
|
|
4216
|
+
const newestByConversation = new Map();
|
|
4217
|
+
let refreshed = 0;
|
|
4218
|
+
for (const [messageId, record] of [...deviceCards.entries()].sort(([, left], [, right]) => right.createdAt - left.createdAt)) {
|
|
4219
|
+
try {
|
|
4220
|
+
const snapshot = await buildDeviceSnapshot(record.conversationKey, record.ownerId, recovery.unexpected ? "本机服务已恢复,状态已自动刷新。" : "");
|
|
4221
|
+
record.sequence += 1;
|
|
4222
|
+
await lark.updateCard(record.cardId, renderDeviceCard(snapshot), record.sequence);
|
|
4223
|
+
await state.upsertDeviceCard({
|
|
4224
|
+
messageId,
|
|
4225
|
+
cardId: record.cardId,
|
|
4226
|
+
conversationKey: record.conversationKey,
|
|
4227
|
+
ownerId: record.ownerId,
|
|
4228
|
+
sequence: record.sequence,
|
|
4229
|
+
createdAt: record.createdAt,
|
|
4230
|
+
});
|
|
4231
|
+
refreshed += 1;
|
|
4232
|
+
if (!newestByConversation.has(record.conversationKey)) {
|
|
4233
|
+
newestByConversation.set(record.conversationKey, { messageId, record });
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
catch (error) {
|
|
4237
|
+
console.warn(`[bridge] unable to refresh persisted device card message=${logRef(messageId)}`, error);
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
if (refreshed > 0)
|
|
4241
|
+
console.info(`[bridge] refreshed ${refreshed} persisted device card(s)`);
|
|
4242
|
+
if (!recovery.notify)
|
|
4243
|
+
return;
|
|
4244
|
+
const now = Date.now();
|
|
4245
|
+
for (const [conversationKey, { messageId }] of newestByConversation) {
|
|
4246
|
+
const lastNotice = state.getLastDeviceRecoveryNotice(conversationKey);
|
|
4247
|
+
if (!shouldSendRecoveryNotice(lastNotice, now))
|
|
4248
|
+
continue;
|
|
4249
|
+
const notifiedAt = new Date(now).toISOString();
|
|
4250
|
+
try {
|
|
4251
|
+
await productReply(messageId, `本地 Codex 已恢复在线,检测到此前离线约 ${formatDuration(recovery.offlineForMs)}。已有控制台状态已刷新。`, replyKey(recovery.previousHeartbeatAt ?? bridgeStartedAt, `device-recovered-${config.instanceId}`), "device-recovered");
|
|
4252
|
+
await state.setLastDeviceRecoveryNotice(conversationKey, notifiedAt);
|
|
4253
|
+
}
|
|
4254
|
+
catch (error) {
|
|
4255
|
+
console.warn(`[bridge] unable to send recovery notice chat=${logRef(conversationKey)}`, error);
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
function processIsAlive(pid) {
|
|
4260
|
+
try {
|
|
4261
|
+
process.kill(pid, 0);
|
|
4262
|
+
return true;
|
|
4263
|
+
}
|
|
4264
|
+
catch (error) {
|
|
4265
|
+
return error.code === "EPERM";
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
async function main() {
|
|
4269
|
+
const previousHealth = await readBridgeHealth(config.dataDir);
|
|
4270
|
+
const recoveryDecision = assessBridgeRecovery(previousHealth, {
|
|
4271
|
+
currentPid: process.pid,
|
|
4272
|
+
processAlive: processIsAlive,
|
|
4273
|
+
});
|
|
4274
|
+
if (recoveryDecision.blockDuplicate && previousHealth) {
|
|
4275
|
+
throw new Error(`Another bridge process is already using this data directory (pid=${previousHealth.pid}, instance=${previousHealth.instanceId}).`);
|
|
4276
|
+
}
|
|
4277
|
+
const recovery = {
|
|
4278
|
+
unexpected: recoveryDecision.unexpected,
|
|
4279
|
+
notify: recoveryDecision.notify,
|
|
4280
|
+
offlineForMs: recoveryDecision.offlineForMs,
|
|
4281
|
+
...(recoveryDecision.previousHeartbeatAt
|
|
4282
|
+
? { previousHeartbeatAt: recoveryDecision.previousHeartbeatAt }
|
|
4283
|
+
: {}),
|
|
4284
|
+
};
|
|
4285
|
+
await removeBridgeHealth(config.dataDir);
|
|
4286
|
+
await publishBridgeHealth("starting");
|
|
4287
|
+
ownsHealthMarker = true;
|
|
4288
|
+
await state.load();
|
|
4289
|
+
if (state.getRemoteReady()) {
|
|
4290
|
+
try {
|
|
4291
|
+
await remoteReady.setEnabled(true);
|
|
4292
|
+
}
|
|
4293
|
+
catch (error) {
|
|
4294
|
+
console.error("[bridge] unable to restore Remote Ready", error);
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
await replayOutbox();
|
|
4298
|
+
startOutboxTimer();
|
|
4299
|
+
const discoveredProjects = await projectRegistry.refresh();
|
|
4300
|
+
await restorePersistedRecords();
|
|
4301
|
+
await runMaintenance();
|
|
4302
|
+
startMaintenanceTimer();
|
|
4303
|
+
console.info(`[bridge] starting V5 instance=${config.instanceId} default=${logRef(config.workdir)} projects=${discoveredProjects.length} roots=${config.projectRoots.length} codex_sync=${config.syncSavedProjects} sandbox=${config.sandboxMode} operator_sandbox=${config.operatorSandboxMode} concurrency=${config.maxConcurrentTasks} admins=${config.adminSenderIds.size} operators=${config.allowedSenderIds.size} viewers=${config.viewerSenderIds.size} project_acl=${config.projectAcl.size} group_scope=${config.groupSessionScope} auto_onboarding=${config.autoOnboarding} chat_allowlist=${config.allowedChatIds.size} env_passthrough=${config.codexAllowedEnvVars.length}`);
|
|
4304
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
4305
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
4306
|
+
const ready = new Set();
|
|
4307
|
+
const reportReady = (consumer) => {
|
|
4308
|
+
ready.add(consumer);
|
|
4309
|
+
if (ready.size === 2) {
|
|
4310
|
+
console.info("[bridge] V5 ready — team isolation, model controls, sessions, durable queue, approvals, and Remote Ready are listening");
|
|
4311
|
+
void publishBridgeHealth("ready").catch((error) => {
|
|
4312
|
+
console.error("[bridge] unable to publish ready health marker", error);
|
|
4313
|
+
});
|
|
4314
|
+
void refreshPersistedDeviceCards(recovery).catch((error) => {
|
|
4315
|
+
console.error("[bridge] unable to refresh recovered device cards", error);
|
|
4316
|
+
});
|
|
4317
|
+
startHealthTimer();
|
|
4318
|
+
}
|
|
4319
|
+
};
|
|
4320
|
+
await Promise.all([
|
|
4321
|
+
lark.consumeMessages(handleEvent, shutdownController.signal, () => reportReady("messages")),
|
|
4322
|
+
lark.consumeEvent("card.action.trigger", handleCardAction, shutdownController.signal, () => reportReady("cards")),
|
|
4323
|
+
]);
|
|
4324
|
+
}
|
|
4325
|
+
main().catch(async (error) => {
|
|
4326
|
+
console.error("[bridge] fatal", error);
|
|
4327
|
+
if (ownsHealthMarker)
|
|
4328
|
+
await publishBridgeHealth("failed").catch(() => undefined);
|
|
4329
|
+
process.exitCode = 1;
|
|
4330
|
+
});
|