@controlflow-ai/daemon 0.1.1 → 0.1.3
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/README.md +66 -24
- package/package.json +16 -3
- package/src/agent-avatar.ts +30 -0
- package/src/agent-key.ts +28 -0
- package/src/agent-permissions.ts +359 -0
- package/src/agent-runtime.ts +810 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +2183 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +482 -12
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +460 -26
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +958 -101
- package/src/db.ts +3216 -113
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +7 -137
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +61 -5
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/setup.ts +74 -5
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +611 -14
- package/src/local-auth.ts +36 -3
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations/024_agents_model.ts +10 -0
- package/src/migrations/025_room_archive.ts +44 -0
- package/src/migrations/026_project_archive.ts +44 -0
- package/src/migrations/027_agent_permission_profiles.ts +16 -0
- package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
- package/src/migrations/029_held_message_drafts.ts +32 -0
- package/src/migrations/030_agent_room_read_state.ts +25 -0
- package/src/migrations/031_room_tasks.ts +29 -0
- package/src/migrations/032_room_reminders.ts +29 -0
- package/src/migrations/033_room_saved_messages.ts +25 -0
- package/src/migrations/034_agent_activity_events.ts +27 -0
- package/src/migrations/035_agent_avatars.ts +17 -0
- package/src/migrations/036_project_agent_defaults.ts +21 -0
- package/src/migrations/037_message_attachments.ts +36 -0
- package/src/migrations/038_agent_activity_room_scope.ts +64 -0
- package/src/migrations/039_message_attachments_path.ts +34 -0
- package/src/migrations/040_message_attachments_file_schema.ts +80 -0
- package/src/migrations/041_room_system_events.ts +30 -0
- package/src/migrations/042_message_attachment_file_kind.ts +52 -0
- package/src/migrations/043_room_mode_skill_registry.ts +92 -0
- package/src/migrations/044_workflow_runtime.ts +69 -0
- package/src/migrations/045_skill_repository_ownership.ts +64 -0
- package/src/migrations.ts +70 -1
- package/src/neeko.ts +40 -4
- package/src/runtime-env.ts +179 -0
- package/src/runtime-registry.ts +83 -13
- package/src/server.ts +244 -4
- package/src/token-file.ts +13 -6
- package/src/types.ts +394 -0
- package/src/workflow-runtime.ts +275 -0
- package/src/web.ts +0 -904
package/src/app.ts
CHANGED
|
@@ -1,13 +1,95 @@
|
|
|
1
1
|
import { artifactHeaders, artifactViewerHtml } from './artifacts.js';
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import QRCode from 'qrcode';
|
|
7
|
+
import { ALL_AGENTS_MENTION, MessageStore } from './db.js';
|
|
3
8
|
import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
|
|
4
9
|
import { assertServerAuth } from './server-auth.js';
|
|
5
|
-
import {
|
|
6
|
-
import type { RunAction, RunStatus } from './types.js';
|
|
10
|
+
import type { AgentActivityKind, AgentRoomSubscriptionMode, Chat, RoomMode, RoomReminderStatus, RoomTaskStatus, RunAction, RunStatus, SkillBindingScope, SkillSource, WorkflowRunStatus } from './types.js';
|
|
7
11
|
import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
|
|
8
|
-
import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
|
|
12
|
+
import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, saveLarkCredentials, type LarkCredentialStore, unbindCredentialAgent } from './lark/credentials.js';
|
|
9
13
|
import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
|
|
14
|
+
import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './lark/app-registration.js';
|
|
15
|
+
import { listRecentInboundEvents, repairInboundEventParseFailures } from './lark/inbound-events.js';
|
|
10
16
|
import { tailscaleAddress } from './network.js';
|
|
17
|
+
import { allRuntimeModelOptions, validateRuntimeModel } from './runtime-registry.js';
|
|
18
|
+
import { validateAgentPermissionProfile, type AgentPermissionProfile as RuntimeAgentPermissionProfile } from './agent-permissions.js';
|
|
19
|
+
import { uniqueAgentKeyFromDisplayName } from './agent-key.js';
|
|
20
|
+
import { AgentWorkspaceFileError, listAgentWorkspaceFiles, updateAgentWorkspaceFile } from './agent-workspace.js';
|
|
21
|
+
import type { AgentDeletionImpact, AgentDeletionLarkBindingImpact, ChannelAccount, Message, MessageDelivery, MessageFreshnessInput } from './types.js';
|
|
22
|
+
import { buildMessagingHealth, buildMessagingStatus, emptyDeliveryWebSocketSummary, type DeliveryConnectionStats, type DeliveryWebSocketSummary, type LarkMessagingStatus } from './messaging-status.js';
|
|
23
|
+
|
|
24
|
+
interface DeliveryNotifyResult {
|
|
25
|
+
deliveries: number;
|
|
26
|
+
target_connections: number;
|
|
27
|
+
open_sockets: number;
|
|
28
|
+
websocket_frames: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AppRouteOptions {
|
|
32
|
+
deliveryNotifier?: {
|
|
33
|
+
notifyDeliveries(deliveries: MessageDelivery[]): DeliveryNotifyResult;
|
|
34
|
+
statsForConnection?(connectionId: string): DeliveryConnectionStats;
|
|
35
|
+
statsAllConnections?(): DeliveryWebSocketSummary;
|
|
36
|
+
};
|
|
37
|
+
larkStatusProvider?: () => LarkMessagingStatus[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface AgentWorkspaceFileUpdateBody {
|
|
41
|
+
path?: unknown;
|
|
42
|
+
content?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface HeldDraftReviseBody {
|
|
46
|
+
content?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function logDeliveryNotifyResult(result: DeliveryNotifyResult | undefined): void {
|
|
50
|
+
if (!result || result.deliveries === 0) return;
|
|
51
|
+
console.log(`[delivery] notify deliveries=${result.deliveries} target_connections=${result.target_connections} open_sockets=${result.open_sockets} websocket_frames=${result.websocket_frames}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function notifyDeliveries(options: AppRouteOptions, deliveries: MessageDelivery[]): DeliveryNotifyResult {
|
|
55
|
+
const result = options.deliveryNotifier?.notifyDeliveries(deliveries) ?? {
|
|
56
|
+
deliveries: deliveries.filter((delivery) => delivery.status === 'pending').length,
|
|
57
|
+
target_connections: 0,
|
|
58
|
+
open_sockets: 0,
|
|
59
|
+
websocket_frames: 0,
|
|
60
|
+
};
|
|
61
|
+
logDeliveryNotifyResult(result);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateMessageMentions(store: MessageStore, roomId: string, batch: Array<{ mentions?: string[] }>): { invalid_mentions: string[]; available_mentions: string[] } | null {
|
|
66
|
+
const mentionables = store.listMentionableRoomParticipants(roomId);
|
|
67
|
+
const available = mentionables.map((participant) => participant.participant_id);
|
|
68
|
+
const valid = new Set(available);
|
|
69
|
+
valid.add(ALL_AGENTS_MENTION);
|
|
70
|
+
const invalid = new Set<string>();
|
|
71
|
+
|
|
72
|
+
for (const item of batch) {
|
|
73
|
+
for (const rawMention of item.mentions ?? []) {
|
|
74
|
+
const mention = typeof rawMention === 'string' ? rawMention.trim().replace(/^@/, '') : '';
|
|
75
|
+
if (!mention || valid.has(mention)) continue;
|
|
76
|
+
invalid.add(mention);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (invalid.size === 0) return null;
|
|
81
|
+
return { invalid_mentions: [...invalid], available_mentions: available };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fireDueRoomRemindersForDelivery(
|
|
85
|
+
store: MessageStore,
|
|
86
|
+
options: AppRouteOptions,
|
|
87
|
+
input: { now?: string | Date | null; limit?: number } = {},
|
|
88
|
+
) {
|
|
89
|
+
const result = store.fireDueRoomReminders(input);
|
|
90
|
+
const notify = notifyDeliveries(options, result.deliveries);
|
|
91
|
+
return { ...result, notify };
|
|
92
|
+
}
|
|
11
93
|
|
|
12
94
|
interface SendBody {
|
|
13
95
|
chat?: string;
|
|
@@ -22,11 +104,277 @@ interface SendBody {
|
|
|
22
104
|
type?: 'message' | 'system';
|
|
23
105
|
idempotency_key?: string | null;
|
|
24
106
|
mentions?: string[];
|
|
107
|
+
attachments?: AttachmentBody[];
|
|
108
|
+
messages?: Array<{
|
|
109
|
+
parent_id?: number;
|
|
110
|
+
channel_id?: string | null;
|
|
111
|
+
recipient?: string | null;
|
|
112
|
+
content?: string;
|
|
113
|
+
type?: 'message' | 'system';
|
|
114
|
+
idempotency_key?: string | null;
|
|
115
|
+
mentions?: string[];
|
|
116
|
+
attachments?: AttachmentBody[];
|
|
117
|
+
}>;
|
|
118
|
+
freshness?: MessageFreshnessInput;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface AttachmentBody {
|
|
122
|
+
kind?: 'image' | 'file';
|
|
123
|
+
mime_type?: string;
|
|
124
|
+
filename?: string | null;
|
|
125
|
+
content_base64?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface AgentRoomReadBody {
|
|
129
|
+
message_id?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface AgentRoomSubscriptionBody {
|
|
133
|
+
mode?: AgentRoomSubscriptionMode;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const agentRoomSubscriptionModes: AgentRoomSubscriptionMode[] = ['all', 'periodic', 'mentions', 'muted', 'off'];
|
|
137
|
+
const agentActivityKinds: AgentActivityKind[] = ['lifecycle', 'working', 'thinking', 'output', 'tool', 'error'];
|
|
138
|
+
const roomTaskStatuses: RoomTaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'closed'];
|
|
139
|
+
const roomReminderStatuses: RoomReminderStatus[] = ['scheduled', 'fired', 'canceled'];
|
|
140
|
+
const roomModes: RoomMode[] = ['standard', 'idea_development'];
|
|
141
|
+
const skillBindingScopes: SkillBindingScope[] = ['project', 'room', 'agent'];
|
|
142
|
+
const skillSources: SkillSource[] = ['system', 'user', 'project'];
|
|
143
|
+
const workflowRunStatuses: WorkflowRunStatus[] = ['running', 'completed', 'failed'];
|
|
144
|
+
|
|
145
|
+
function parseAgentRoomSubscriptionMode(value: unknown): AgentRoomSubscriptionMode {
|
|
146
|
+
if (typeof value !== 'string' || !agentRoomSubscriptionModes.includes(value as AgentRoomSubscriptionMode)) {
|
|
147
|
+
throw new HttpError(400, 'INVALID_SUBSCRIPTION_MODE', 'mode must be all, periodic, mentions, muted, or off');
|
|
148
|
+
}
|
|
149
|
+
return value as AgentRoomSubscriptionMode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseRoomMode(value: unknown): RoomMode {
|
|
153
|
+
if (value === undefined || value === null || value === '') return 'standard';
|
|
154
|
+
if (typeof value !== 'string' || !roomModes.includes(value as RoomMode)) {
|
|
155
|
+
throw new HttpError(400, 'INVALID_ROOM_MODE', 'mode must be standard or idea_development');
|
|
156
|
+
}
|
|
157
|
+
return value as RoomMode;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseSkillBindingScope(value: unknown): SkillBindingScope {
|
|
161
|
+
if (typeof value !== 'string' || !skillBindingScopes.includes(value as SkillBindingScope)) {
|
|
162
|
+
throw new HttpError(400, 'INVALID_SKILL_BINDING_SCOPE', 'scope must be project, room, or agent');
|
|
163
|
+
}
|
|
164
|
+
return value as SkillBindingScope;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseSkillSource(value: unknown): SkillSource {
|
|
168
|
+
if (value === undefined || value === null || value === '') return 'user';
|
|
169
|
+
if (typeof value !== 'string' || !skillSources.includes(value as SkillSource)) {
|
|
170
|
+
throw new HttpError(400, 'INVALID_SKILL_SOURCE', 'source must be system, user, or project');
|
|
171
|
+
}
|
|
172
|
+
return value as SkillSource;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseRoomTaskStatus(value: unknown): RoomTaskStatus {
|
|
176
|
+
if (typeof value !== 'string' || !roomTaskStatuses.includes(value as RoomTaskStatus)) {
|
|
177
|
+
throw new HttpError(400, 'INVALID_TASK_STATUS', 'status must be todo, in_progress, in_review, done, or closed');
|
|
178
|
+
}
|
|
179
|
+
return value as RoomTaskStatus;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseRoomReminderStatus(value: unknown): RoomReminderStatus {
|
|
183
|
+
if (typeof value !== 'string' || !roomReminderStatuses.includes(value as RoomReminderStatus)) {
|
|
184
|
+
throw new HttpError(400, 'INVALID_REMINDER_STATUS', 'status must be scheduled, fired, or canceled');
|
|
185
|
+
}
|
|
186
|
+
return value as RoomReminderStatus;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseWorkflowRunStatus(value: unknown): WorkflowRunStatus {
|
|
190
|
+
if (typeof value !== 'string' || !workflowRunStatuses.includes(value as WorkflowRunStatus)) {
|
|
191
|
+
throw new HttpError(400, 'INVALID_WORKFLOW_RUN_STATUS', 'status must be running, completed, or failed');
|
|
192
|
+
}
|
|
193
|
+
return value as WorkflowRunStatus;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function skillStoreHttpError(error: unknown): HttpError | null {
|
|
197
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
198
|
+
if (message === 'SKILL_ALREADY_EXISTS') return new HttpError(409, 'SKILL_ALREADY_EXISTS', 'skill key already exists');
|
|
199
|
+
if (message === 'SKILL_SOURCE_IMMUTABLE') return new HttpError(409, 'SKILL_SOURCE_IMMUTABLE', 'skill source cannot be changed');
|
|
200
|
+
if (message === 'SYSTEM_SKILL_OWNERSHIP_IMMUTABLE') return new HttpError(409, 'SYSTEM_SKILL_OWNERSHIP_IMMUTABLE', 'system skill ownership cannot be changed');
|
|
201
|
+
if (message === 'SKILL_OWNER_IMMUTABLE') return new HttpError(409, 'SKILL_OWNER_IMMUTABLE', 'skill owner cannot be changed');
|
|
202
|
+
if (message === 'SKILL_PROJECT_IMMUTABLE') return new HttpError(409, 'SKILL_PROJECT_IMMUTABLE', 'skill project cannot be changed');
|
|
203
|
+
if (message === 'SYSTEM_SKILL_CREATE_FORBIDDEN') return new HttpError(409, 'SYSTEM_SKILL_CREATE_FORBIDDEN', 'system skills are seeded by PAL migrations');
|
|
204
|
+
if (message === 'SYSTEM_SKILL_READ_ONLY') return new HttpError(409, 'SYSTEM_SKILL_READ_ONLY', 'system skill definitions are read-only');
|
|
205
|
+
if (message === 'INVALID_SKILL_KEY') return new HttpError(400, 'INVALID_SKILL_KEY', 'skill key must start with a letter or number and contain only letters, numbers, underscores, or hyphens');
|
|
206
|
+
if (message === 'owner_user_id is required for user skills') return new HttpError(400, 'MISSING_SKILL_OWNER', message);
|
|
207
|
+
if (message === 'project_id is required for project skills') return new HttpError(400, 'MISSING_SKILL_PROJECT', message);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function readOptionalJson<T extends object>(request: Request): Promise<T> {
|
|
212
|
+
const text = await request.text();
|
|
213
|
+
if (!text.trim()) return {} as T;
|
|
214
|
+
try {
|
|
215
|
+
const value = JSON.parse(text) as unknown;
|
|
216
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
217
|
+
throw new HttpError(400, 'BAD_JSON', 'expected a JSON object');
|
|
218
|
+
}
|
|
219
|
+
return value as T;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof HttpError) throw error;
|
|
222
|
+
throw new HttpError(400, 'BAD_JSON', 'request body must be valid JSON');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseMessageAttachments(attachments: AttachmentBody[] | undefined) {
|
|
227
|
+
if (!attachments?.length) return [];
|
|
228
|
+
return attachments.map((attachment) => {
|
|
229
|
+
const kind = attachment.kind ?? 'file';
|
|
230
|
+
if (kind !== 'image' && kind !== 'file') throw new HttpError(400, 'UNSUPPORTED_ATTACHMENT_KIND', 'attachment kind must be image or file');
|
|
231
|
+
if (!attachment.mime_type?.trim()) throw new HttpError(400, 'MISSING_ATTACHMENT_MIME', 'attachment mime_type is required');
|
|
232
|
+
if (!attachment.content_base64) throw new HttpError(400, 'MISSING_ATTACHMENT_CONTENT', 'attachment content_base64 is required');
|
|
233
|
+
return {
|
|
234
|
+
kind,
|
|
235
|
+
mimeType: attachment.mime_type,
|
|
236
|
+
filename: attachment.filename,
|
|
237
|
+
content: Uint8Array.from(Buffer.from(attachment.content_base64, 'base64')),
|
|
238
|
+
sourceProvider: 'web' as const,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function taskNumberFromPath(value: string | undefined): number {
|
|
244
|
+
const taskNumber = Number(value);
|
|
245
|
+
if (!Number.isInteger(taskNumber) || taskNumber < 1) {
|
|
246
|
+
throw new HttpError(400, 'INVALID_TASK_NUMBER', 'task_number must be a positive integer');
|
|
247
|
+
}
|
|
248
|
+
return taskNumber;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function freshnessInput(value: unknown): MessageFreshnessInput | null {
|
|
252
|
+
if (!value || typeof value !== 'object') return null;
|
|
253
|
+
const candidate = value as Partial<MessageFreshnessInput>;
|
|
254
|
+
const baseMessageId = Number(candidate.base_message_id);
|
|
255
|
+
if (!Number.isInteger(baseMessageId) || baseMessageId < 0) {
|
|
256
|
+
throw new HttpError(400, 'INVALID_FRESHNESS', 'freshness.base_message_id must be a non-negative integer');
|
|
257
|
+
}
|
|
258
|
+
if (candidate.on_stale !== 'hold' && candidate.on_stale !== 'send_anyway') {
|
|
259
|
+
throw new HttpError(400, 'INVALID_FRESHNESS', 'freshness.on_stale must be hold or send_anyway');
|
|
260
|
+
}
|
|
261
|
+
return { base_message_id: baseMessageId, on_stale: candidate.on_stale };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface ProbeDeliveryBody {
|
|
265
|
+
agent?: string;
|
|
266
|
+
room?: string;
|
|
267
|
+
sender?: string;
|
|
268
|
+
content?: string;
|
|
269
|
+
idempotency_key?: string | null;
|
|
25
270
|
}
|
|
26
271
|
|
|
27
272
|
interface CreateRoomBody {
|
|
28
273
|
name?: string;
|
|
29
274
|
kind?: 'group' | 'dm';
|
|
275
|
+
mode?: RoomMode;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface HandoffRoomBody {
|
|
279
|
+
source_room_id?: string;
|
|
280
|
+
source_message_id?: number;
|
|
281
|
+
actor?: string;
|
|
282
|
+
purpose?: string;
|
|
283
|
+
name?: string;
|
|
284
|
+
team?: Array<{ agent?: string; mode?: string }>;
|
|
285
|
+
handoff_content?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface UpdateRoomBody {
|
|
289
|
+
status?: 'active' | 'archived';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface CreateRoomTasksBody {
|
|
293
|
+
title?: string;
|
|
294
|
+
tasks?: Array<{ title?: string }>;
|
|
295
|
+
created_by?: string | null;
|
|
296
|
+
source_message_id?: number | null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface ClaimRoomTaskBody {
|
|
300
|
+
assignee?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface UpdateRoomTaskBody {
|
|
304
|
+
status?: RoomTaskStatus;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface CreateRoomReminderBody {
|
|
308
|
+
msg_id?: number;
|
|
309
|
+
source_message_id?: number;
|
|
310
|
+
title?: string;
|
|
311
|
+
created_by?: string | null;
|
|
312
|
+
fire_at?: string | null;
|
|
313
|
+
delay_seconds?: number | null;
|
|
314
|
+
repeat?: string | null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface CreateRoomSavedMessageBody {
|
|
318
|
+
msg_id?: number;
|
|
319
|
+
source_message_id?: number;
|
|
320
|
+
saved_by?: string | null;
|
|
321
|
+
note?: string | null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface FireDueRoomRemindersBody {
|
|
325
|
+
now?: string | null;
|
|
326
|
+
limit?: number | null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface CreateProjectBody {
|
|
330
|
+
name?: string;
|
|
331
|
+
computer_id?: string;
|
|
332
|
+
root_path?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface UpdateProjectBody {
|
|
336
|
+
status?: 'active' | 'archived';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface ProjectAgentDefaultsBody {
|
|
340
|
+
defaults?: Array<{ agent?: string; mode?: string }>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface UpsertSkillBody {
|
|
344
|
+
key?: string;
|
|
345
|
+
name?: string;
|
|
346
|
+
description?: string;
|
|
347
|
+
instruction_content?: string;
|
|
348
|
+
status?: 'active' | 'disabled';
|
|
349
|
+
source?: SkillSource;
|
|
350
|
+
owner_user_id?: string | null;
|
|
351
|
+
project_id?: string | null;
|
|
352
|
+
repository_path?: string | null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface SkillBindingBody {
|
|
356
|
+
scope?: SkillBindingScope;
|
|
357
|
+
scope_id?: string;
|
|
358
|
+
skill_key?: string;
|
|
359
|
+
enabled?: boolean;
|
|
360
|
+
priority?: number;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type PalContentFileType = 'markdown' | 'html';
|
|
364
|
+
|
|
365
|
+
interface ProjectPalFileEntry {
|
|
366
|
+
name: string;
|
|
367
|
+
path: string;
|
|
368
|
+
type: 'directory' | 'file';
|
|
369
|
+
file_type?: PalContentFileType;
|
|
370
|
+
size_bytes?: number;
|
|
371
|
+
children?: ProjectPalFileEntry[];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
interface ProjectPalGitCommitBody {
|
|
375
|
+
message?: string;
|
|
376
|
+
files?: string[];
|
|
377
|
+
expected_head?: string | null;
|
|
30
378
|
}
|
|
31
379
|
|
|
32
380
|
interface StartRunBody {
|
|
@@ -60,6 +408,20 @@ interface ProvisionComputerBody {
|
|
|
60
408
|
package_name?: string;
|
|
61
409
|
}
|
|
62
410
|
|
|
411
|
+
interface UpdateComputerBody {
|
|
412
|
+
name?: string;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
interface RegenerateComputerCommandBody {
|
|
416
|
+
server_url?: string;
|
|
417
|
+
package_name?: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
interface DeleteComputerBody {
|
|
421
|
+
remove_projects?: boolean;
|
|
422
|
+
remove_agents?: boolean;
|
|
423
|
+
}
|
|
424
|
+
|
|
63
425
|
interface ConnectComputerBody {
|
|
64
426
|
computer_id?: string;
|
|
65
427
|
secret?: string;
|
|
@@ -71,6 +433,25 @@ interface ConnectComputerBody {
|
|
|
71
433
|
agents?: Array<{ agent?: string; cwd?: string; capabilities?: Record<string, unknown> }>;
|
|
72
434
|
}
|
|
73
435
|
|
|
436
|
+
async function fetchDaemonJson<T>(input: { localUrl: string; token: string; path: string }): Promise<T> {
|
|
437
|
+
const base = input.localUrl.replace(/\/$/, '');
|
|
438
|
+
const response = await fetch(`${base}${input.path}`, {
|
|
439
|
+
headers: { authorization: `Bearer ${input.token}` },
|
|
440
|
+
});
|
|
441
|
+
const payload = await response.json().catch(() => ({})) as { ok?: boolean; data?: T; message?: string; code?: string };
|
|
442
|
+
if (!response.ok || payload.ok === false) {
|
|
443
|
+
throw new HttpError(response.status || 502, payload.code || 'DAEMON_REQUEST_FAILED', payload.message || 'daemon request failed');
|
|
444
|
+
}
|
|
445
|
+
return (payload.data ?? {}) as T;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function validateRemoteProjectPath(store: MessageStore, computerId: string, rootPath: string): Promise<void> {
|
|
449
|
+
const control = store.getComputerLocalControl(computerId);
|
|
450
|
+
if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
|
|
451
|
+
const params = new URLSearchParams({ path: rootPath, validate: 'directory', show_hidden: 'true' });
|
|
452
|
+
await fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files?${params}` });
|
|
453
|
+
}
|
|
454
|
+
|
|
74
455
|
interface AssignAgentBody {
|
|
75
456
|
computer_id?: string;
|
|
76
457
|
}
|
|
@@ -79,13 +460,26 @@ interface OnboardAgentBody {
|
|
|
79
460
|
agent_key?: string;
|
|
80
461
|
display_name?: string;
|
|
81
462
|
runtime?: string | null;
|
|
463
|
+
model?: string | null;
|
|
82
464
|
description?: string | null;
|
|
83
465
|
computer_id?: string;
|
|
84
466
|
}
|
|
85
467
|
|
|
468
|
+
interface DeleteAgentBody {
|
|
469
|
+
confirm_delete?: boolean;
|
|
470
|
+
leave_rooms?: boolean;
|
|
471
|
+
unbind_lark?: boolean;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
interface AgentPermissionsBody {
|
|
475
|
+
filesystemMode?: RuntimeAgentPermissionProfile['filesystemMode'];
|
|
476
|
+
extraWritableRoots?: string[];
|
|
477
|
+
}
|
|
478
|
+
|
|
86
479
|
interface LarkSetupBody {
|
|
87
480
|
app_id?: string;
|
|
88
481
|
app_secret?: string;
|
|
482
|
+
registration_id?: string;
|
|
89
483
|
label?: string;
|
|
90
484
|
agent?: string;
|
|
91
485
|
config?: string;
|
|
@@ -96,6 +490,17 @@ interface LarkAuthorizedUserBody {
|
|
|
96
490
|
display_name?: string | null;
|
|
97
491
|
}
|
|
98
492
|
|
|
493
|
+
async function checkedRuntimeModel(runtime: string | null | undefined, model: string | null | undefined): Promise<string | null> {
|
|
494
|
+
if (model?.trim() && !runtime?.trim()) {
|
|
495
|
+
throw new HttpError(400, 'MISSING_RUNTIME', 'runtime is required when model is configured');
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
return await validateRuntimeModel(runtime ?? '', model);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
throw new HttpError(400, 'INVALID_MODEL', error instanceof Error ? error.message : String(error));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
99
504
|
interface CreateDeliveryBody {
|
|
100
505
|
message_id?: number;
|
|
101
506
|
agent?: string;
|
|
@@ -106,6 +511,7 @@ interface ClaimDeliveryBody {
|
|
|
106
511
|
connection_id?: string | null;
|
|
107
512
|
computer_id?: string | null;
|
|
108
513
|
lease_ms?: number;
|
|
514
|
+
steer_run_id?: string | null;
|
|
109
515
|
}
|
|
110
516
|
|
|
111
517
|
interface SessionBody {
|
|
@@ -151,6 +557,13 @@ interface FinishRunBody {
|
|
|
151
557
|
output?: string;
|
|
152
558
|
}
|
|
153
559
|
|
|
560
|
+
interface RecordRunActivityBody {
|
|
561
|
+
kind?: AgentActivityKind;
|
|
562
|
+
title?: string;
|
|
563
|
+
detail?: string | null;
|
|
564
|
+
metadata?: Record<string, unknown> | null;
|
|
565
|
+
}
|
|
566
|
+
|
|
154
567
|
export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkCredentialStore, sender: string, chatId: string) {
|
|
155
568
|
for (const bot of credentials.bots) {
|
|
156
569
|
if (!boundAgents(bot).includes(sender)) continue;
|
|
@@ -161,14 +574,469 @@ export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkC
|
|
|
161
574
|
return null;
|
|
162
575
|
}
|
|
163
576
|
|
|
577
|
+
export function resolveLarkMentionTarget(store: MessageStore, account: ChannelAccount, recipient: string | null | undefined): { openId: string; displayName?: string | null } | null {
|
|
578
|
+
const target = recipient?.trim();
|
|
579
|
+
if (!target) return null;
|
|
580
|
+
|
|
581
|
+
const agentBot = store.db.query(`
|
|
582
|
+
SELECT bot_open_id, name
|
|
583
|
+
FROM channel_accounts
|
|
584
|
+
WHERE channel = 'lark'
|
|
585
|
+
AND status = 'active'
|
|
586
|
+
AND agent = ?
|
|
587
|
+
AND bot_open_id IS NOT NULL
|
|
588
|
+
AND bot_open_id != ''
|
|
589
|
+
LIMIT 1
|
|
590
|
+
`).get(target) as { bot_open_id: string; name: string | null } | null;
|
|
591
|
+
if (agentBot) return { openId: agentBot.bot_open_id, displayName: agentBot.name };
|
|
592
|
+
|
|
593
|
+
if (!account.provider_account_id) return null;
|
|
594
|
+
const identity = store.db.query(`
|
|
595
|
+
SELECT b.external_id, i.display_name
|
|
596
|
+
FROM pal_identities i
|
|
597
|
+
INNER JOIN provider_identity_bindings b ON b.identity_id = i.id
|
|
598
|
+
WHERE i.stable_handle = ?
|
|
599
|
+
AND b.provider = 'lark'
|
|
600
|
+
AND b.provider_account_id = ?
|
|
601
|
+
AND b.external_type IN ('user', 'bot')
|
|
602
|
+
LIMIT 1
|
|
603
|
+
`).get(target, account.provider_account_id) as { external_id: string; display_name: string | null } | null;
|
|
604
|
+
if (!identity) return null;
|
|
605
|
+
return { openId: identity.external_id, displayName: identity.display_name };
|
|
606
|
+
}
|
|
607
|
+
|
|
164
608
|
function routeNotFound(): Response {
|
|
165
609
|
return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
|
|
166
610
|
}
|
|
167
611
|
|
|
612
|
+
const projectPalSupportedExtensions = new Map<string, PalContentFileType>([
|
|
613
|
+
['.md', 'markdown'],
|
|
614
|
+
['.markdown', 'markdown'],
|
|
615
|
+
['.html', 'html'],
|
|
616
|
+
['.htm', 'html'],
|
|
617
|
+
]);
|
|
618
|
+
const projectPalMaxReadBytes = 1024 * 1024;
|
|
619
|
+
const projectPalMaxTreeEntries = 500;
|
|
620
|
+
const projectPalDiffMaxBytes = 512 * 1024;
|
|
621
|
+
|
|
622
|
+
function isPathInside(basePath: string, targetPath: string): boolean {
|
|
623
|
+
const relativePath = relative(basePath, targetPath);
|
|
624
|
+
return relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function normalizePalRelativePath(value: string | null | undefined): string {
|
|
628
|
+
const candidate = (value || '').trim().replace(/\\/g, '/');
|
|
629
|
+
if (!candidate || candidate.startsWith('/') || candidate.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
630
|
+
throw new HttpError(400, 'BAD_PAL_FILE_PATH', 'path must be a relative .pal file path');
|
|
631
|
+
}
|
|
632
|
+
return candidate;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function projectPalRoot(projectRootPath: string): { projectRoot: string; palRoot: string; exists: boolean } {
|
|
636
|
+
const projectRoot = realpathSync(projectRootPath);
|
|
637
|
+
const palPath = resolve(projectRootPath, '.pal');
|
|
638
|
+
if (!existsSync(palPath)) return { projectRoot, palRoot: palPath, exists: false };
|
|
639
|
+
const palRoot = realpathSync(palPath);
|
|
640
|
+
if (!isPathInside(projectRoot, palRoot)) {
|
|
641
|
+
throw new HttpError(403, 'PAL_ROOT_OUTSIDE_PROJECT', '.pal must resolve inside the project root');
|
|
642
|
+
}
|
|
643
|
+
const stat = statSync(palRoot);
|
|
644
|
+
if (!stat.isDirectory()) throw new HttpError(400, 'PAL_ROOT_NOT_DIRECTORY', '.pal is not a directory');
|
|
645
|
+
return { projectRoot, palRoot, exists: true };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function projectPalFileType(filePath: string): PalContentFileType | null {
|
|
649
|
+
return projectPalSupportedExtensions.get(extname(filePath).toLowerCase()) || null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function isProjectPalMarkdownPath(filePath: string): boolean {
|
|
653
|
+
return projectPalFileType(filePath) === 'markdown';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function gitPathJoin(...parts: string[]): string {
|
|
657
|
+
return parts
|
|
658
|
+
.flatMap((part) => part.split(/[\\/]/))
|
|
659
|
+
.filter(Boolean)
|
|
660
|
+
.join('/');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function gitCommand(projectRootPath: string, args: string[], options?: { check?: boolean }) {
|
|
664
|
+
const result = spawnSync('git', ['-C', projectRootPath, ...args], { encoding: 'utf8' });
|
|
665
|
+
const stdout = result.stdout || '';
|
|
666
|
+
const stderr = result.stderr || '';
|
|
667
|
+
const status = result.status ?? 1;
|
|
668
|
+
if (options?.check !== false && status !== 0) {
|
|
669
|
+
const message = (stderr || stdout || `git ${args[0] || 'command'} failed`).trim();
|
|
670
|
+
throw new HttpError(409, 'GIT_COMMAND_FAILED', message);
|
|
671
|
+
}
|
|
672
|
+
return { stdout, stderr, status };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function projectPalGitInfo(projectRootPath: string) {
|
|
676
|
+
const root = projectPalRoot(projectRootPath);
|
|
677
|
+
const repoRootResult = gitCommand(root.projectRoot, ['rev-parse', '--show-toplevel'], { check: false });
|
|
678
|
+
if (repoRootResult.status !== 0) {
|
|
679
|
+
return {
|
|
680
|
+
root,
|
|
681
|
+
git: {
|
|
682
|
+
available: false,
|
|
683
|
+
repo_root: null,
|
|
684
|
+
head: null,
|
|
685
|
+
branch: null,
|
|
686
|
+
error: (repoRootResult.stderr || repoRootResult.stdout || 'project is not inside a Git repository').trim(),
|
|
687
|
+
dirty: false,
|
|
688
|
+
},
|
|
689
|
+
repoRoot: null,
|
|
690
|
+
palRepoPath: null,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const repoRoot = realpathSync(repoRootResult.stdout.trim());
|
|
694
|
+
if (!isPathInside(repoRoot, root.projectRoot)) {
|
|
695
|
+
throw new HttpError(403, 'PROJECT_OUTSIDE_GIT_REPO', 'project root must resolve inside its Git repository');
|
|
696
|
+
}
|
|
697
|
+
const headResult = gitCommand(root.projectRoot, ['rev-parse', 'HEAD'], { check: false });
|
|
698
|
+
const branchResult = gitCommand(root.projectRoot, ['branch', '--show-current'], { check: false });
|
|
699
|
+
const palRepoPath = gitPathJoin(relative(repoRoot, root.exists ? root.palRoot : resolve(root.projectRoot, '.pal')));
|
|
700
|
+
return {
|
|
701
|
+
root,
|
|
702
|
+
git: {
|
|
703
|
+
available: true,
|
|
704
|
+
repo_root: repoRoot,
|
|
705
|
+
head: headResult.status === 0 ? headResult.stdout.trim() : null,
|
|
706
|
+
branch: branchResult.status === 0 ? branchResult.stdout.trim() || null : null,
|
|
707
|
+
error: null,
|
|
708
|
+
dirty: false,
|
|
709
|
+
},
|
|
710
|
+
repoRoot,
|
|
711
|
+
palRepoPath,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function normalizeGitStatusPath(rawPath: string): string {
|
|
716
|
+
const renamedPath = rawPath.includes(' -> ') ? rawPath.split(' -> ').pop()! : rawPath;
|
|
717
|
+
return renamedPath.trim().replace(/\\/g, '/').replace(/^"|"$/g, '');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function projectPalRelativeFromRepoPath(repoPath: string, palRepoPath: string): string | null {
|
|
721
|
+
const normalizedPath = repoPath.replace(/\\/g, '/');
|
|
722
|
+
const normalizedRoot = palRepoPath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
723
|
+
if (normalizedPath === normalizedRoot) return null;
|
|
724
|
+
const prefix = normalizedRoot ? `${normalizedRoot}/` : '';
|
|
725
|
+
if (!normalizedPath.startsWith(prefix)) return null;
|
|
726
|
+
const palRelativePath = normalizedPath.slice(prefix.length);
|
|
727
|
+
if (!palRelativePath || palRelativePath.split('/').some((part) => !part || part === '.' || part === '..')) return null;
|
|
728
|
+
return palRelativePath;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function syntheticNewFileDiff(repoPath: string, content: string): string {
|
|
732
|
+
const lines = content.length ? content.split(/\r?\n/) : [];
|
|
733
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
734
|
+
const body = lines.map((line) => `+${line}`).join('\n');
|
|
735
|
+
return [
|
|
736
|
+
`diff --git a/${repoPath} b/${repoPath}`,
|
|
737
|
+
'new file mode 100644',
|
|
738
|
+
'index 0000000..0000000',
|
|
739
|
+
'--- /dev/null',
|
|
740
|
+
`+++ b/${repoPath}`,
|
|
741
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
742
|
+
body,
|
|
743
|
+
].filter(Boolean).join('\n') + '\n';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function gitNumstatForPath(projectRootPath: string, repoPath: string): { additions: number; deletions: number } {
|
|
747
|
+
const result = gitCommand(projectRootPath, ['diff', '--numstat', 'HEAD', '--', repoPath], { check: false });
|
|
748
|
+
if (result.status !== 0 || !result.stdout.trim()) return { additions: 0, deletions: 0 };
|
|
749
|
+
const [additions, deletions] = result.stdout.trim().split(/\s+/);
|
|
750
|
+
return {
|
|
751
|
+
additions: Number.parseInt(additions || '0', 10) || 0,
|
|
752
|
+
deletions: Number.parseInt(deletions || '0', 10) || 0,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function projectPalGitChanges(projectRootPath: string, options?: { includeDiff?: boolean }) {
|
|
757
|
+
const info = projectPalGitInfo(projectRootPath);
|
|
758
|
+
if (!info.git.available || !info.repoRoot || !info.palRepoPath) {
|
|
759
|
+
return { git: info.git, files: [], file_count: 0, additions: 0, deletions: 0, diff: '' };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const statusResult = gitCommand(info.root.projectRoot, ['status', '--porcelain=v1', '--untracked-files=all', '--', info.palRepoPath], { check: false });
|
|
763
|
+
if (statusResult.status !== 0) {
|
|
764
|
+
throw new HttpError(409, 'GIT_STATUS_FAILED', (statusResult.stderr || statusResult.stdout || 'git status failed').trim());
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const files: Array<{ path: string; repo_path: string; status: string; additions: number; deletions: number }> = [];
|
|
768
|
+
const diffableRepoPaths: string[] = [];
|
|
769
|
+
let diff = '';
|
|
770
|
+
|
|
771
|
+
for (const line of statusResult.stdout.split(/\r?\n/)) {
|
|
772
|
+
if (!line.trim()) continue;
|
|
773
|
+
const code = line.slice(0, 2);
|
|
774
|
+
const repoPath = normalizeGitStatusPath(line.slice(3));
|
|
775
|
+
const palRelativePath = projectPalRelativeFromRepoPath(repoPath, info.palRepoPath);
|
|
776
|
+
if (!palRelativePath || !isProjectPalMarkdownPath(palRelativePath)) continue;
|
|
777
|
+
let status = 'modified';
|
|
778
|
+
if (code.includes('?')) status = 'untracked';
|
|
779
|
+
else if (code.includes('D')) status = 'deleted';
|
|
780
|
+
else if (code.includes('A')) status = 'added';
|
|
781
|
+
else if (code.includes('R')) status = 'renamed';
|
|
782
|
+
|
|
783
|
+
let stats = gitNumstatForPath(info.root.projectRoot, repoPath);
|
|
784
|
+
if (status === 'untracked') {
|
|
785
|
+
const absolutePath = resolve(info.repoRoot, repoPath);
|
|
786
|
+
if (!existsSync(absolutePath) || lstatSync(absolutePath).isSymbolicLink()) continue;
|
|
787
|
+
const realFilePath = realpathSync(absolutePath);
|
|
788
|
+
if (!isPathInside(info.root.palRoot, realFilePath)) continue;
|
|
789
|
+
const content = readFileSync(realFilePath, 'utf8');
|
|
790
|
+
stats = { additions: content ? content.split(/\r?\n/).filter((_, index, lines) => !(index === lines.length - 1 && lines[index] === '')).length : 0, deletions: 0 };
|
|
791
|
+
if (options?.includeDiff) diff += syntheticNewFileDiff(repoPath, content);
|
|
792
|
+
} else {
|
|
793
|
+
diffableRepoPaths.push(repoPath);
|
|
794
|
+
}
|
|
795
|
+
files.push({ path: palRelativePath, repo_path: repoPath, status, additions: stats.additions, deletions: stats.deletions });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (options?.includeDiff && diffableRepoPaths.length) {
|
|
799
|
+
const result = gitCommand(info.root.projectRoot, ['diff', '--no-color', 'HEAD', '--', ...diffableRepoPaths], { check: false });
|
|
800
|
+
if (result.status !== 0) throw new HttpError(409, 'GIT_DIFF_FAILED', (result.stderr || result.stdout || 'git diff failed').trim());
|
|
801
|
+
diff = `${result.stdout}${diff}`;
|
|
802
|
+
}
|
|
803
|
+
if (diff.length > projectPalDiffMaxBytes) {
|
|
804
|
+
diff = `${diff.slice(0, projectPalDiffMaxBytes)}\n... diff truncated at ${projectPalDiffMaxBytes} bytes ...\n`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
|
808
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
|
809
|
+
return {
|
|
810
|
+
git: { ...info.git, dirty: files.length > 0 },
|
|
811
|
+
files: files.sort((left, right) => left.path.localeCompare(right.path)),
|
|
812
|
+
file_count: files.length,
|
|
813
|
+
additions,
|
|
814
|
+
deletions,
|
|
815
|
+
diff,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function projectPalCommitPath(projectRootPath: string, requestedPath: string): string {
|
|
820
|
+
const info = projectPalGitInfo(projectRootPath);
|
|
821
|
+
if (!info.git.available || !info.repoRoot || !info.palRepoPath) throw new HttpError(409, 'GIT_NOT_AVAILABLE', info.git.error || 'project is not inside a Git repository');
|
|
822
|
+
const palRelativePath = normalizePalRelativePath(requestedPath);
|
|
823
|
+
if (!isProjectPalMarkdownPath(palRelativePath)) throw new HttpError(400, 'UNSUPPORTED_PAL_COMMIT_FILE', 'only .pal Markdown files can be committed');
|
|
824
|
+
return gitPathJoin(info.palRepoPath, palRelativePath);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function listProjectPalFiles(projectRootPath: string): { rootPath: string; entries: ProjectPalFileEntry[]; supportedFileCount: number; truncated: boolean } {
|
|
828
|
+
const root = projectPalRoot(projectRootPath);
|
|
829
|
+
if (!root.exists) return { rootPath: resolve(projectRootPath, '.pal'), entries: [], supportedFileCount: 0, truncated: false };
|
|
830
|
+
|
|
831
|
+
let visited = 0;
|
|
832
|
+
let supportedFileCount = 0;
|
|
833
|
+
const walk = (directory: string): ProjectPalFileEntry[] => {
|
|
834
|
+
if (visited >= projectPalMaxTreeEntries) return [];
|
|
835
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
836
|
+
const tree: ProjectPalFileEntry[] = [];
|
|
837
|
+
for (const entry of entries) {
|
|
838
|
+
if (visited >= projectPalMaxTreeEntries) break;
|
|
839
|
+
const absolutePath = resolve(directory, entry.name);
|
|
840
|
+
const entryStat = lstatSync(absolutePath);
|
|
841
|
+
if (entryStat.isSymbolicLink()) continue;
|
|
842
|
+
const realEntryPath = realpathSync(absolutePath);
|
|
843
|
+
if (!isPathInside(root.palRoot, realEntryPath)) continue;
|
|
844
|
+
visited += 1;
|
|
845
|
+
const relativePath = relative(root.palRoot, realEntryPath).split(sep).join('/');
|
|
846
|
+
if (entry.isDirectory()) {
|
|
847
|
+
const children = walk(realEntryPath);
|
|
848
|
+
if (children.length) tree.push({ name: entry.name, path: relativePath, type: 'directory', children });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (!entry.isFile()) continue;
|
|
852
|
+
const fileType = projectPalFileType(entry.name);
|
|
853
|
+
if (!fileType) continue;
|
|
854
|
+
supportedFileCount += 1;
|
|
855
|
+
tree.push({ name: entry.name, path: relativePath, type: 'file', file_type: fileType, size_bytes: entryStat.size });
|
|
856
|
+
}
|
|
857
|
+
return tree.sort((left, right) => {
|
|
858
|
+
if (left.type !== right.type) return left.type === 'directory' ? -1 : 1;
|
|
859
|
+
return left.name.localeCompare(right.name);
|
|
860
|
+
});
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
return { rootPath: root.palRoot, entries: walk(root.palRoot), supportedFileCount, truncated: visited >= projectPalMaxTreeEntries };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function readProjectPalFile(projectRootPath: string, requestedPath: string | null | undefined) {
|
|
867
|
+
const root = projectPalRoot(projectRootPath);
|
|
868
|
+
if (!root.exists) throw new HttpError(404, 'PAL_ROOT_NOT_FOUND', '.pal directory was not found for this project');
|
|
869
|
+
const relativePath = normalizePalRelativePath(requestedPath);
|
|
870
|
+
const absolutePath = resolve(root.palRoot, relativePath);
|
|
871
|
+
if (!existsSync(absolutePath)) throw new HttpError(404, 'PAL_FILE_NOT_FOUND', '.pal file was not found');
|
|
872
|
+
if (lstatSync(absolutePath).isSymbolicLink()) throw new HttpError(403, 'PAL_FILE_SYMLINK', 'symbolic links are not readable from .pal');
|
|
873
|
+
const realFilePath = realpathSync(absolutePath);
|
|
874
|
+
if (!isPathInside(root.palRoot, realFilePath)) throw new HttpError(403, 'PAL_FILE_OUTSIDE_PROJECT', 'file must resolve inside the project .pal directory');
|
|
875
|
+
const fileStat = statSync(realFilePath);
|
|
876
|
+
if (!fileStat.isFile()) throw new HttpError(400, 'PAL_FILE_NOT_READABLE', 'path is not a readable file');
|
|
877
|
+
if (fileStat.size > projectPalMaxReadBytes) throw new HttpError(413, 'PAL_FILE_TOO_LARGE', '.pal file is too large to preview');
|
|
878
|
+
const fileType = projectPalFileType(realFilePath);
|
|
879
|
+
if (!fileType) throw new HttpError(400, 'UNSUPPORTED_PAL_FILE', 'only Markdown and HTML files can be previewed');
|
|
880
|
+
return {
|
|
881
|
+
name: basename(realFilePath),
|
|
882
|
+
path: relative(root.palRoot, realFilePath).split(sep).join('/'),
|
|
883
|
+
file_type: fileType,
|
|
884
|
+
mime_type: fileType === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/html; charset=utf-8',
|
|
885
|
+
size_bytes: fileStat.size,
|
|
886
|
+
content: readFileSync(realFilePath, 'utf8'),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
168
890
|
function html(body: string): Response {
|
|
169
891
|
return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
170
892
|
}
|
|
171
893
|
|
|
894
|
+
const webDistDir = fileURLToPath(new URL('../web/app/dist/', import.meta.url));
|
|
895
|
+
const projectRootDir = fileURLToPath(new URL('../', import.meta.url));
|
|
896
|
+
let attemptedWebBuild = false;
|
|
897
|
+
|
|
898
|
+
interface PendingLarkRegistration {
|
|
899
|
+
id: string;
|
|
900
|
+
deviceCode: string;
|
|
901
|
+
url: string;
|
|
902
|
+
qrDataUrl: string;
|
|
903
|
+
expiresAt: number;
|
|
904
|
+
interval: number;
|
|
905
|
+
completed?: LarkRegistrationComplete;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const larkRegistrations = new Map<string, PendingLarkRegistration>();
|
|
909
|
+
|
|
910
|
+
function credentialBindingsForAgent(credentials: LarkCredentialStore, agentKey: string): AgentDeletionLarkBindingImpact[] {
|
|
911
|
+
return credentials.bots
|
|
912
|
+
.filter((bot) => bot.agent === agentKey)
|
|
913
|
+
.map((bot) => ({ source: 'credential' as const, app_id: bot.appId, label: bot.label ?? null }));
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function mergeAgentDeletionImpact(storeImpact: AgentDeletionImpact, credentials: LarkCredentialStore, agentKey: string): AgentDeletionImpact {
|
|
917
|
+
const seen = new Set<string>();
|
|
918
|
+
const larkBindings: AgentDeletionLarkBindingImpact[] = [];
|
|
919
|
+
for (const binding of [...storeImpact.lark_bindings, ...credentialBindingsForAgent(credentials, agentKey)]) {
|
|
920
|
+
const key = `${binding.source}:${binding.app_id}`;
|
|
921
|
+
if (seen.has(key)) continue;
|
|
922
|
+
seen.add(key);
|
|
923
|
+
larkBindings.push(binding);
|
|
924
|
+
}
|
|
925
|
+
return { ...storeImpact, lark_bindings: larkBindings };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function agentDeletionImpact(store: MessageStore, agentKey: string, configPath: string): AgentDeletionImpact | null {
|
|
929
|
+
const storeImpact = store.getAgentDeletionImpact(agentKey);
|
|
930
|
+
if (!storeImpact) return null;
|
|
931
|
+
try {
|
|
932
|
+
return mergeAgentDeletionImpact(storeImpact, loadLarkCredentials(configPath), agentKey);
|
|
933
|
+
} catch {
|
|
934
|
+
return storeImpact;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function pruneLarkRegistrations(): void {
|
|
939
|
+
const now = Date.now();
|
|
940
|
+
for (const [id, registration] of larkRegistrations) {
|
|
941
|
+
if (registration.expiresAt < now - 60_000) larkRegistrations.delete(id);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function larkRegistrationPublic(entry: PendingLarkRegistration, status: 'pending' | 'complete' = entry.completed ? 'complete' : 'pending') {
|
|
946
|
+
return {
|
|
947
|
+
id: entry.id,
|
|
948
|
+
status,
|
|
949
|
+
url: entry.url,
|
|
950
|
+
qrDataUrl: entry.qrDataUrl,
|
|
951
|
+
expiresAt: new Date(entry.expiresAt).toISOString(),
|
|
952
|
+
interval: entry.interval,
|
|
953
|
+
appId: entry.completed?.appId ?? null,
|
|
954
|
+
tenantBrand: entry.completed?.tenantBrand ?? null,
|
|
955
|
+
userOpenId: entry.completed?.userOpenId ?? null,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function pollStoredLarkRegistration(id: string): Promise<PendingLarkRegistration> {
|
|
960
|
+
pruneLarkRegistrations();
|
|
961
|
+
const entry = larkRegistrations.get(id);
|
|
962
|
+
if (!entry) throw new HttpError(404, 'LARK_REGISTRATION_NOT_FOUND', 'registration was not found or has expired');
|
|
963
|
+
if (entry.completed) return entry;
|
|
964
|
+
if (Date.now() > entry.expiresAt) {
|
|
965
|
+
larkRegistrations.delete(id);
|
|
966
|
+
throw new HttpError(410, 'LARK_REGISTRATION_EXPIRED', 'registration link expired');
|
|
967
|
+
}
|
|
968
|
+
const result = await pollLarkAppRegistration({ deviceCode: entry.deviceCode });
|
|
969
|
+
if (result.status === 'pending') return entry;
|
|
970
|
+
if (result.status === 'slow_down') {
|
|
971
|
+
entry.interval = Math.max(entry.interval + 5, result.interval);
|
|
972
|
+
return entry;
|
|
973
|
+
}
|
|
974
|
+
if (result.status === 'complete') {
|
|
975
|
+
entry.completed = result.registration;
|
|
976
|
+
return entry;
|
|
977
|
+
}
|
|
978
|
+
throw new HttpError(400, 'LARK_REGISTRATION_FAILED', result.message);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function webAssetContentType(pathname: string): string {
|
|
982
|
+
if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8';
|
|
983
|
+
if (pathname.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
984
|
+
if (pathname.endsWith('.svg')) return 'image/svg+xml';
|
|
985
|
+
if (pathname.endsWith('.png')) return 'image/png';
|
|
986
|
+
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg';
|
|
987
|
+
if (pathname.endsWith('.webp')) return 'image/webp';
|
|
988
|
+
if (pathname.endsWith('.ico')) return 'image/x-icon';
|
|
989
|
+
return 'application/octet-stream';
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function builtWebIndex(): Response | null {
|
|
993
|
+
ensureBuiltWeb();
|
|
994
|
+
const indexPath = join(webDistDir, 'index.html');
|
|
995
|
+
if (!existsSync(indexPath)) return null;
|
|
996
|
+
return new Response(Bun.file(indexPath), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function ensureBuiltWeb(): void {
|
|
1000
|
+
if (attemptedWebBuild || existsSync(join(webDistDir, 'index.html'))) return;
|
|
1001
|
+
attemptedWebBuild = true;
|
|
1002
|
+
const result = Bun.spawnSync(['bun', 'run', 'web:build'], {
|
|
1003
|
+
cwd: projectRootDir,
|
|
1004
|
+
stdout: 'pipe',
|
|
1005
|
+
stderr: 'pipe',
|
|
1006
|
+
});
|
|
1007
|
+
if (result.exitCode !== 0) {
|
|
1008
|
+
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
1009
|
+
console.warn(`[web] React build failed${stderr ? `: ${stderr}` : ''}`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function missingWebBuild(): Response {
|
|
1014
|
+
return html(`<!doctype html>
|
|
1015
|
+
<html lang="en">
|
|
1016
|
+
<head>
|
|
1017
|
+
<meta charset="utf-8">
|
|
1018
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1019
|
+
<title>Pal Web</title>
|
|
1020
|
+
</head>
|
|
1021
|
+
<body>
|
|
1022
|
+
<main>
|
|
1023
|
+
<h1>React web build not found</h1>
|
|
1024
|
+
<p>Run <code>bun run web:build</code> or use <code>bun run web:dev</code> for the Pal web UI.</p>
|
|
1025
|
+
</main>
|
|
1026
|
+
</body>
|
|
1027
|
+
</html>`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function builtWebAsset(pathname: string): Response | null {
|
|
1031
|
+
if (!pathname.startsWith('/assets/')) return null;
|
|
1032
|
+
ensureBuiltWeb();
|
|
1033
|
+
const relative = decodeURIComponent(pathname.slice('/assets/'.length));
|
|
1034
|
+
if (!relative || relative.includes('..') || relative.includes('/') || relative.includes('\\')) return null;
|
|
1035
|
+
const assetPath = join(webDistDir, 'assets', relative);
|
|
1036
|
+
if (!existsSync(assetPath)) return null;
|
|
1037
|
+
return new Response(Bun.file(assetPath), { headers: { 'content-type': webAssetContentType(assetPath) } });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
172
1040
|
function daemonAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } | null {
|
|
173
1041
|
const computerId = request.headers.get('x-pal-computer-id')?.trim();
|
|
174
1042
|
const connectionId = request.headers.get('x-pal-connection-id')?.trim();
|
|
@@ -195,12 +1063,73 @@ function requireDaemonConnection(store: MessageStore, request: Request): { compu
|
|
|
195
1063
|
return connection;
|
|
196
1064
|
}
|
|
197
1065
|
|
|
198
|
-
|
|
1066
|
+
function requireAgentScopedRead(store: MessageStore, request: Request, url: URL, roomId: string): void {
|
|
1067
|
+
const agent = stringParam(url, 'agent_scope');
|
|
1068
|
+
if (!agent) return;
|
|
1069
|
+
const connection = requireDaemonConnection(store, request);
|
|
1070
|
+
if (!store.daemonHasAgent(connection.connectionId, agent)) {
|
|
1071
|
+
throw new HttpError(403, 'CONNECTION_AGENT_NOT_REGISTERED', 'connection has not registered this agent');
|
|
1072
|
+
}
|
|
1073
|
+
const room = store.getChatById(roomId);
|
|
1074
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1075
|
+
if (!store.canAgentParticipateInRoom(agent, room)) {
|
|
1076
|
+
throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'agent cannot read this room');
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function publishMessageSideEffects(store: MessageStore, options: AppRouteOptions, message: Message, content: string): Promise<{ deliveries: MessageDelivery[]; notify: DeliveryNotifyResult }> {
|
|
1081
|
+
const deliveries = store.resolveDeliveriesForMessage(message.id);
|
|
1082
|
+
const notify = notifyDeliveries(options, deliveries);
|
|
1083
|
+
|
|
1084
|
+
const credentials = loadLarkCredentials();
|
|
1085
|
+
const outboundRoute = resolveLarkOutboundRoute(store, credentials, message.sender, message.chat_id);
|
|
1086
|
+
if (outboundRoute) {
|
|
1087
|
+
const { conversation, bot } = outboundRoute;
|
|
1088
|
+
try {
|
|
1089
|
+
const client = createLarkApiClient(bot.appId, bot.appSecret);
|
|
1090
|
+
const mention = resolveLarkMentionTarget(store, outboundRoute.account, message.recipient);
|
|
1091
|
+
const result = await sendTextMessage({
|
|
1092
|
+
client,
|
|
1093
|
+
receiveIdType: 'chat_id',
|
|
1094
|
+
receiveId: conversation.external_chat_id,
|
|
1095
|
+
text: larkOutboundMessageText(message, content),
|
|
1096
|
+
mention,
|
|
1097
|
+
});
|
|
1098
|
+
console.log(`[lark] forwarded message to chat=${conversation.external_chat_id} messageId=${result.messageId ?? '-'}`);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
console.warn(`[lark] failed to forward message to chat=${conversation.external_chat_id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return { deliveries, notify };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
export function larkOutboundMessageText(message: Message, content: string): string {
|
|
1108
|
+
const attachments = message.attachments ?? [];
|
|
1109
|
+
if (attachments.length === 0) return content;
|
|
1110
|
+
const baseUrl = process.env.PAL_PUBLIC_BASE_URL ?? process.env.PAL_SERVER_PUBLIC_URL ?? '';
|
|
1111
|
+
const lines = attachments.map((attachment) => {
|
|
1112
|
+
const label = attachment.filename || attachment.id;
|
|
1113
|
+
const url = baseUrl
|
|
1114
|
+
? `${baseUrl.replace(/\/+$/, '')}/api/message-attachments/${encodeURIComponent(attachment.id)}/content`
|
|
1115
|
+
: `/api/message-attachments/${encodeURIComponent(attachment.id)}/content`;
|
|
1116
|
+
return `- ${label} (${attachment.mime_type}, ${attachment.size_bytes} bytes): ${url}`;
|
|
1117
|
+
});
|
|
1118
|
+
const body = content.trim() || '[file]';
|
|
1119
|
+
return `${body}\n\nAttachments:\n${lines.join('\n')}`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
export function route(store: MessageStore, request: Request, options: AppRouteOptions = {}): Promise<Response> | Response {
|
|
199
1123
|
const url = new URL(request.url);
|
|
200
1124
|
const { pathname } = url;
|
|
201
1125
|
|
|
202
1126
|
if (request.method === 'GET' && pathname === '/') {
|
|
203
|
-
return
|
|
1127
|
+
return builtWebIndex() ?? missingWebBuild();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (request.method === 'GET') {
|
|
1131
|
+
const asset = builtWebAsset(pathname);
|
|
1132
|
+
if (asset) return asset;
|
|
204
1133
|
}
|
|
205
1134
|
|
|
206
1135
|
if (request.method === 'GET' && pathname === '/health') {
|
|
@@ -222,6 +1151,71 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
222
1151
|
return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
|
|
223
1152
|
}
|
|
224
1153
|
|
|
1154
|
+
const computerMatch = pathname.match(/^\/api\/computers\/([^/]+)$/);
|
|
1155
|
+
if (request.method === 'PATCH' && computerMatch) {
|
|
1156
|
+
return readJson<UpdateComputerBody>(request).then((body) => {
|
|
1157
|
+
const name = body.name?.trim();
|
|
1158
|
+
if (!name) throw new HttpError(400, 'MISSING_COMPUTER_NAME', 'name is required');
|
|
1159
|
+
try {
|
|
1160
|
+
const computer = store.updateComputerName(decodeURIComponent(computerMatch[1]!), name);
|
|
1161
|
+
return json({ computer });
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1164
|
+
if (message === 'computer not found') throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const computerReconnectCommandMatch = pathname.match(/^\/api\/computers\/([^/]+)\/reconnect-command$/);
|
|
1171
|
+
if (request.method === 'POST' && computerReconnectCommandMatch) {
|
|
1172
|
+
return readJson<RegenerateComputerCommandBody>(request).then((body) => {
|
|
1173
|
+
try {
|
|
1174
|
+
const result = store.regenerateComputerCommand(decodeURIComponent(computerReconnectCommandMatch[1]!), {
|
|
1175
|
+
serverUrl: body.server_url,
|
|
1176
|
+
packageName: body.package_name,
|
|
1177
|
+
});
|
|
1178
|
+
return json(result);
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1181
|
+
if (message === 'computer not found') throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const computerDeleteImpactMatch = pathname.match(/^\/api\/computers\/([^/]+)\/delete-impact$/);
|
|
1188
|
+
if (request.method === 'GET' && computerDeleteImpactMatch) {
|
|
1189
|
+
const impact = store.getComputerDeletionImpact(decodeURIComponent(computerDeleteImpactMatch[1]!));
|
|
1190
|
+
if (!impact) throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
|
|
1191
|
+
return json({ impact });
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (request.method === 'DELETE' && computerMatch) {
|
|
1195
|
+
return readOptionalJson<DeleteComputerBody>(request).then((body) => {
|
|
1196
|
+
try {
|
|
1197
|
+
const deletion = store.deleteComputer(decodeURIComponent(computerMatch[1]!), {
|
|
1198
|
+
removeProjects: body.remove_projects === true,
|
|
1199
|
+
removeAssignments: body.remove_agents === true,
|
|
1200
|
+
});
|
|
1201
|
+
return json({ ...deletion, deleted: true });
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1204
|
+
if (message === 'computer not found') throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
|
|
1205
|
+
if (message === 'COMPUTER_HAS_PROJECTS') throw new HttpError(409, 'COMPUTER_HAS_PROJECTS', 'computer has projects');
|
|
1206
|
+
if (message === 'COMPUTER_HAS_ASSIGNMENTS') throw new HttpError(409, 'COMPUTER_HAS_ASSIGNMENTS', 'computer has agent assignments');
|
|
1207
|
+
throw error;
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const computerWorkloadMatch = pathname.match(/^\/api\/computers\/([^/]+)\/workload$/);
|
|
1213
|
+
if (request.method === 'GET' && computerWorkloadMatch) {
|
|
1214
|
+
const workload = store.getComputerWorkload(decodeURIComponent(computerWorkloadMatch[1]!));
|
|
1215
|
+
if (!workload) throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
|
|
1216
|
+
return json({ workload });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
225
1219
|
if (request.method === 'POST' && pathname === '/api/computers/provision') {
|
|
226
1220
|
return readJson<ProvisionComputerBody>(request).then((body) => {
|
|
227
1221
|
const result = store.provisionComputer({
|
|
@@ -251,7 +1245,14 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
251
1245
|
capabilities: agent.capabilities,
|
|
252
1246
|
})),
|
|
253
1247
|
});
|
|
254
|
-
return json(
|
|
1248
|
+
return json({
|
|
1249
|
+
computer: result.computer,
|
|
1250
|
+
connection: result.connection,
|
|
1251
|
+
token: result.token,
|
|
1252
|
+
local_control_token: result.localControlToken,
|
|
1253
|
+
daemon: result.daemon,
|
|
1254
|
+
agents: result.agents,
|
|
1255
|
+
}, 201);
|
|
255
1256
|
});
|
|
256
1257
|
}
|
|
257
1258
|
|
|
@@ -306,35 +1307,418 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
306
1307
|
content,
|
|
307
1308
|
ttlSeconds: body.ttl_seconds,
|
|
308
1309
|
});
|
|
309
|
-
return json({ artifact: result.artifact, token: result.token, url: `/artifacts/${result.token}` }, 201);
|
|
1310
|
+
return json({ artifact: result.artifact, token: result.token, url: `/artifacts/${result.token}` }, 201);
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (request.method === 'POST' && pathname === '/api/artifacts/cleanup') {
|
|
1315
|
+
assertServerAuth(request);
|
|
1316
|
+
return json({ deleted: store.cleanupArtifacts() });
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const artifactRevokeMatch = pathname.match(/^\/api\/artifacts\/([^/]+)\/revoke$/);
|
|
1320
|
+
if (request.method === 'POST' && artifactRevokeMatch) {
|
|
1321
|
+
assertServerAuth(request);
|
|
1322
|
+
return json({ artifact: store.revokeArtifact(artifactRevokeMatch[1]!) });
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (request.method === 'GET' && pathname === '/api/chats') {
|
|
1326
|
+
return json({ chats: store.listChats() });
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (request.method === 'GET' && pathname === '/api/rooms') {
|
|
1330
|
+
const scopedAgent = stringParam(url, 'agent_scope');
|
|
1331
|
+
if (scopedAgent) {
|
|
1332
|
+
const connection = requireDaemonConnection(store, request);
|
|
1333
|
+
if (!store.daemonHasAgent(connection.connectionId, scopedAgent)) {
|
|
1334
|
+
throw new HttpError(403, 'CONNECTION_AGENT_NOT_REGISTERED', 'connection has not registered this agent');
|
|
1335
|
+
}
|
|
1336
|
+
return json({ rooms: store.listChatsForAgent(scopedAgent) });
|
|
1337
|
+
}
|
|
1338
|
+
return json({ rooms: store.listChats() });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (request.method === 'GET' && pathname === '/api/projects') {
|
|
1342
|
+
return json({ projects: store.listProjects(numberParam(url, 'limit', 50)) });
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (request.method === 'POST' && pathname === '/api/projects') {
|
|
1346
|
+
return readJson<CreateProjectBody>(request).then(async (body) => {
|
|
1347
|
+
const name = body.name?.trim();
|
|
1348
|
+
const computerId = body.computer_id?.trim();
|
|
1349
|
+
const rootPath = body.root_path?.trim();
|
|
1350
|
+
if (!name) throw new HttpError(400, 'MISSING_PROJECT_NAME', 'name is required');
|
|
1351
|
+
if (!computerId) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
|
|
1352
|
+
if (!rootPath) throw new HttpError(400, 'MISSING_ROOT_PATH', 'root_path is required');
|
|
1353
|
+
await validateRemoteProjectPath(store, computerId, rootPath);
|
|
1354
|
+
const project = store.createProject({ name, computerId, rootPath });
|
|
1355
|
+
return json({ project }, 201);
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);
|
|
1360
|
+
if (request.method === 'PATCH' && projectMatch) {
|
|
1361
|
+
return readJson<UpdateProjectBody>(request).then((body) => {
|
|
1362
|
+
const project = store.getProject(decodeURIComponent(projectMatch[1]!));
|
|
1363
|
+
if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
1364
|
+
if (body.status !== 'active' && body.status !== 'archived') throw new HttpError(400, 'BAD_PROJECT_STATUS', 'status must be active or archived');
|
|
1365
|
+
return json({ project: store.updateProjectStatus(project.id, body.status) });
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (request.method === 'DELETE' && projectMatch) {
|
|
1370
|
+
const project = store.getProject(decodeURIComponent(projectMatch[1]!));
|
|
1371
|
+
if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
1372
|
+
try {
|
|
1373
|
+
const deleted = store.deleteProject(project.id);
|
|
1374
|
+
return json({ project: deleted, deleted: true });
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1377
|
+
if (message.startsWith('PROJECT_NOT_ARCHIVED')) throw new HttpError(409, 'PROJECT_NOT_ARCHIVED', 'project must be archived before deletion');
|
|
1378
|
+
throw error;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const projectAgentDefaultsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/agent-defaults$/);
|
|
1383
|
+
if (request.method === 'GET' && projectAgentDefaultsMatch) {
|
|
1384
|
+
const project = store.getProject(decodeURIComponent(projectAgentDefaultsMatch[1]!));
|
|
1385
|
+
if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
1386
|
+
return json({ project, defaults: store.listProjectAgentDefaults(project.id) });
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (request.method === 'PUT' && projectAgentDefaultsMatch) {
|
|
1390
|
+
return readJson<ProjectAgentDefaultsBody>(request).then((body) => {
|
|
1391
|
+
const project = store.getProject(decodeURIComponent(projectAgentDefaultsMatch[1]!));
|
|
1392
|
+
if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
1393
|
+
const defaults = body.defaults;
|
|
1394
|
+
if (!Array.isArray(defaults)) throw new HttpError(400, 'BAD_PROJECT_AGENT_DEFAULTS', 'defaults must be an array');
|
|
1395
|
+
return json({
|
|
1396
|
+
project,
|
|
1397
|
+
defaults: store.setProjectAgentDefaults(project.id, defaults.map((item) => {
|
|
1398
|
+
if (!item.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
1399
|
+
return { agent: item.agent, mode: parseAgentRoomSubscriptionMode(item.mode ?? 'mentions') };
|
|
1400
|
+
})),
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const computerFilesMatch = pathname.match(/^\/api\/computers\/([^/]+)\/files$/);
|
|
1406
|
+
if (request.method === 'GET' && computerFilesMatch) {
|
|
1407
|
+
const computerId = decodeURIComponent(computerFilesMatch[1]!);
|
|
1408
|
+
const control = store.getComputerLocalControl(computerId);
|
|
1409
|
+
if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
|
|
1410
|
+
const params = new URLSearchParams();
|
|
1411
|
+
const browsePath = stringParam(url, 'path');
|
|
1412
|
+
if (browsePath) params.set('path', browsePath);
|
|
1413
|
+
if (url.searchParams.get('show_hidden') === 'true') params.set('show_hidden', 'true');
|
|
1414
|
+
return fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files${params.size ? `?${params}` : ''}` })
|
|
1415
|
+
.then((data) => json(data));
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (request.method === 'POST' && pathname === '/api/rooms') {
|
|
1419
|
+
return readJson<CreateRoomBody>(request).then((body) => {
|
|
1420
|
+
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
|
|
1421
|
+
if (body.kind && body.kind !== 'group') throw new HttpError(400, 'BAD_ROOM_KIND', 'web rooms are always group rooms');
|
|
1422
|
+
const existing = store.getChatByName(body.name);
|
|
1423
|
+
if (existing) throw new HttpError(409, 'ROOM_NAME_EXISTS', `room name already exists: ${existing.name}`);
|
|
1424
|
+
const room = store.getOrCreateChat(body.name, 'group');
|
|
1425
|
+
return json({ room }, 201);
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (request.method === 'POST' && pathname === '/api/rooms/handoff') {
|
|
1430
|
+
return readJson<HandoffRoomBody>(request).then(async (body) => {
|
|
1431
|
+
const sourceRoomId = body.source_room_id?.trim();
|
|
1432
|
+
const actor = body.actor?.trim();
|
|
1433
|
+
const purpose = body.purpose?.trim();
|
|
1434
|
+
const roomName = body.name?.trim();
|
|
1435
|
+
if (!sourceRoomId) throw new HttpError(400, 'MISSING_SOURCE_ROOM', 'source_room_id is required');
|
|
1436
|
+
if (!actor) throw new HttpError(400, 'MISSING_ACTOR', 'actor is required');
|
|
1437
|
+
if (!purpose) throw new HttpError(400, 'MISSING_PURPOSE', 'purpose is required');
|
|
1438
|
+
if (!roomName) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
|
|
1439
|
+
if (!store.getAgent(actor)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'actor agent not found');
|
|
1440
|
+
|
|
1441
|
+
const sourceRoom = store.getChatById(sourceRoomId);
|
|
1442
|
+
if (!sourceRoom) throw new HttpError(404, 'SOURCE_ROOM_NOT_FOUND', 'source room not found');
|
|
1443
|
+
if (!store.canAgentParticipateInRoom(actor, sourceRoom)) {
|
|
1444
|
+
throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'actor cannot read the source room');
|
|
1445
|
+
}
|
|
1446
|
+
if (body.source_message_id !== undefined) {
|
|
1447
|
+
const sourceMessage = store.getMessage(Number(body.source_message_id));
|
|
1448
|
+
if (!sourceMessage || sourceMessage.chat_id !== sourceRoom.id) {
|
|
1449
|
+
throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message was not found in source room');
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const teamWasExplicit = Array.isArray(body.team);
|
|
1454
|
+
const team = teamWasExplicit
|
|
1455
|
+
? body.team!.map((item) => {
|
|
1456
|
+
if (!item.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'team agent is required');
|
|
1457
|
+
return { agent: item.agent.trim(), mode: parseAgentRoomSubscriptionMode(item.mode ?? 'mentions') };
|
|
1458
|
+
})
|
|
1459
|
+
: sourceRoom.project_id
|
|
1460
|
+
? null
|
|
1461
|
+
: store.listAgentRoomSubscriptions(sourceRoom.id).map((item) => ({ agent: item.agent, mode: item.mode }));
|
|
1462
|
+
|
|
1463
|
+
let room: Chat | null;
|
|
1464
|
+
if (sourceRoom.project_id) {
|
|
1465
|
+
room = store.createProjectRoom({ projectId: sourceRoom.project_id, name: roomName, kind: 'group' });
|
|
1466
|
+
} else {
|
|
1467
|
+
if (store.getChatByName(roomName)) throw new HttpError(409, 'ROOM_NAME_EXISTS', `room name already exists: ${roomName}`);
|
|
1468
|
+
room = store.getOrCreateChat(roomName, 'group');
|
|
1469
|
+
}
|
|
1470
|
+
if (!room) throw new HttpError(500, 'ROOM_CREATE_FAILED', 'room was not created');
|
|
1471
|
+
|
|
1472
|
+
if (teamWasExplicit || team) {
|
|
1473
|
+
const desired = new Map((team ?? []).map((item) => [item.agent, item.mode]));
|
|
1474
|
+
for (const existing of store.listAgentRoomSubscriptions(room.id)) {
|
|
1475
|
+
if (!desired.has(existing.agent)) store.leaveAgentRoom({ roomId: room.id, agent: existing.agent });
|
|
1476
|
+
}
|
|
1477
|
+
for (const [agent, mode] of desired) {
|
|
1478
|
+
store.inviteAgentToRoom({ roomId: room.id, agent, mode });
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const content = body.handoff_content?.trim();
|
|
1483
|
+
if (content) {
|
|
1484
|
+
const message = store.createMessage({
|
|
1485
|
+
chatId: room.id,
|
|
1486
|
+
sender: 'system',
|
|
1487
|
+
content,
|
|
1488
|
+
type: 'system',
|
|
1489
|
+
mentions: [actor],
|
|
1490
|
+
});
|
|
1491
|
+
const sideEffects = await publishMessageSideEffects(store, options, message, content);
|
|
1492
|
+
return json({ room: store.getChatById(room.id), message, deliveries: sideEffects.deliveries, notify: sideEffects.notify }, 201);
|
|
1493
|
+
}
|
|
1494
|
+
return json({ room: store.getChatById(room.id), message: null, deliveries: [], notify: null }, 201);
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const roomMatch = pathname.match(/^\/api\/rooms\/([^/]+)$/);
|
|
1499
|
+
if (request.method === 'PATCH' && roomMatch) {
|
|
1500
|
+
return readJson<UpdateRoomBody>(request).then((body) => {
|
|
1501
|
+
const room = store.resolveRoom(decodeURIComponent(roomMatch[1]!));
|
|
1502
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1503
|
+
if (room.provider !== 'web') throw new HttpError(403, 'EXTERNAL_ROOM_STATUS_READ_ONLY', 'external provider rooms cannot be archived from Web');
|
|
1504
|
+
if (body.status !== 'active' && body.status !== 'archived') throw new HttpError(400, 'BAD_ROOM_STATUS', 'status must be active or archived');
|
|
1505
|
+
return json({ room: store.updateRoomStatus(room.id, body.status) });
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (request.method === 'DELETE' && roomMatch) {
|
|
1510
|
+
const room = store.resolveRoom(decodeURIComponent(roomMatch[1]!));
|
|
1511
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1512
|
+
try {
|
|
1513
|
+
const deleted = store.deleteRoom(room.id);
|
|
1514
|
+
return json({ room: deleted, deleted: true });
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1517
|
+
if (message.startsWith('EXTERNAL_ROOM_DELETE_READ_ONLY')) throw new HttpError(403, 'EXTERNAL_ROOM_DELETE_READ_ONLY', 'external provider rooms cannot be deleted from Web');
|
|
1518
|
+
if (message.startsWith('ROOM_NOT_ARCHIVED')) throw new HttpError(409, 'ROOM_NOT_ARCHIVED', 'room must be archived before deletion');
|
|
1519
|
+
throw error;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const roomProjectPalMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal$/);
|
|
1524
|
+
if (request.method === 'GET' && roomProjectPalMatch) {
|
|
1525
|
+
const room = store.resolveRoom(decodeURIComponent(roomProjectPalMatch[1]!));
|
|
1526
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1527
|
+
if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
|
|
1528
|
+
const files = listProjectPalFiles(room.project_root_path);
|
|
1529
|
+
return json({
|
|
1530
|
+
room,
|
|
1531
|
+
project: {
|
|
1532
|
+
id: room.project_id,
|
|
1533
|
+
name: room.project_name,
|
|
1534
|
+
root_path: room.project_root_path,
|
|
1535
|
+
},
|
|
1536
|
+
pal_root_path: files.rootPath,
|
|
1537
|
+
entries: files.entries,
|
|
1538
|
+
supported_file_count: files.supportedFileCount,
|
|
1539
|
+
truncated: files.truncated,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const roomProjectPalContentMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/content$/);
|
|
1544
|
+
if (request.method === 'GET' && roomProjectPalContentMatch) {
|
|
1545
|
+
const room = store.resolveRoom(decodeURIComponent(roomProjectPalContentMatch[1]!));
|
|
1546
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1547
|
+
if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
|
|
1548
|
+
return json({
|
|
1549
|
+
room,
|
|
1550
|
+
file: readProjectPalFile(room.project_root_path, stringParam(url, 'path')),
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const roomProjectPalGitStatusMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/git-status$/);
|
|
1555
|
+
if (request.method === 'GET' && roomProjectPalGitStatusMatch) {
|
|
1556
|
+
const room = store.resolveRoom(decodeURIComponent(roomProjectPalGitStatusMatch[1]!));
|
|
1557
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1558
|
+
if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
|
|
1559
|
+
return json({
|
|
1560
|
+
room,
|
|
1561
|
+
changes: projectPalGitChanges(room.project_root_path),
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const roomProjectPalGitDiffMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/git-diff$/);
|
|
1566
|
+
if (request.method === 'GET' && roomProjectPalGitDiffMatch) {
|
|
1567
|
+
const room = store.resolveRoom(decodeURIComponent(roomProjectPalGitDiffMatch[1]!));
|
|
1568
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1569
|
+
if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
|
|
1570
|
+
return json({
|
|
1571
|
+
room,
|
|
1572
|
+
changes: projectPalGitChanges(room.project_root_path, { includeDiff: true }),
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const roomProjectPalGitCommitMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/git-commit$/);
|
|
1577
|
+
if (request.method === 'POST' && roomProjectPalGitCommitMatch) {
|
|
1578
|
+
return readJson<ProjectPalGitCommitBody>(request).then((body) => {
|
|
1579
|
+
const room = store.resolveRoom(decodeURIComponent(roomProjectPalGitCommitMatch[1]!));
|
|
1580
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1581
|
+
if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
|
|
1582
|
+
const projectRootPath = room.project_root_path;
|
|
1583
|
+
const message = body.message?.trim();
|
|
1584
|
+
if (!message) throw new HttpError(400, 'MISSING_COMMIT_MESSAGE', 'commit message is required');
|
|
1585
|
+
|
|
1586
|
+
const info = projectPalGitInfo(projectRootPath);
|
|
1587
|
+
if (!info.git.available || !info.git.head) throw new HttpError(409, 'GIT_NOT_AVAILABLE', info.git.error || 'project is not inside a Git repository with a HEAD commit');
|
|
1588
|
+
const expectedHead = body.expected_head?.trim();
|
|
1589
|
+
if (expectedHead && expectedHead !== info.git.head) throw new HttpError(409, 'GIT_HEAD_CHANGED', 'Git HEAD changed; refresh changes before committing');
|
|
1590
|
+
|
|
1591
|
+
const selectedPaths = body.files?.length
|
|
1592
|
+
? body.files
|
|
1593
|
+
: projectPalGitChanges(projectRootPath).files.map((file) => file.path);
|
|
1594
|
+
const repoPaths = Array.from(new Set(selectedPaths.map((filePath) => projectPalCommitPath(projectRootPath, filePath))));
|
|
1595
|
+
if (!repoPaths.length) throw new HttpError(400, 'NO_COMMIT_FILES', 'no .pal Markdown files were selected');
|
|
1596
|
+
|
|
1597
|
+
const status = projectPalGitChanges(projectRootPath);
|
|
1598
|
+
const dirtyRepoPaths = new Set(status.files.map((file) => file.repo_path));
|
|
1599
|
+
const filteredRepoPaths = repoPaths.filter((repoPath) => dirtyRepoPaths.has(repoPath));
|
|
1600
|
+
if (!filteredRepoPaths.length) throw new HttpError(409, 'NO_COMMIT_CHANGES', 'selected .pal Markdown files have no changes to commit');
|
|
1601
|
+
|
|
1602
|
+
gitCommand(projectRootPath, ['add', '--', ...filteredRepoPaths]);
|
|
1603
|
+
gitCommand(projectRootPath, ['commit', '-m', message, '--', ...filteredRepoPaths]);
|
|
1604
|
+
const committedHead = gitCommand(projectRootPath, ['rev-parse', 'HEAD']).stdout.trim();
|
|
1605
|
+
return json({
|
|
1606
|
+
room,
|
|
1607
|
+
commit: {
|
|
1608
|
+
previous_head: info.git.head,
|
|
1609
|
+
head: committedHead,
|
|
1610
|
+
message,
|
|
1611
|
+
files: filteredRepoPaths,
|
|
1612
|
+
},
|
|
1613
|
+
changes: projectPalGitChanges(projectRootPath, { includeDiff: true }),
|
|
1614
|
+
});
|
|
310
1615
|
});
|
|
311
1616
|
}
|
|
312
1617
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return
|
|
1618
|
+
const projectRoomsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/rooms$/);
|
|
1619
|
+
if (request.method === 'POST' && projectRoomsMatch) {
|
|
1620
|
+
return readJson<CreateRoomBody>(request).then((body) => {
|
|
1621
|
+
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
|
|
1622
|
+
if (body.kind && body.kind !== 'group') throw new HttpError(400, 'BAD_ROOM_KIND', 'web rooms are always group rooms');
|
|
1623
|
+
const room = store.createProjectRoom({
|
|
1624
|
+
projectId: decodeURIComponent(projectRoomsMatch[1]!),
|
|
1625
|
+
name: body.name,
|
|
1626
|
+
kind: 'group',
|
|
1627
|
+
mode: parseRoomMode(body.mode),
|
|
1628
|
+
});
|
|
1629
|
+
return json({ room }, 201);
|
|
1630
|
+
});
|
|
316
1631
|
}
|
|
317
1632
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
1633
|
+
if (request.method === 'GET' && pathname === '/api/skills') {
|
|
1634
|
+
const source = stringParam(url, 'source') as SkillSource | undefined;
|
|
1635
|
+
const scope = stringParam(url, 'scope') as SkillBindingScope | undefined;
|
|
1636
|
+
const scopeId = stringParam(url, 'scope_id');
|
|
1637
|
+
return json({
|
|
1638
|
+
skills: store.listSkillDefinitions({
|
|
1639
|
+
source: source ? parseSkillSource(source) : undefined,
|
|
1640
|
+
ownerUserId: url.searchParams.has('owner_user_id') ? stringParam(url, 'owner_user_id') : undefined,
|
|
1641
|
+
projectId: url.searchParams.has('project_id') ? stringParam(url, 'project_id') : undefined,
|
|
1642
|
+
includeDisabled: stringParam(url, 'include_disabled') === 'true',
|
|
1643
|
+
}),
|
|
1644
|
+
bindings: store.listSkillBindings({
|
|
1645
|
+
scope: scope ? parseSkillBindingScope(scope) : undefined,
|
|
1646
|
+
scopeId: scopeId ?? undefined,
|
|
1647
|
+
}),
|
|
1648
|
+
enabled_skills: store.listEnabledSkills({
|
|
1649
|
+
projectId: stringParam(url, 'enabled_project_id'),
|
|
1650
|
+
roomId: stringParam(url, 'enabled_room_id'),
|
|
1651
|
+
agent: stringParam(url, 'enabled_agent'),
|
|
1652
|
+
}),
|
|
1653
|
+
native_runtime_warning: 'PAL-managed skills are injected by PAL for project, room, and agent context. Native runtime-discovered global skills from Codex, OpenCode, or other adapters may still be loaded by those runtimes and may not be fully blockable from PAL Web; keep native global skill directories minimal.',
|
|
1654
|
+
activation_scopes: skillBindingScopes,
|
|
1655
|
+
repository_sources: skillSources,
|
|
1656
|
+
});
|
|
322
1657
|
}
|
|
323
1658
|
|
|
324
|
-
if (request.method === '
|
|
325
|
-
return
|
|
1659
|
+
if (request.method === 'POST' && pathname === '/api/skills') {
|
|
1660
|
+
return readJson<UpsertSkillBody>(request).then((body) => {
|
|
1661
|
+
try {
|
|
1662
|
+
const source = parseSkillSource(body.source);
|
|
1663
|
+
const skill = store.createSkillDefinition({
|
|
1664
|
+
key: body.key ?? '',
|
|
1665
|
+
name: body.name ?? '',
|
|
1666
|
+
description: body.description ?? '',
|
|
1667
|
+
instructionContent: body.instruction_content ?? '',
|
|
1668
|
+
status: body.status,
|
|
1669
|
+
source,
|
|
1670
|
+
ownerUserId: source === 'user' ? body.owner_user_id ?? 'owner' : body.owner_user_id ?? null,
|
|
1671
|
+
projectId: body.project_id ?? null,
|
|
1672
|
+
repositoryPath: body.repository_path ?? null,
|
|
1673
|
+
});
|
|
1674
|
+
return json({ skill }, 201);
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
throw skillStoreHttpError(error) ?? error;
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
326
1679
|
}
|
|
327
1680
|
|
|
328
|
-
|
|
329
|
-
|
|
1681
|
+
const skillDefinitionMatch = pathname.match(/^\/api\/skills\/([^/]+)$/);
|
|
1682
|
+
if (request.method === 'PATCH' && skillDefinitionMatch) {
|
|
1683
|
+
return readJson<UpsertSkillBody>(request).then((body) => {
|
|
1684
|
+
try {
|
|
1685
|
+
const skill = store.updateSkillDefinition(decodeURIComponent(skillDefinitionMatch[1]!), {
|
|
1686
|
+
name: body.name,
|
|
1687
|
+
description: body.description,
|
|
1688
|
+
instructionContent: body.instruction_content,
|
|
1689
|
+
status: body.status,
|
|
1690
|
+
ownerUserId: body.owner_user_id,
|
|
1691
|
+
projectId: body.project_id,
|
|
1692
|
+
repositoryPath: body.repository_path,
|
|
1693
|
+
});
|
|
1694
|
+
return json({ skill });
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
throw skillStoreHttpError(error) ?? error;
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
330
1699
|
}
|
|
331
1700
|
|
|
332
|
-
if (request.method === '
|
|
333
|
-
return readJson<
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1701
|
+
if (request.method === 'PUT' && pathname === '/api/skill-bindings') {
|
|
1702
|
+
return readJson<SkillBindingBody>(request).then((body) => {
|
|
1703
|
+
const binding = store.upsertSkillBinding({
|
|
1704
|
+
scope: parseSkillBindingScope(body.scope),
|
|
1705
|
+
scopeId: body.scope_id ?? '',
|
|
1706
|
+
skillKey: body.skill_key ?? '',
|
|
1707
|
+
enabled: body.enabled,
|
|
1708
|
+
priority: Number.isInteger(body.priority) ? body.priority : undefined,
|
|
1709
|
+
});
|
|
1710
|
+
return json({ binding });
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (request.method === 'DELETE' && pathname === '/api/skill-bindings') {
|
|
1715
|
+
return readJson<SkillBindingBody>(request).then((body) => {
|
|
1716
|
+
const deleted = store.deleteSkillBinding({
|
|
1717
|
+
scope: parseSkillBindingScope(body.scope),
|
|
1718
|
+
scopeId: body.scope_id ?? '',
|
|
1719
|
+
skillKey: body.skill_key ?? '',
|
|
1720
|
+
});
|
|
1721
|
+
return json({ deleted });
|
|
338
1722
|
});
|
|
339
1723
|
}
|
|
340
1724
|
|
|
@@ -345,6 +1729,12 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
345
1729
|
return json({
|
|
346
1730
|
room,
|
|
347
1731
|
participants: store.listRoomParticipants(room.id),
|
|
1732
|
+
agent_subscriptions: store.listAgentRoomSubscriptions(room.id),
|
|
1733
|
+
enabled_skills: store.listEnabledSkills({
|
|
1734
|
+
projectId: room.project_id,
|
|
1735
|
+
roomId: room.id,
|
|
1736
|
+
agent: stringParam(url, 'agent'),
|
|
1737
|
+
}),
|
|
348
1738
|
completeness: 'Human members come from lark_member_api snapshots. Bot members are known/observed only; Feishu member-list API filters bots.',
|
|
349
1739
|
});
|
|
350
1740
|
}
|
|
@@ -362,6 +1752,18 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
362
1752
|
});
|
|
363
1753
|
}
|
|
364
1754
|
|
|
1755
|
+
const roomAgentActivityMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agent-activity$/);
|
|
1756
|
+
if (request.method === 'GET' && roomAgentActivityMatch) {
|
|
1757
|
+
const room = store.resolveRoom(decodeURIComponent(roomAgentActivityMatch[1]!));
|
|
1758
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
1759
|
+
const agent = stringParam(url, 'agent');
|
|
1760
|
+
return json({
|
|
1761
|
+
room,
|
|
1762
|
+
activity: store.listRoomAgentActivity(room.id),
|
|
1763
|
+
events: agent ? store.listActivityForRoomAgent(room.id, agent, numberParam(url, 'limit', 80)) : [],
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
|
|
365
1767
|
const transcriptReadMatch = pathname.match(/^\/api\/transcripts\/([^/]+)\/messages$/);
|
|
366
1768
|
if (request.method === 'GET' && transcriptReadMatch) {
|
|
367
1769
|
return json({ messages: store.listTranscriptMessagesReadOnly(transcriptReadMatch[1]!, numberParam(url, 'limit', 50)) });
|
|
@@ -371,17 +1773,85 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
371
1773
|
return json({ agents: store.listAgents(numberParam(url, 'limit', 50)) });
|
|
372
1774
|
}
|
|
373
1775
|
|
|
1776
|
+
const agentWorkbenchMatch = pathname.match(/^\/api\/agents\/([^/]+)\/workbench$/);
|
|
1777
|
+
if (request.method === 'GET' && agentWorkbenchMatch) {
|
|
1778
|
+
const workbench = store.getAgentWorkbench(decodeURIComponent(agentWorkbenchMatch[1]!));
|
|
1779
|
+
if (!workbench) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
|
|
1780
|
+
return json({ workbench });
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const agentWorkspaceFilesMatch = pathname.match(/^\/api\/agents\/([^/]+)\/workspace-files$/);
|
|
1784
|
+
if (request.method === 'GET' && agentWorkspaceFilesMatch) {
|
|
1785
|
+
const agent = store.getAgent(decodeURIComponent(agentWorkspaceFilesMatch[1]!));
|
|
1786
|
+
if (!agent) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
|
|
1787
|
+
return json({ files: listAgentWorkspaceFiles(agent) });
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (request.method === 'PUT' && agentWorkspaceFilesMatch) {
|
|
1791
|
+
return readJson<AgentWorkspaceFileUpdateBody>(request).then((body) => {
|
|
1792
|
+
const agent = store.getAgent(decodeURIComponent(agentWorkspaceFilesMatch[1]!));
|
|
1793
|
+
if (!agent) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
|
|
1794
|
+
if (typeof body.path !== 'string' || typeof body.content !== 'string') {
|
|
1795
|
+
throw new HttpError(400, 'WORKSPACE_FILE_UPDATE_INVALID', 'workspace file update requires path and content');
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
return json({ file: updateAgentWorkspaceFile(agent, body.path, body.content) });
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
if (err instanceof AgentWorkspaceFileError) {
|
|
1801
|
+
throw new HttpError(err.status, err.code, err.message);
|
|
1802
|
+
}
|
|
1803
|
+
throw err;
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
const agentPermissionsMatch = pathname.match(/^\/api\/agents\/([^/]+)\/permissions$/);
|
|
1809
|
+
if (request.method === 'GET' && agentPermissionsMatch) {
|
|
1810
|
+
const agentKey = decodeURIComponent(agentPermissionsMatch[1]!);
|
|
1811
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1812
|
+
return json({ profile: store.getAgentPermissionProfile(agentKey) });
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (request.method === 'PUT' && agentPermissionsMatch) {
|
|
1816
|
+
return readJson<AgentPermissionsBody>(request).then((body) => {
|
|
1817
|
+
const agentKey = decodeURIComponent(agentPermissionsMatch[1]!);
|
|
1818
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1819
|
+
const validation = validateAgentPermissionProfile(body);
|
|
1820
|
+
if (!validation.ok) {
|
|
1821
|
+
throw new HttpError(400, 'PERMISSION_PROFILE_INVALID', validation.diagnostics.filter((diagnostic) => diagnostic.level === 'error').map((diagnostic) => diagnostic.message).join('; '));
|
|
1822
|
+
}
|
|
1823
|
+
const profile = store.upsertAgentPermissionProfile(agentKey, validation.profile);
|
|
1824
|
+
return json({ profile, warnings: validation.warnings });
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const agentPermissionsValidateMatch = pathname.match(/^\/api\/agents\/([^/]+)\/permissions\/validate$/);
|
|
1829
|
+
if (request.method === 'POST' && agentPermissionsValidateMatch) {
|
|
1830
|
+
return readJson<AgentPermissionsBody>(request).then((body) => {
|
|
1831
|
+
const agentKey = decodeURIComponent(agentPermissionsValidateMatch[1]!);
|
|
1832
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1833
|
+
const validation = validateAgentPermissionProfile(body, { checkFilesystem: url.searchParams.get('check_filesystem') === '1' });
|
|
1834
|
+
return json(validation);
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
if (request.method === 'GET' && pathname === '/api/runtime-models') {
|
|
1839
|
+
return allRuntimeModelOptions().then((runtimes) => json({ runtimes }));
|
|
1840
|
+
}
|
|
1841
|
+
|
|
374
1842
|
if (request.method === 'POST' && pathname === '/api/agents/onboard') {
|
|
375
|
-
return readJson<OnboardAgentBody>(request).then((body) => {
|
|
376
|
-
const agentKey = body.agent_key?.trim();
|
|
1843
|
+
return readJson<OnboardAgentBody>(request).then(async (body) => {
|
|
377
1844
|
const displayName = body.display_name?.trim();
|
|
378
|
-
if (!agentKey) throw new HttpError(400, 'MISSING_AGENT_KEY', 'agent_key is required');
|
|
379
1845
|
if (!displayName) throw new HttpError(400, 'MISSING_DISPLAY_NAME', 'display_name is required');
|
|
1846
|
+
const agentKey = body.agent_key?.trim() || uniqueAgentKeyFromDisplayName(displayName, store);
|
|
1847
|
+
if (store.getAgent(agentKey)) throw new HttpError(409, 'AGENT_KEY_EXISTS', `agent ${agentKey} already exists`);
|
|
1848
|
+
const runtime = body.runtime ?? 'codex';
|
|
380
1849
|
const agent = store.upsertAgent({
|
|
381
1850
|
agent_key: agentKey,
|
|
382
1851
|
display_name: displayName,
|
|
383
1852
|
description: body.description ?? null,
|
|
384
|
-
runtime
|
|
1853
|
+
runtime,
|
|
1854
|
+
model: await checkedRuntimeModel(runtime, body.model),
|
|
385
1855
|
});
|
|
386
1856
|
const assignment = body.computer_id?.trim()
|
|
387
1857
|
? store.assignAgentToComputer({ agent: agentKey, computerId: body.computer_id })
|
|
@@ -391,33 +1861,97 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
391
1861
|
}
|
|
392
1862
|
|
|
393
1863
|
if (request.method === 'POST' && pathname === '/api/agents') {
|
|
394
|
-
return readJson<Record<string, unknown>>(request).then((body) => {
|
|
1864
|
+
return readJson<Record<string, unknown>>(request).then(async (body) => {
|
|
395
1865
|
const validation = store.validateAgentSchema(body);
|
|
396
1866
|
if (!validation.ok) {
|
|
397
1867
|
throw new HttpError(400, 'VALIDATION_ERROR', validation.diagnostics.map((d) => `${d.code}: ${d.message}`).join('; '));
|
|
398
1868
|
}
|
|
1869
|
+
if (store.getAgent(String(body.agent_key))) throw new HttpError(409, 'AGENT_KEY_EXISTS', `agent ${String(body.agent_key)} already exists`);
|
|
1870
|
+
const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : null;
|
|
1871
|
+
const model = body.model === null ? null : typeof body.model === 'string' ? body.model : null;
|
|
399
1872
|
const agent = store.upsertAgent({
|
|
400
1873
|
id: typeof body.id === 'string' ? body.id : undefined,
|
|
401
1874
|
agent_key: String(body.agent_key),
|
|
402
1875
|
display_name: String(body.display_name),
|
|
403
1876
|
description: body.description === null ? null : typeof body.description === 'string' ? body.description : null,
|
|
404
|
-
runtime
|
|
1877
|
+
runtime,
|
|
1878
|
+
model: await checkedRuntimeModel(runtime, model),
|
|
405
1879
|
});
|
|
406
1880
|
return json({ agent }, 201);
|
|
407
1881
|
});
|
|
408
1882
|
}
|
|
409
1883
|
|
|
1884
|
+
const agentDmMatch = pathname.match(/^\/api\/agents\/([^/]+)\/dm$/);
|
|
1885
|
+
if (request.method === 'POST' && agentDmMatch) {
|
|
1886
|
+
const agentKey = decodeURIComponent(agentDmMatch[1]!);
|
|
1887
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1888
|
+
try {
|
|
1889
|
+
return json({ room: store.getOrCreateAgentDm(agentKey) }, 201);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1892
|
+
if (message.includes('already exists and is not a web DM')) throw new HttpError(409, 'ROOM_NAME_EXISTS', message);
|
|
1893
|
+
throw error;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
410
1897
|
const agentPatchMatch = pathname.match(/^\/api\/agents\/([^/]+)$/);
|
|
1898
|
+
const agentDeleteImpactMatch = pathname.match(/^\/api\/agents\/([^/]+)\/delete-impact$/);
|
|
1899
|
+
if (request.method === 'GET' && agentDeleteImpactMatch) {
|
|
1900
|
+
const agentKey = decodeURIComponent(agentDeleteImpactMatch[1]!);
|
|
1901
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1902
|
+
const configPath = stringParam(url, 'lark_config') ?? stringParam(url, 'config') ?? defaultLarkConfigPath();
|
|
1903
|
+
return json({ impact: agentDeletionImpact(store, agentKey, configPath) });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (request.method === 'DELETE' && agentPatchMatch) {
|
|
1907
|
+
return readJson<DeleteAgentBody>(request).then((body) => {
|
|
1908
|
+
const agentKey = decodeURIComponent(agentPatchMatch[1]!);
|
|
1909
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1910
|
+
if (body.confirm_delete !== true) throw new HttpError(400, 'DELETE_CONFIRMATION_REQUIRED', 'agent deletion requires explicit confirmation');
|
|
1911
|
+
|
|
1912
|
+
const configPath = stringParam(url, 'lark_config') ?? stringParam(url, 'config') ?? defaultLarkConfigPath();
|
|
1913
|
+
const impact = agentDeletionImpact(store, agentKey, configPath);
|
|
1914
|
+
if (!impact) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1915
|
+
if (impact.rooms.length > 0 && body.leave_rooms !== true) {
|
|
1916
|
+
throw new HttpError(400, 'DELETE_CLEANUP_REQUIRED', 'agent deletion requires leave_rooms cleanup confirmation');
|
|
1917
|
+
}
|
|
1918
|
+
if (impact.lark_bindings.length > 0 && body.unbind_lark !== true) {
|
|
1919
|
+
throw new HttpError(400, 'DELETE_CLEANUP_REQUIRED', 'agent deletion requires unbind_lark cleanup confirmation');
|
|
1920
|
+
}
|
|
1921
|
+
const deletion = store.deleteAgent(agentKey);
|
|
1922
|
+
if (!deletion) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1923
|
+
|
|
1924
|
+
let lark: { changed: boolean; unbound?: { appId: string }; error?: string } = { changed: false };
|
|
1925
|
+
try {
|
|
1926
|
+
const credentials = loadLarkCredentials(configPath);
|
|
1927
|
+
const unbound = unbindCredentialAgent(credentials, agentKey);
|
|
1928
|
+
if (unbound.changed) saveLarkCredentials(unbound.store, configPath);
|
|
1929
|
+
lark = { changed: unbound.changed, unbound: unbound.unbound };
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
lark = { changed: false, error: error instanceof Error ? error.message : String(error) };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return json({ impact, deletion, lark: { config_path: configPath, unbound: lark.changed, app_id: lark.unbound?.appId ?? null, error: lark.error ?? null } });
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
|
|
411
1938
|
if (request.method === 'PATCH' && agentPatchMatch) {
|
|
412
|
-
return readJson<Record<string, unknown>>(request).then((body) => {
|
|
413
|
-
const agentKey = agentPatchMatch[1]
|
|
1939
|
+
return readJson<Record<string, unknown>>(request).then(async (body) => {
|
|
1940
|
+
const agentKey = decodeURIComponent(agentPatchMatch[1]!);
|
|
414
1941
|
const existing = store.getAgent(agentKey);
|
|
415
1942
|
if (!existing) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
416
1943
|
|
|
417
|
-
if ('runtime' in body) {
|
|
1944
|
+
if ('runtime' in body || 'model' in body) {
|
|
418
1945
|
const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : undefined;
|
|
419
|
-
|
|
420
|
-
|
|
1946
|
+
const model = body.model === null ? null : typeof body.model === 'string' ? body.model : undefined;
|
|
1947
|
+
if (runtime !== undefined || model !== undefined) {
|
|
1948
|
+
const nextRuntime = runtime ?? existing.runtime ?? '';
|
|
1949
|
+
const runtimeChanged = runtime !== undefined && runtime !== existing.runtime;
|
|
1950
|
+
const updated = store.updateAgentRuntimeConfig(agentKey, {
|
|
1951
|
+
...(runtime !== undefined ? { runtime } : {}),
|
|
1952
|
+
...(model !== undefined ? { model: await checkedRuntimeModel(nextRuntime, model) } : {}),
|
|
1953
|
+
...(model === undefined && runtimeChanged ? { model: null } : {}),
|
|
1954
|
+
});
|
|
421
1955
|
return json({ agent: updated });
|
|
422
1956
|
}
|
|
423
1957
|
}
|
|
@@ -429,7 +1963,8 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
429
1963
|
const agentAssignmentMatch = pathname.match(/^\/api\/agents\/([^/]+)\/assignment$/);
|
|
430
1964
|
if (request.method === 'POST' && agentAssignmentMatch) {
|
|
431
1965
|
return readJson<AssignAgentBody>(request).then((body) => {
|
|
432
|
-
const agentKey = agentAssignmentMatch[1]
|
|
1966
|
+
const agentKey = decodeURIComponent(agentAssignmentMatch[1]!);
|
|
1967
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
433
1968
|
if (!body.computer_id?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
|
|
434
1969
|
const assignment = store.assignAgentToComputer({
|
|
435
1970
|
agent: agentKey,
|
|
@@ -439,6 +1974,13 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
439
1974
|
});
|
|
440
1975
|
}
|
|
441
1976
|
|
|
1977
|
+
if (request.method === 'DELETE' && agentAssignmentMatch) {
|
|
1978
|
+
const agentKey = decodeURIComponent(agentAssignmentMatch[1]!);
|
|
1979
|
+
if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
1980
|
+
const removed = store.unassignAgentFromComputer(agentKey);
|
|
1981
|
+
return json({ assignment: null, removed });
|
|
1982
|
+
}
|
|
1983
|
+
|
|
442
1984
|
if (request.method === 'GET' && pathname === '/api/sessions') {
|
|
443
1985
|
return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
|
|
444
1986
|
}
|
|
@@ -459,6 +2001,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
459
2001
|
});
|
|
460
2002
|
}
|
|
461
2003
|
|
|
2004
|
+
if (request.method === 'POST' && pathname === '/api/lark/registration') {
|
|
2005
|
+
return (async () => {
|
|
2006
|
+
pruneLarkRegistrations();
|
|
2007
|
+
const begin = await beginLarkAppRegistration({ source: 'pal-web' });
|
|
2008
|
+
const id = crypto.randomUUID();
|
|
2009
|
+
const qrDataUrl = await QRCode.toDataURL(begin.url, {
|
|
2010
|
+
margin: 1,
|
|
2011
|
+
width: 220,
|
|
2012
|
+
color: { dark: '#181714', light: '#ffffff' },
|
|
2013
|
+
});
|
|
2014
|
+
const entry: PendingLarkRegistration = {
|
|
2015
|
+
id,
|
|
2016
|
+
deviceCode: begin.deviceCode,
|
|
2017
|
+
url: begin.url,
|
|
2018
|
+
qrDataUrl,
|
|
2019
|
+
expiresAt: Date.now() + begin.expiresIn * 1000,
|
|
2020
|
+
interval: begin.interval,
|
|
2021
|
+
};
|
|
2022
|
+
larkRegistrations.set(id, entry);
|
|
2023
|
+
return json(larkRegistrationPublic(entry), 201);
|
|
2024
|
+
})();
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const larkRegistrationMatch = pathname.match(/^\/api\/lark\/registration\/([^/]+)$/);
|
|
2028
|
+
if (request.method === 'GET' && larkRegistrationMatch) {
|
|
2029
|
+
return (async () => {
|
|
2030
|
+
const entry = await pollStoredLarkRegistration(decodeURIComponent(larkRegistrationMatch[1]!));
|
|
2031
|
+
return json(larkRegistrationPublic(entry));
|
|
2032
|
+
})();
|
|
2033
|
+
}
|
|
2034
|
+
|
|
462
2035
|
if (request.method === 'GET' && pathname === '/api/lark/authorized-users') {
|
|
463
2036
|
return json({ users: store.listLarkAuthorizedUsers() });
|
|
464
2037
|
}
|
|
@@ -482,11 +2055,21 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
482
2055
|
|
|
483
2056
|
if (request.method === 'POST' && pathname === '/api/lark/setup') {
|
|
484
2057
|
return readJson<LarkSetupBody>(request).then(async (body) => {
|
|
485
|
-
const
|
|
486
|
-
|
|
2058
|
+
const registrationId = body.registration_id?.trim();
|
|
2059
|
+
let appId = body.app_id?.trim();
|
|
2060
|
+
let appSecret = body.app_secret?.trim();
|
|
487
2061
|
const agent = body.agent?.trim();
|
|
488
2062
|
const label = body.label?.trim();
|
|
489
2063
|
const configPath = body.config?.trim() || defaultLarkConfigPath();
|
|
2064
|
+
if (registrationId) {
|
|
2065
|
+
const registration = await pollStoredLarkRegistration(registrationId);
|
|
2066
|
+
if (!registration.completed) throw new HttpError(409, 'LARK_REGISTRATION_PENDING', 'registration has not completed yet');
|
|
2067
|
+
if (registration.completed.tenantBrand === 'lark') {
|
|
2068
|
+
throw new HttpError(400, 'LARK_TENANT_UNSUPPORTED', 'Lark international tenants are not supported yet; use a Feishu tenant');
|
|
2069
|
+
}
|
|
2070
|
+
appId = registration.completed.appId;
|
|
2071
|
+
appSecret = registration.completed.appSecret;
|
|
2072
|
+
}
|
|
490
2073
|
if (!appId) throw new HttpError(400, 'MISSING_APP_ID', 'app_id is required');
|
|
491
2074
|
if (!appSecret) throw new HttpError(400, 'MISSING_APP_SECRET', 'app_secret is required');
|
|
492
2075
|
if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
@@ -511,6 +2094,7 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
511
2094
|
botOpenId: botInfo.openId,
|
|
512
2095
|
agent,
|
|
513
2096
|
});
|
|
2097
|
+
if (registrationId) larkRegistrations.delete(registrationId);
|
|
514
2098
|
return json({ ...result, appName: botInfo.appName ?? null, account }, 201);
|
|
515
2099
|
});
|
|
516
2100
|
}
|
|
@@ -532,6 +2116,241 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
532
2116
|
});
|
|
533
2117
|
}
|
|
534
2118
|
|
|
2119
|
+
const roomLeaveMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)$/);
|
|
2120
|
+
if (request.method === 'DELETE' && roomLeaveMatch) {
|
|
2121
|
+
const room = store.resolveRoom(decodeURIComponent(roomLeaveMatch[1]!));
|
|
2122
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2123
|
+
const agent = decodeURIComponent(roomLeaveMatch[2]!);
|
|
2124
|
+
const result = store.leaveAgentRoom({ roomId: room.id, agent });
|
|
2125
|
+
return json(result);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const roomAgentReadMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/read$/);
|
|
2129
|
+
if (request.method === 'POST' && roomAgentReadMatch) {
|
|
2130
|
+
return readJson<AgentRoomReadBody>(request).then((body) => {
|
|
2131
|
+
const room = store.resolveRoom(decodeURIComponent(roomAgentReadMatch[1]!));
|
|
2132
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2133
|
+
const agent = decodeURIComponent(roomAgentReadMatch[2]!);
|
|
2134
|
+
if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
|
|
2135
|
+
try {
|
|
2136
|
+
const workbenchRoom = store.markAgentRoomRead({ roomId: room.id, agent, messageId: body.message_id });
|
|
2137
|
+
return json({ room: workbenchRoom });
|
|
2138
|
+
} catch (error) {
|
|
2139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2140
|
+
if (message === 'ROOM_ACCESS_DENIED') throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'agent cannot read this room');
|
|
2141
|
+
if (message === 'INVALID_READ_MESSAGE_ID') throw new HttpError(400, 'INVALID_READ_MESSAGE_ID', 'message_id must be a non-negative integer');
|
|
2142
|
+
throw error;
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const roomAgentRestartMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/restart$/);
|
|
2148
|
+
if (request.method === 'POST' && roomAgentRestartMatch) {
|
|
2149
|
+
const room = store.resolveRoom(decodeURIComponent(roomAgentRestartMatch[1]!));
|
|
2150
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2151
|
+
const agent = decodeURIComponent(roomAgentRestartMatch[2]!);
|
|
2152
|
+
if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
|
|
2153
|
+
try {
|
|
2154
|
+
const run = store.requestRoomAgentRunAction({ roomId: room.id, agent, action: 'restart' });
|
|
2155
|
+
return json({ run });
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2158
|
+
if (message.includes('running run for agent') && message.includes('was not found')) {
|
|
2159
|
+
throw new HttpError(404, 'RUN_NOT_FOUND', 'no running runtime for this agent in this room');
|
|
2160
|
+
}
|
|
2161
|
+
throw error;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const roomAgentSubscriptionMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/subscription$/);
|
|
2166
|
+
if (request.method === 'PATCH' && roomAgentSubscriptionMatch) {
|
|
2167
|
+
return readJson<AgentRoomSubscriptionBody>(request).then((body) => {
|
|
2168
|
+
const room = store.resolveRoom(decodeURIComponent(roomAgentSubscriptionMatch[1]!));
|
|
2169
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2170
|
+
const agent = decodeURIComponent(roomAgentSubscriptionMatch[2]!);
|
|
2171
|
+
if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
|
|
2172
|
+
const mode = parseAgentRoomSubscriptionMode(body.mode);
|
|
2173
|
+
try {
|
|
2174
|
+
return json(store.updateAgentRoomSubscriptionMode({ roomId: room.id, agent, mode }));
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2177
|
+
if (message === 'ROOM_ACCESS_DENIED') throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'agent cannot update this room subscription');
|
|
2178
|
+
throw error;
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
const roomTasksMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks$/);
|
|
2184
|
+
if (request.method === 'GET' && roomTasksMatch) {
|
|
2185
|
+
const room = store.resolveRoom(decodeURIComponent(roomTasksMatch[1]!));
|
|
2186
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2187
|
+
const statusParam = stringParam(url, 'status') ?? 'all';
|
|
2188
|
+
const status = statusParam === 'all' ? 'all' : parseRoomTaskStatus(statusParam);
|
|
2189
|
+
return json({ tasks: store.listRoomTasks(room.id, status, numberParam(url, 'limit', 50)) });
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
if (request.method === 'POST' && roomTasksMatch) {
|
|
2193
|
+
return readJson<CreateRoomTasksBody>(request).then((body) => {
|
|
2194
|
+
const room = store.resolveRoom(decodeURIComponent(roomTasksMatch[1]!));
|
|
2195
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2196
|
+
const titles = body.tasks?.map((task) => task.title ?? '') ?? [body.title ?? ''];
|
|
2197
|
+
try {
|
|
2198
|
+
return json({
|
|
2199
|
+
tasks: store.createRoomTasks({
|
|
2200
|
+
roomId: room.id,
|
|
2201
|
+
titles,
|
|
2202
|
+
createdBy: body.created_by,
|
|
2203
|
+
sourceMessageId: body.source_message_id,
|
|
2204
|
+
}),
|
|
2205
|
+
}, 201);
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2208
|
+
if (message === 'task title is required') throw new HttpError(400, 'MISSING_TASK_TITLE', 'task title is required');
|
|
2209
|
+
if (message === 'INVALID_SOURCE_MESSAGE_ID') throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'source_message_id must be a positive integer');
|
|
2210
|
+
if (message === 'SOURCE_MESSAGE_NOT_FOUND') throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message not found in room');
|
|
2211
|
+
throw error;
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
const roomTaskClaimMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks\/([^/]+)\/claim$/);
|
|
2217
|
+
if (request.method === 'POST' && roomTaskClaimMatch) {
|
|
2218
|
+
return readJson<ClaimRoomTaskBody>(request).then((body) => {
|
|
2219
|
+
const room = store.resolveRoom(decodeURIComponent(roomTaskClaimMatch[1]!));
|
|
2220
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2221
|
+
if (!body.assignee?.trim()) throw new HttpError(400, 'MISSING_ASSIGNEE', 'assignee is required');
|
|
2222
|
+
const task = store.claimRoomTask({
|
|
2223
|
+
roomId: room.id,
|
|
2224
|
+
taskNumber: taskNumberFromPath(roomTaskClaimMatch[2]),
|
|
2225
|
+
assignee: body.assignee,
|
|
2226
|
+
});
|
|
2227
|
+
if (!task) throw new HttpError(404, 'TASK_NOT_FOUND', 'task not found');
|
|
2228
|
+
return json({ task });
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const roomTaskUnclaimMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks\/([^/]+)\/unclaim$/);
|
|
2233
|
+
if (request.method === 'POST' && roomTaskUnclaimMatch) {
|
|
2234
|
+
const room = store.resolveRoom(decodeURIComponent(roomTaskUnclaimMatch[1]!));
|
|
2235
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2236
|
+
const task = store.unclaimRoomTask(room.id, taskNumberFromPath(roomTaskUnclaimMatch[2]));
|
|
2237
|
+
if (!task) throw new HttpError(404, 'TASK_NOT_FOUND', 'task not found');
|
|
2238
|
+
return json({ task });
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
const roomTaskUpdateMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks\/([^/]+)$/);
|
|
2242
|
+
if (request.method === 'PATCH' && roomTaskUpdateMatch) {
|
|
2243
|
+
return readJson<UpdateRoomTaskBody>(request).then((body) => {
|
|
2244
|
+
const room = store.resolveRoom(decodeURIComponent(roomTaskUpdateMatch[1]!));
|
|
2245
|
+
if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
|
|
2246
|
+
const task = store.updateRoomTaskStatus({
|
|
2247
|
+
roomId: room.id,
|
|
2248
|
+
taskNumber: taskNumberFromPath(roomTaskUpdateMatch[2]),
|
|
2249
|
+
status: parseRoomTaskStatus(body.status),
|
|
2250
|
+
});
|
|
2251
|
+
if (!task) throw new HttpError(404, 'TASK_NOT_FOUND', 'task not found');
|
|
2252
|
+
return json({ task });
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
if (request.method === 'GET' && pathname === '/api/reminders') {
|
|
2257
|
+
const statusParam = stringParam(url, 'status') ?? 'scheduled';
|
|
2258
|
+
const status = statusParam === 'all' ? 'all' : parseRoomReminderStatus(statusParam);
|
|
2259
|
+
return json({
|
|
2260
|
+
reminders: store.listRoomReminders({
|
|
2261
|
+
status,
|
|
2262
|
+
createdBy: stringParam(url, 'created_by'),
|
|
2263
|
+
roomId: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
|
|
2264
|
+
limit: numberParam(url, 'limit', 50),
|
|
2265
|
+
}),
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
if (request.method === 'POST' && pathname === '/api/reminders/fire-due') {
|
|
2270
|
+
assertServerAuth(request);
|
|
2271
|
+
return readJson<FireDueRoomRemindersBody>(request).then((body) => {
|
|
2272
|
+
try {
|
|
2273
|
+
return json(fireDueRoomRemindersForDelivery(store, options, {
|
|
2274
|
+
now: body.now,
|
|
2275
|
+
limit: body.limit ?? undefined,
|
|
2276
|
+
}));
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2279
|
+
if (message === 'INVALID_REMINDER_TIME') throw new HttpError(400, 'INVALID_REMINDER_TIME', 'now must be a valid time');
|
|
2280
|
+
throw error;
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
if (request.method === 'POST' && pathname === '/api/reminders') {
|
|
2286
|
+
return readJson<CreateRoomReminderBody>(request).then((body) => {
|
|
2287
|
+
const sourceMessageId = Number(body.msg_id ?? body.source_message_id);
|
|
2288
|
+
try {
|
|
2289
|
+
const reminder = store.createRoomReminder({
|
|
2290
|
+
sourceMessageId,
|
|
2291
|
+
title: body.title ?? '',
|
|
2292
|
+
createdBy: body.created_by,
|
|
2293
|
+
fireAt: body.fire_at,
|
|
2294
|
+
delaySeconds: body.delay_seconds,
|
|
2295
|
+
repeat: body.repeat,
|
|
2296
|
+
});
|
|
2297
|
+
return json({ reminder }, 201);
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2300
|
+
if (message === 'reminder title is required') throw new HttpError(400, 'MISSING_REMINDER_TITLE', 'title is required');
|
|
2301
|
+
if (message === 'INVALID_REMINDER_TIME') throw new HttpError(400, 'INVALID_REMINDER_TIME', 'fire_at or delay_seconds must be a valid time');
|
|
2302
|
+
if (message === 'INVALID_REMINDER_REPEAT') throw new HttpError(400, 'INVALID_REMINDER_REPEAT', 'repeat must be hourly, daily, weekly, or every:<number><s|m|h|d|w>');
|
|
2303
|
+
if (message === 'INVALID_SOURCE_MESSAGE_ID') throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
|
|
2304
|
+
if (message === 'SOURCE_MESSAGE_NOT_FOUND') throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message not found');
|
|
2305
|
+
throw error;
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
const reminderCancelMatch = pathname.match(/^\/api\/reminders\/([^/]+)\/cancel$/);
|
|
2311
|
+
if (request.method === 'POST' && reminderCancelMatch) {
|
|
2312
|
+
const reminder = store.cancelRoomReminder(decodeURIComponent(reminderCancelMatch[1]!));
|
|
2313
|
+
if (!reminder) throw new HttpError(404, 'REMINDER_NOT_FOUND', 'reminder not found');
|
|
2314
|
+
return json({ reminder });
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
if (request.method === 'GET' && pathname === '/api/saved-messages') {
|
|
2318
|
+
return json({
|
|
2319
|
+
saved_messages: store.listRoomSavedMessages({
|
|
2320
|
+
roomId: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
|
|
2321
|
+
savedBy: stringParam(url, 'saved_by'),
|
|
2322
|
+
limit: numberParam(url, 'limit', 50),
|
|
2323
|
+
}),
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
if (request.method === 'POST' && pathname === '/api/saved-messages') {
|
|
2328
|
+
return readJson<CreateRoomSavedMessageBody>(request).then((body) => {
|
|
2329
|
+
const sourceMessageId = Number(body.msg_id ?? body.source_message_id);
|
|
2330
|
+
try {
|
|
2331
|
+
const result = store.saveRoomMessage({
|
|
2332
|
+
sourceMessageId,
|
|
2333
|
+
savedBy: body.saved_by ?? '',
|
|
2334
|
+
note: body.note,
|
|
2335
|
+
});
|
|
2336
|
+
return json({ saved_message: result.savedMessage }, result.created ? 201 : 200);
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2339
|
+
if (message === 'saved_by is required') throw new HttpError(400, 'MISSING_SAVED_BY', 'saved_by is required');
|
|
2340
|
+
if (message === 'INVALID_SOURCE_MESSAGE_ID') throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
|
|
2341
|
+
if (message === 'SOURCE_MESSAGE_NOT_FOUND') throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message not found');
|
|
2342
|
+
throw error;
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const savedMessageDeleteMatch = pathname.match(/^\/api\/saved-messages\/([^/]+)\/delete$/);
|
|
2348
|
+
if (request.method === 'POST' && savedMessageDeleteMatch) {
|
|
2349
|
+
const savedMessage = store.removeRoomSavedMessage(decodeURIComponent(savedMessageDeleteMatch[1]!));
|
|
2350
|
+
if (!savedMessage) throw new HttpError(404, 'SAVED_MESSAGE_NOT_FOUND', 'saved message not found');
|
|
2351
|
+
return json({ saved_message: savedMessage });
|
|
2352
|
+
}
|
|
2353
|
+
|
|
535
2354
|
const roomTopicMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/topics$/);
|
|
536
2355
|
if (request.method === 'POST' && roomTopicMatch) {
|
|
537
2356
|
return readJson<{ name?: string; created_by?: string | null }>(request).then((body) => {
|
|
@@ -544,17 +2363,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
544
2363
|
}
|
|
545
2364
|
|
|
546
2365
|
if (request.method === 'GET' && pathname === '/api/messages') {
|
|
2366
|
+
const chatName = stringParam(url, 'chat');
|
|
2367
|
+
const chatId = stringParam(url, 'chat_id');
|
|
2368
|
+
const scopedAgent = stringParam(url, 'agent_scope');
|
|
2369
|
+
if (scopedAgent) {
|
|
2370
|
+
const room = chatId ? store.getChatById(chatId) : chatName ? store.resolveRoom(chatName) : null;
|
|
2371
|
+
if (!room) throw new HttpError(400, 'MISSING_ROOM_SCOPE', 'agent-scoped message reads require chat_id or chat');
|
|
2372
|
+
requireAgentScopedRead(store, request, url, room.id);
|
|
2373
|
+
}
|
|
547
2374
|
const messages = store.listMessages({
|
|
548
|
-
chatName
|
|
549
|
-
chatId
|
|
2375
|
+
chatName,
|
|
2376
|
+
chatId,
|
|
550
2377
|
parentId: numberParam(url, 'parent_id'),
|
|
551
2378
|
after: numberParam(url, 'after'),
|
|
2379
|
+
before: numberParam(url, 'before'),
|
|
552
2380
|
limit: numberParam(url, 'limit', 50),
|
|
553
2381
|
q: stringParam(url, 'q'),
|
|
554
2382
|
});
|
|
555
2383
|
return json({ messages });
|
|
556
2384
|
}
|
|
557
2385
|
|
|
2386
|
+
if (request.method === 'GET' && pathname === '/api/messages/context') {
|
|
2387
|
+
const agent = stringParam(url, 'agent');
|
|
2388
|
+
const chatId = stringParam(url, 'chat_id');
|
|
2389
|
+
const messageId = numberParam(url, 'message_id');
|
|
2390
|
+
if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
2391
|
+
if (!chatId) throw new HttpError(400, 'MISSING_CHAT_ID', 'chat_id is required');
|
|
2392
|
+
if (messageId === undefined) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
|
|
2393
|
+
requireAgentScopedRead(store, request, new URL(`${url.origin}${url.pathname}?agent_scope=${encodeURIComponent(agent)}`), chatId);
|
|
2394
|
+
return json({ context: store.getDeliveryContext({ agent, chatId, messageId, limit: numberParam(url, 'limit', 50) }) });
|
|
2395
|
+
}
|
|
2396
|
+
|
|
558
2397
|
if (request.method === 'GET' && pathname === '/api/inbox') {
|
|
559
2398
|
const agent = stringParam(url, 'agent');
|
|
560
2399
|
if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
@@ -584,17 +2423,126 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
584
2423
|
if (request.method === 'GET' && pathname === '/api/deliveries') {
|
|
585
2424
|
const agent = stringParam(url, 'agent');
|
|
586
2425
|
if (agent) {
|
|
587
|
-
return json({
|
|
2426
|
+
return json({
|
|
2427
|
+
deliveries: store.listDeliveries(agent, stringParam(url, 'status') ?? 'pending', numberParam(url, 'limit', 50), {
|
|
2428
|
+
distinctChat: url.searchParams.get('distinct_chat') === 'true',
|
|
2429
|
+
excludeRunningComputerId: stringParam(url, 'exclude_running_computer_id'),
|
|
2430
|
+
connectionId: stringParam(url, 'connection_id'),
|
|
2431
|
+
}),
|
|
2432
|
+
});
|
|
588
2433
|
}
|
|
589
2434
|
return json({ deliveries: store.listAllDeliveries(numberParam(url, 'limit', 50)) });
|
|
590
2435
|
}
|
|
591
2436
|
|
|
2437
|
+
if (request.method === 'GET' && pathname === '/api/deliveries/pending-agents') {
|
|
2438
|
+
const connection = requireDaemonConnection(store, request);
|
|
2439
|
+
return json({ agents: store.listPendingDeliveryAgentsForConnection(connection.connectionId) });
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
if (request.method === 'GET' && pathname === '/api/deliveries/backlog') {
|
|
2443
|
+
const connection = requireDaemonConnection(store, request);
|
|
2444
|
+
return json({ backlog: store.listDeliveryBacklogForConnection(connection.connectionId) });
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
if (request.method === 'GET' && pathname === '/api/daemon/ws/status') {
|
|
2448
|
+
const connection = requireDaemonConnection(store, request);
|
|
2449
|
+
const stats = options.deliveryNotifier?.statsForConnection?.(connection.connectionId) ?? {
|
|
2450
|
+
connection_id: connection.connectionId,
|
|
2451
|
+
computer_id: connection.computerId,
|
|
2452
|
+
open_sockets: 0,
|
|
2453
|
+
last_open_at: null,
|
|
2454
|
+
last_close_at: null,
|
|
2455
|
+
last_ping_at: null,
|
|
2456
|
+
last_pong_at: null,
|
|
2457
|
+
last_close_code: null,
|
|
2458
|
+
last_close_reason: null,
|
|
2459
|
+
pending_agents: store.listPendingDeliveryAgentsForConnection(connection.connectionId),
|
|
2460
|
+
backlog: store.listDeliveryBacklogForConnection(connection.connectionId),
|
|
2461
|
+
};
|
|
2462
|
+
return json({ websocket: stats });
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
const deliveryGetMatch = pathname.match(/^\/api\/deliveries\/([^/]+)$/);
|
|
2466
|
+
if (request.method === 'GET' && deliveryGetMatch) {
|
|
2467
|
+
const delivery = store.getDelivery(deliveryGetMatch[1]!);
|
|
2468
|
+
if (!delivery) throw new HttpError(404, 'DELIVERY_NOT_FOUND', 'delivery not found');
|
|
2469
|
+
return json({ delivery });
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
if (request.method === 'GET' && pathname === '/api/messaging/status') {
|
|
2473
|
+
const larkBots = options.larkStatusProvider?.() ?? [];
|
|
2474
|
+
const deliveryWebSocket = options.deliveryNotifier?.statsAllConnections?.() ?? emptyDeliveryWebSocketSummary();
|
|
2475
|
+
return json(buildMessagingStatus({ larkBots, deliveryWebSocket }));
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (request.method === 'GET' && pathname === '/api/messaging/health') {
|
|
2479
|
+
const larkBots = options.larkStatusProvider?.() ?? [];
|
|
2480
|
+
const deliveryWebSocket = options.deliveryNotifier?.statsAllConnections?.() ?? emptyDeliveryWebSocketSummary();
|
|
2481
|
+
return json(buildMessagingHealth(buildMessagingStatus({ larkBots, deliveryWebSocket })));
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
if (request.method === 'POST' && pathname === '/api/messaging/probe-delivery') {
|
|
2485
|
+
assertServerAuth(request);
|
|
2486
|
+
return readJson<ProbeDeliveryBody>(request).then((body) => {
|
|
2487
|
+
const agent = body.agent?.trim() || 'lock';
|
|
2488
|
+
if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent not found: ${agent}`);
|
|
2489
|
+
const roomName = body.room?.trim() || `pal-delivery-probe-${agent}`;
|
|
2490
|
+
const sender = body.sender?.trim() || 'pal-probe';
|
|
2491
|
+
if (sender === agent) throw new HttpError(400, 'PROBE_SENDER_AGENT_CONFLICT', 'probe sender must differ from agent');
|
|
2492
|
+
const token = crypto.randomUUID();
|
|
2493
|
+
const content = body.content?.trim() || `PAL delivery probe ${token}. Reply with probe-ok ${token}.`;
|
|
2494
|
+
const idempotencyKey = body.idempotency_key?.trim() || `pal.probe.delivery:${agent}:${token}`;
|
|
2495
|
+
const room = store.getOrCreateChat(roomName, 'group');
|
|
2496
|
+
store.inviteAgentToRoom({ roomId: room.id, agent, mode: 'mentions' });
|
|
2497
|
+
const message = store.createMessage({
|
|
2498
|
+
chatId: room.id,
|
|
2499
|
+
sender,
|
|
2500
|
+
recipient: agent,
|
|
2501
|
+
content,
|
|
2502
|
+
idempotencyKey,
|
|
2503
|
+
mentions: [agent],
|
|
2504
|
+
});
|
|
2505
|
+
const deliveries = store.resolveDeliveriesForMessage(message.id);
|
|
2506
|
+
const notify = notifyDeliveries(options, deliveries);
|
|
2507
|
+
return json({ message, deliveries, notify, probe: { token, agent, room: room.name } }, 201);
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
if (request.method === 'GET' && pathname === '/api/lark/events/recent') {
|
|
2512
|
+
const limit = numberParam(url, 'limit', 20);
|
|
2513
|
+
const rows = listRecentInboundEvents(store.db, limit);
|
|
2514
|
+
return json({
|
|
2515
|
+
events: rows.map((event) => ({
|
|
2516
|
+
id: event.id,
|
|
2517
|
+
received_at: event.received_at,
|
|
2518
|
+
app_id: event.app_id,
|
|
2519
|
+
event_type: event.event_type,
|
|
2520
|
+
event_id: event.event_id,
|
|
2521
|
+
parse_ok: event.parse_ok,
|
|
2522
|
+
bytes: event.raw_body_bytes?.byteLength ?? 0,
|
|
2523
|
+
is_probe: event.event_type.startsWith('pal.probe.') || event.event_id.startsWith('pal.probe:'),
|
|
2524
|
+
})),
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (request.method === 'POST' && pathname === '/api/lark/events/repair-parse') {
|
|
2529
|
+
assertServerAuth(request);
|
|
2530
|
+
return readJson<{ app_id?: unknown; limit?: unknown; dry_run?: unknown }>(request).then(async (body) => {
|
|
2531
|
+
const appId = typeof body.app_id === 'string' && body.app_id.trim() ? body.app_id.trim() : undefined;
|
|
2532
|
+
const limit = typeof body.limit === 'number' && Number.isFinite(body.limit) ? body.limit : undefined;
|
|
2533
|
+
const dryRun = body.dry_run === undefined ? true : body.dry_run !== false;
|
|
2534
|
+
const result = await repairInboundEventParseFailures(store.db, { appId, limit, dryRun });
|
|
2535
|
+
return json(result);
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
|
|
592
2539
|
if (request.method === 'POST' && pathname === '/api/deliveries') {
|
|
593
2540
|
return readJson<CreateDeliveryBody>(request).then((body) => {
|
|
594
2541
|
if (!body.message_id) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
|
|
595
2542
|
if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
596
2543
|
const delivery = store.createDelivery({ messageId: body.message_id, agent: body.agent });
|
|
597
|
-
|
|
2544
|
+
const notify = notifyDeliveries(options, [delivery]);
|
|
2545
|
+
return json({ delivery, notify }, 201);
|
|
598
2546
|
});
|
|
599
2547
|
}
|
|
600
2548
|
|
|
@@ -609,6 +2557,7 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
609
2557
|
connectionId: connection?.connectionId ?? body.connection_id,
|
|
610
2558
|
computerId: connection?.computerId ?? body.computer_id,
|
|
611
2559
|
leaseMs: body.lease_ms,
|
|
2560
|
+
steerRunId: body.steer_run_id,
|
|
612
2561
|
});
|
|
613
2562
|
return json({ delivery });
|
|
614
2563
|
});
|
|
@@ -669,6 +2618,23 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
669
2618
|
});
|
|
670
2619
|
}
|
|
671
2620
|
|
|
2621
|
+
const deliveryProcessingMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/processing-completed$/);
|
|
2622
|
+
if (request.method === 'POST' && deliveryProcessingMatch) {
|
|
2623
|
+
return readJson<FinishDeliveryBody>(request).then((body) => {
|
|
2624
|
+
const connection = requireDaemonConnection(store, request);
|
|
2625
|
+
const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
|
|
2626
|
+
if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
|
|
2627
|
+
if (!body.claim_token?.trim()) throw new HttpError(400, 'MISSING_CLAIM_TOKEN', 'claim_token is required');
|
|
2628
|
+
const delivery = store.markDeliveryProcessingCompleted(deliveryProcessingMatch[1]!, {
|
|
2629
|
+
daemonId: ownerId,
|
|
2630
|
+
connectionId: connection?.connectionId ?? body.connection_id,
|
|
2631
|
+
claimToken: body.claim_token,
|
|
2632
|
+
runId: body.run_id,
|
|
2633
|
+
});
|
|
2634
|
+
return json({ delivery });
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
|
|
672
2638
|
const deliveryFailMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/fail$/);
|
|
673
2639
|
if (request.method === 'POST' && deliveryFailMatch) {
|
|
674
2640
|
return readJson<FinishDeliveryBody>(request).then((body) => {
|
|
@@ -691,19 +2657,49 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
691
2657
|
return readJson<SendBody>(request).then(async (body) => {
|
|
692
2658
|
const connection = assertDaemonConnection(store, request);
|
|
693
2659
|
const sender = body.sender?.trim();
|
|
694
|
-
const content = body.content?.trim();
|
|
695
2660
|
if (!sender) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
|
|
696
|
-
|
|
2661
|
+
const batch = body.messages?.length
|
|
2662
|
+
? body.messages.map((item) => ({
|
|
2663
|
+
parent_id: item.parent_id ?? body.parent_id,
|
|
2664
|
+
channel_id: item.channel_id ?? body.channel_id,
|
|
2665
|
+
recipient: item.recipient ?? body.recipient,
|
|
2666
|
+
content: item.content?.trim() ?? '',
|
|
2667
|
+
type: item.type ?? body.type,
|
|
2668
|
+
idempotency_key: item.idempotency_key,
|
|
2669
|
+
mentions: [...(body.mentions ?? []), ...(item.mentions ?? [])],
|
|
2670
|
+
attachments: parseMessageAttachments(item.attachments ?? body.attachments),
|
|
2671
|
+
}))
|
|
2672
|
+
: [{
|
|
2673
|
+
parent_id: body.parent_id,
|
|
2674
|
+
channel_id: body.channel_id,
|
|
2675
|
+
recipient: body.recipient,
|
|
2676
|
+
content: body.content?.trim() ?? '',
|
|
2677
|
+
type: body.type,
|
|
2678
|
+
idempotency_key: body.idempotency_key,
|
|
2679
|
+
mentions: body.mentions,
|
|
2680
|
+
attachments: parseMessageAttachments(body.attachments),
|
|
2681
|
+
}];
|
|
2682
|
+
if (batch.length === 0 || batch.some((item) => !item.content && item.attachments.length === 0)) {
|
|
2683
|
+
throw new HttpError(400, 'MISSING_CONTENT', 'content or attachments are required');
|
|
2684
|
+
}
|
|
697
2685
|
|
|
698
|
-
const
|
|
699
|
-
|
|
2686
|
+
const firstParentId = batch.find((item) => item.parent_id !== undefined)?.parent_id;
|
|
2687
|
+
const targetRoom = firstParentId !== undefined
|
|
2688
|
+
? store.getMessage(firstParentId)?.chat_id
|
|
700
2689
|
: body.room_id ?? body.chat_id;
|
|
701
2690
|
const target = targetRoom
|
|
702
2691
|
? store.getChatById(targetRoom)
|
|
703
2692
|
: (body.room || body.chat) ? store.resolveRoom(body.room ?? body.chat ?? '') : null;
|
|
2693
|
+
if (target?.status === 'archived') {
|
|
2694
|
+
throw new HttpError(403, 'ROOM_ARCHIVED', 'archived rooms are read-only until restored');
|
|
2695
|
+
}
|
|
704
2696
|
if (target && !store.canSendWebMessageToRoom({ room: target, sender })) {
|
|
705
2697
|
throw new HttpError(403, 'EXTERNAL_ROOM_READ_ONLY', 'external provider rooms are read-only for web human senders');
|
|
706
2698
|
}
|
|
2699
|
+
if (target) {
|
|
2700
|
+
const mentionValidation = validateMessageMentions(store, target.id, batch);
|
|
2701
|
+
if (mentionValidation) return json(mentionValidation);
|
|
2702
|
+
}
|
|
707
2703
|
if (store.getAgent(sender) || store.hasDaemonAgent(sender)) {
|
|
708
2704
|
if (!connection) throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection auth is required for agent-authored messages');
|
|
709
2705
|
if (!store.daemonHasAgent(connection.connectionId, sender)) {
|
|
@@ -711,47 +2707,108 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
711
2707
|
}
|
|
712
2708
|
}
|
|
713
2709
|
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const result = await sendTextMessage({
|
|
736
|
-
client,
|
|
737
|
-
receiveIdType: 'chat_id',
|
|
738
|
-
receiveId: conversation.external_chat_id,
|
|
739
|
-
text: content,
|
|
2710
|
+
const freshness = freshnessInput(body.freshness);
|
|
2711
|
+
if (freshness?.on_stale === 'hold') {
|
|
2712
|
+
if (!target) throw new HttpError(400, 'MISSING_ROOM_SCOPE', 'freshness hold requires an existing room or parent message');
|
|
2713
|
+
if (!store.getAgent(sender)) throw new HttpError(400, 'FRESHNESS_AGENT_REQUIRED', 'freshness hold requires an agent sender');
|
|
2714
|
+
const latestMessageId = store.getLatestMessageIdForFreshness(target.id, sender);
|
|
2715
|
+
if (latestMessageId > freshness.base_message_id) {
|
|
2716
|
+
const drafts = batch.map((item) => store.createHeldMessageDraft({
|
|
2717
|
+
chatId: target.id,
|
|
2718
|
+
agent: sender,
|
|
2719
|
+
sender,
|
|
2720
|
+
content: item.content,
|
|
2721
|
+
mentions: item.mentions,
|
|
2722
|
+
baseMessageId: freshness.base_message_id,
|
|
2723
|
+
latestMessageIdAtHold: latestMessageId,
|
|
2724
|
+
holdReason: 'stale_room',
|
|
2725
|
+
}));
|
|
2726
|
+
const interveningMessages = store.listInterveningMessagesForFreshness({
|
|
2727
|
+
chatId: target.id,
|
|
2728
|
+
baseMessageId: freshness.base_message_id,
|
|
2729
|
+
sender,
|
|
2730
|
+
limit: 50,
|
|
740
2731
|
});
|
|
741
|
-
|
|
742
|
-
} catch (err) {
|
|
743
|
-
console.warn(`[lark] failed to forward message to chat=${conversation.external_chat_id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2732
|
+
return json({ draft: drafts[0], drafts, intervening_messages: interveningMessages }, 202);
|
|
744
2733
|
}
|
|
745
2734
|
}
|
|
746
2735
|
|
|
747
|
-
|
|
2736
|
+
const messages = [];
|
|
2737
|
+
const deliveries = [];
|
|
2738
|
+
const notify = [];
|
|
2739
|
+
for (const item of batch) {
|
|
2740
|
+
const message = store.createMessage({
|
|
2741
|
+
chatId: body.room_id ?? body.chat_id,
|
|
2742
|
+
chatName: body.room ?? body.chat,
|
|
2743
|
+
parentId: item.parent_id,
|
|
2744
|
+
channelId: item.channel_id,
|
|
2745
|
+
sender,
|
|
2746
|
+
recipient: item.recipient,
|
|
2747
|
+
content: item.content,
|
|
2748
|
+
type: item.type,
|
|
2749
|
+
idempotencyKey: item.idempotency_key,
|
|
2750
|
+
mentions: item.mentions,
|
|
2751
|
+
attachments: item.attachments,
|
|
2752
|
+
});
|
|
2753
|
+
messages.push(message);
|
|
2754
|
+
const sideEffects = await publishMessageSideEffects(store, options, message, item.content);
|
|
2755
|
+
deliveries.push(...sideEffects.deliveries);
|
|
2756
|
+
notify.push(sideEffects.notify);
|
|
2757
|
+
}
|
|
2758
|
+
return json({ message: messages[0], messages, deliveries, notify: batch.length === 1 ? notify[0] : notify }, 201);
|
|
748
2759
|
});
|
|
749
2760
|
}
|
|
750
2761
|
|
|
2762
|
+
const heldDraftMatch = pathname.match(/^\/api\/held-drafts\/([^/]+)\/(abandon|send-anyway|revise)$/);
|
|
2763
|
+
if (request.method === 'POST' && heldDraftMatch) {
|
|
2764
|
+
return (async () => {
|
|
2765
|
+
const draftId = heldDraftMatch[1]!;
|
|
2766
|
+
const action = heldDraftMatch[2]!;
|
|
2767
|
+
try {
|
|
2768
|
+
if (action === 'abandon') {
|
|
2769
|
+
const draft = store.abandonHeldMessageDraft(draftId);
|
|
2770
|
+
if (!draft) throw new HttpError(404, 'HELD_DRAFT_NOT_FOUND', 'held draft not found');
|
|
2771
|
+
return json({ draft });
|
|
2772
|
+
}
|
|
2773
|
+
if (action === 'revise') {
|
|
2774
|
+
const body = await readJson<HeldDraftReviseBody>(request);
|
|
2775
|
+
const content = typeof body.content === 'string' ? body.content.trim() : '';
|
|
2776
|
+
if (!content) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
|
|
2777
|
+
const result = store.reviseHeldMessageDraft(draftId, content);
|
|
2778
|
+
if (!result) throw new HttpError(404, 'HELD_DRAFT_NOT_FOUND', 'held draft not found');
|
|
2779
|
+
const { deliveries, notify } = await publishMessageSideEffects(store, options, result.message, result.message.content);
|
|
2780
|
+
return json({ draft: result.draft, message: result.message, deliveries, notify });
|
|
2781
|
+
}
|
|
2782
|
+
const result = store.sendHeldMessageDraftAnyway(draftId);
|
|
2783
|
+
if (!result) throw new HttpError(404, 'HELD_DRAFT_NOT_FOUND', 'held draft not found');
|
|
2784
|
+
const { deliveries, notify } = await publishMessageSideEffects(store, options, result.message, result.message.content);
|
|
2785
|
+
return json({ draft: result.draft, message: result.message, deliveries, notify });
|
|
2786
|
+
} catch (err) {
|
|
2787
|
+
if (err instanceof Error && err.message === 'HELD_DRAFT_ALREADY_RESOLVED') {
|
|
2788
|
+
throw new HttpError(409, 'HELD_DRAFT_ALREADY_RESOLVED', 'held draft is already resolved');
|
|
2789
|
+
}
|
|
2790
|
+
throw err;
|
|
2791
|
+
}
|
|
2792
|
+
})();
|
|
2793
|
+
}
|
|
2794
|
+
|
|
751
2795
|
if (request.method === 'GET' && pathname === '/api/runs') {
|
|
752
2796
|
return json({ runs: store.listRuns(numberParam(url, 'limit', 50)) });
|
|
753
2797
|
}
|
|
754
2798
|
|
|
2799
|
+
if (request.method === 'GET' && pathname === '/api/workflows') {
|
|
2800
|
+
const statusParam = stringParam(url, 'status') ?? 'all';
|
|
2801
|
+
const status = statusParam === 'all' ? 'all' : parseWorkflowRunStatus(statusParam);
|
|
2802
|
+
return json({ workflows: store.listWorkflowRuns({ status, limit: numberParam(url, 'limit', 50) }) });
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
const workflowRecordMatch = pathname.match(/^\/api\/workflows\/([^/]+)$/);
|
|
2806
|
+
if (request.method === 'GET' && workflowRecordMatch) {
|
|
2807
|
+
const workflow = store.getWorkflowRunRecord(decodeURIComponent(workflowRecordMatch[1]!));
|
|
2808
|
+
if (!workflow) throw new HttpError(404, 'WORKFLOW_RUN_NOT_FOUND', 'workflow run not found');
|
|
2809
|
+
return json({ workflow });
|
|
2810
|
+
}
|
|
2811
|
+
|
|
755
2812
|
if (request.method === 'POST' && pathname === '/api/runs') {
|
|
756
2813
|
return readJson<StartRunBody>(request).then((body) => {
|
|
757
2814
|
const connection = requireDaemonConnection(store, request);
|
|
@@ -786,9 +2843,34 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
786
2843
|
if (request.method === 'GET' && messageMatch) {
|
|
787
2844
|
const message = store.getMessage(Number(messageMatch[1]));
|
|
788
2845
|
if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
|
|
2846
|
+
requireAgentScopedRead(store, request, url, message.chat_id);
|
|
789
2847
|
return json({ message });
|
|
790
2848
|
}
|
|
791
2849
|
|
|
2850
|
+
const messageAttachmentContentMatch = pathname.match(/^\/api\/message-attachments\/([^/]+)\/content$/);
|
|
2851
|
+
if (request.method === 'GET' && messageAttachmentContentMatch) {
|
|
2852
|
+
const attachment = store.getMessageAttachment(decodeURIComponent(messageAttachmentContentMatch[1]!));
|
|
2853
|
+
if (!attachment) throw new HttpError(404, 'ATTACHMENT_NOT_FOUND', 'message attachment not found');
|
|
2854
|
+
const message = store.getMessage(attachment.message_id);
|
|
2855
|
+
if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
|
|
2856
|
+
requireAgentScopedRead(store, request, url, message.chat_id);
|
|
2857
|
+
let content: Buffer;
|
|
2858
|
+
try {
|
|
2859
|
+
content = readFileSync(attachment.path);
|
|
2860
|
+
} catch {
|
|
2861
|
+
throw new HttpError(404, 'ATTACHMENT_FILE_NOT_FOUND', 'message attachment file not found');
|
|
2862
|
+
}
|
|
2863
|
+
const body = content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength) as ArrayBuffer;
|
|
2864
|
+
return new Response(body, {
|
|
2865
|
+
headers: {
|
|
2866
|
+
'content-type': attachment.mime_type,
|
|
2867
|
+
'content-length': String(attachment.size_bytes),
|
|
2868
|
+
'content-disposition': `${url.searchParams.has('download') ? 'attachment' : 'inline'}; filename="${attachment.filename.replace(/"/g, '')}"`,
|
|
2869
|
+
'cache-control': 'private, no-store',
|
|
2870
|
+
},
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
|
|
792
2874
|
const runMatch = pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
793
2875
|
if (request.method === 'GET' && runMatch) {
|
|
794
2876
|
const run = store.getRun(runMatch[1]!);
|
|
@@ -825,6 +2907,28 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
825
2907
|
});
|
|
826
2908
|
}
|
|
827
2909
|
|
|
2910
|
+
const activityMatch = pathname.match(/^\/api\/runs\/([^/]+)\/activity$/);
|
|
2911
|
+
if (request.method === 'POST' && activityMatch) {
|
|
2912
|
+
return readJson<RecordRunActivityBody>(request).then((body) => {
|
|
2913
|
+
const connection = requireDaemonConnection(store, request);
|
|
2914
|
+
const runBefore = store.getRun(activityMatch[1]!);
|
|
2915
|
+
if (!runBefore) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
|
|
2916
|
+
if (connection && runBefore.connection_id !== connection.connectionId) throw new HttpError(403, 'RUN_CONNECTION_MISMATCH', 'run belongs to another connection');
|
|
2917
|
+
if (!body.kind || !agentActivityKinds.includes(body.kind)) {
|
|
2918
|
+
throw new HttpError(400, 'INVALID_ACTIVITY_KIND', `kind must be ${agentActivityKinds.join(', ')}`);
|
|
2919
|
+
}
|
|
2920
|
+
if (!body.title?.trim()) throw new HttpError(400, 'MISSING_TITLE', 'title is required');
|
|
2921
|
+
const event = store.recordAgentActivity({
|
|
2922
|
+
runId: runBefore.id,
|
|
2923
|
+
kind: body.kind,
|
|
2924
|
+
title: body.title,
|
|
2925
|
+
detail: body.detail,
|
|
2926
|
+
metadata: body.metadata,
|
|
2927
|
+
});
|
|
2928
|
+
return json({ event }, 201);
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
|
|
828
2932
|
const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart)$/);
|
|
829
2933
|
if (request.method === 'POST' && actionMatch) {
|
|
830
2934
|
const run = store.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
|
|
@@ -834,9 +2938,9 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
834
2938
|
return routeNotFound();
|
|
835
2939
|
}
|
|
836
2940
|
|
|
837
|
-
export async function handleRequest(store: MessageStore, request: Request): Promise<Response> {
|
|
2941
|
+
export async function handleRequest(store: MessageStore, request: Request, options: AppRouteOptions = {}): Promise<Response> {
|
|
838
2942
|
try {
|
|
839
|
-
return await route(store, request);
|
|
2943
|
+
return await route(store, request, options);
|
|
840
2944
|
} catch (error) {
|
|
841
2945
|
return failure(error);
|
|
842
2946
|
}
|