@controlflow-ai/daemon 0.1.2 → 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 +54 -6
- package/package.json +3 -1
- 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 +795 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +1970 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +472 -10
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +230 -20
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +936 -98
- package/src/db.ts +3128 -122
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/cli.ts +3 -3
- package/src/lark/event-router.ts +60 -4
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +545 -15
- package/src/local-auth.ts +33 -1
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -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 +69 -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 +362 -0
- package/src/workflow-runtime.ts +275 -0
- package/src/web.ts +0 -904
package/src/app.ts
CHANGED
|
@@ -1,18 +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';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import QRCode from 'qrcode';
|
|
6
|
-
import { MessageStore } from './db.js';
|
|
7
|
+
import { ALL_AGENTS_MENTION, MessageStore } from './db.js';
|
|
7
8
|
import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
|
|
8
9
|
import { assertServerAuth } from './server-auth.js';
|
|
9
|
-
import {
|
|
10
|
-
import type { RunAction, RunStatus } from './types.js';
|
|
10
|
+
import type { AgentActivityKind, AgentRoomSubscriptionMode, Chat, RoomMode, RoomReminderStatus, RoomTaskStatus, RunAction, RunStatus, SkillBindingScope, SkillSource, WorkflowRunStatus } from './types.js';
|
|
11
11
|
import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
|
|
12
|
-
import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
|
|
12
|
+
import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, saveLarkCredentials, type LarkCredentialStore, unbindCredentialAgent } from './lark/credentials.js';
|
|
13
13
|
import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
|
|
14
14
|
import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './lark/app-registration.js';
|
|
15
|
+
import { listRecentInboundEvents, repairInboundEventParseFailures } from './lark/inbound-events.js';
|
|
15
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
|
+
}
|
|
16
93
|
|
|
17
94
|
interface SendBody {
|
|
18
95
|
chat?: string;
|
|
@@ -27,11 +104,226 @@ interface SendBody {
|
|
|
27
104
|
type?: 'message' | 'system';
|
|
28
105
|
idempotency_key?: string | null;
|
|
29
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;
|
|
30
270
|
}
|
|
31
271
|
|
|
32
272
|
interface CreateRoomBody {
|
|
33
273
|
name?: string;
|
|
34
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;
|
|
35
327
|
}
|
|
36
328
|
|
|
37
329
|
interface CreateProjectBody {
|
|
@@ -40,6 +332,51 @@ interface CreateProjectBody {
|
|
|
40
332
|
root_path?: string;
|
|
41
333
|
}
|
|
42
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;
|
|
378
|
+
}
|
|
379
|
+
|
|
43
380
|
interface StartRunBody {
|
|
44
381
|
message_id?: number;
|
|
45
382
|
agent?: string;
|
|
@@ -71,6 +408,20 @@ interface ProvisionComputerBody {
|
|
|
71
408
|
package_name?: string;
|
|
72
409
|
}
|
|
73
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
|
+
|
|
74
425
|
interface ConnectComputerBody {
|
|
75
426
|
computer_id?: string;
|
|
76
427
|
secret?: string;
|
|
@@ -109,10 +460,22 @@ interface OnboardAgentBody {
|
|
|
109
460
|
agent_key?: string;
|
|
110
461
|
display_name?: string;
|
|
111
462
|
runtime?: string | null;
|
|
463
|
+
model?: string | null;
|
|
112
464
|
description?: string | null;
|
|
113
465
|
computer_id?: string;
|
|
114
466
|
}
|
|
115
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
|
+
|
|
116
479
|
interface LarkSetupBody {
|
|
117
480
|
app_id?: string;
|
|
118
481
|
app_secret?: string;
|
|
@@ -127,6 +490,17 @@ interface LarkAuthorizedUserBody {
|
|
|
127
490
|
display_name?: string | null;
|
|
128
491
|
}
|
|
129
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
|
+
|
|
130
504
|
interface CreateDeliveryBody {
|
|
131
505
|
message_id?: number;
|
|
132
506
|
agent?: string;
|
|
@@ -137,6 +511,7 @@ interface ClaimDeliveryBody {
|
|
|
137
511
|
connection_id?: string | null;
|
|
138
512
|
computer_id?: string | null;
|
|
139
513
|
lease_ms?: number;
|
|
514
|
+
steer_run_id?: string | null;
|
|
140
515
|
}
|
|
141
516
|
|
|
142
517
|
interface SessionBody {
|
|
@@ -182,6 +557,13 @@ interface FinishRunBody {
|
|
|
182
557
|
output?: string;
|
|
183
558
|
}
|
|
184
559
|
|
|
560
|
+
interface RecordRunActivityBody {
|
|
561
|
+
kind?: AgentActivityKind;
|
|
562
|
+
title?: string;
|
|
563
|
+
detail?: string | null;
|
|
564
|
+
metadata?: Record<string, unknown> | null;
|
|
565
|
+
}
|
|
566
|
+
|
|
185
567
|
export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkCredentialStore, sender: string, chatId: string) {
|
|
186
568
|
for (const bot of credentials.bots) {
|
|
187
569
|
if (!boundAgents(bot).includes(sender)) continue;
|
|
@@ -192,15 +574,326 @@ export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkC
|
|
|
192
574
|
return null;
|
|
193
575
|
}
|
|
194
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
|
+
|
|
195
608
|
function routeNotFound(): Response {
|
|
196
609
|
return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
|
|
197
610
|
}
|
|
198
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
|
+
|
|
199
890
|
function html(body: string): Response {
|
|
200
891
|
return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
201
892
|
}
|
|
202
893
|
|
|
203
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;
|
|
204
897
|
|
|
205
898
|
interface PendingLarkRegistration {
|
|
206
899
|
id: string;
|
|
@@ -214,6 +907,34 @@ interface PendingLarkRegistration {
|
|
|
214
907
|
|
|
215
908
|
const larkRegistrations = new Map<string, PendingLarkRegistration>();
|
|
216
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
|
+
|
|
217
938
|
function pruneLarkRegistrations(): void {
|
|
218
939
|
const now = Date.now();
|
|
219
940
|
for (const [id, registration] of larkRegistrations) {
|
|
@@ -269,13 +990,46 @@ function webAssetContentType(pathname: string): string {
|
|
|
269
990
|
}
|
|
270
991
|
|
|
271
992
|
function builtWebIndex(): Response | null {
|
|
993
|
+
ensureBuiltWeb();
|
|
272
994
|
const indexPath = join(webDistDir, 'index.html');
|
|
273
995
|
if (!existsSync(indexPath)) return null;
|
|
274
996
|
return new Response(Bun.file(indexPath), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
275
997
|
}
|
|
276
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
|
+
|
|
277
1030
|
function builtWebAsset(pathname: string): Response | null {
|
|
278
1031
|
if (!pathname.startsWith('/assets/')) return null;
|
|
1032
|
+
ensureBuiltWeb();
|
|
279
1033
|
const relative = decodeURIComponent(pathname.slice('/assets/'.length));
|
|
280
1034
|
if (!relative || relative.includes('..') || relative.includes('/') || relative.includes('\\')) return null;
|
|
281
1035
|
const assetPath = join(webDistDir, 'assets', relative);
|
|
@@ -309,12 +1063,68 @@ function requireDaemonConnection(store: MessageStore, request: Request): { compu
|
|
|
309
1063
|
return connection;
|
|
310
1064
|
}
|
|
311
1065
|
|
|
312
|
-
|
|
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 {
|
|
313
1123
|
const url = new URL(request.url);
|
|
314
1124
|
const { pathname } = url;
|
|
315
1125
|
|
|
316
1126
|
if (request.method === 'GET' && pathname === '/') {
|
|
317
|
-
return builtWebIndex() ??
|
|
1127
|
+
return builtWebIndex() ?? missingWebBuild();
|
|
318
1128
|
}
|
|
319
1129
|
|
|
320
1130
|
if (request.method === 'GET') {
|
|
@@ -341,6 +1151,71 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
341
1151
|
return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
|
|
342
1152
|
}
|
|
343
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
|
+
|
|
344
1219
|
if (request.method === 'POST' && pathname === '/api/computers/provision') {
|
|
345
1220
|
return readJson<ProvisionComputerBody>(request).then((body) => {
|
|
346
1221
|
const result = store.provisionComputer({
|
|
@@ -452,6 +1327,14 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
452
1327
|
}
|
|
453
1328
|
|
|
454
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
|
+
}
|
|
455
1338
|
return json({ rooms: store.listChats() });
|
|
456
1339
|
}
|
|
457
1340
|
|
|
@@ -473,25 +1356,262 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
473
1356
|
});
|
|
474
1357
|
}
|
|
475
1358
|
|
|
476
|
-
const
|
|
477
|
-
if (request.method === '
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
});
|
|
487
1563
|
}
|
|
488
1564
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
});
|
|
495
1615
|
});
|
|
496
1616
|
}
|
|
497
1617
|
|
|
@@ -499,16 +1619,109 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
499
1619
|
if (request.method === 'POST' && projectRoomsMatch) {
|
|
500
1620
|
return readJson<CreateRoomBody>(request).then((body) => {
|
|
501
1621
|
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
|
|
502
|
-
if (body.kind && body.kind !== 'group'
|
|
1622
|
+
if (body.kind && body.kind !== 'group') throw new HttpError(400, 'BAD_ROOM_KIND', 'web rooms are always group rooms');
|
|
503
1623
|
const room = store.createProjectRoom({
|
|
504
1624
|
projectId: decodeURIComponent(projectRoomsMatch[1]!),
|
|
505
1625
|
name: body.name,
|
|
506
|
-
kind:
|
|
1626
|
+
kind: 'group',
|
|
1627
|
+
mode: parseRoomMode(body.mode),
|
|
507
1628
|
});
|
|
508
1629
|
return json({ room }, 201);
|
|
509
1630
|
});
|
|
510
1631
|
}
|
|
511
1632
|
|
|
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
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
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
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
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
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
|
|
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 });
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
|
|
512
1725
|
const roomMembersMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/members$/);
|
|
513
1726
|
if (request.method === 'GET' && roomMembersMatch) {
|
|
514
1727
|
const room = store.resolveRoom(decodeURIComponent(roomMembersMatch[1]!));
|
|
@@ -516,6 +1729,12 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
516
1729
|
return json({
|
|
517
1730
|
room,
|
|
518
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
|
+
}),
|
|
519
1738
|
completeness: 'Human members come from lark_member_api snapshots. Bot members are known/observed only; Feishu member-list API filters bots.',
|
|
520
1739
|
});
|
|
521
1740
|
}
|
|
@@ -533,6 +1752,18 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
533
1752
|
});
|
|
534
1753
|
}
|
|
535
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
|
+
|
|
536
1767
|
const transcriptReadMatch = pathname.match(/^\/api\/transcripts\/([^/]+)\/messages$/);
|
|
537
1768
|
if (request.method === 'GET' && transcriptReadMatch) {
|
|
538
1769
|
return json({ messages: store.listTranscriptMessagesReadOnly(transcriptReadMatch[1]!, numberParam(url, 'limit', 50)) });
|
|
@@ -542,17 +1773,85 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
542
1773
|
return json({ agents: store.listAgents(numberParam(url, 'limit', 50)) });
|
|
543
1774
|
}
|
|
544
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
|
+
|
|
545
1842
|
if (request.method === 'POST' && pathname === '/api/agents/onboard') {
|
|
546
|
-
return readJson<OnboardAgentBody>(request).then((body) => {
|
|
547
|
-
const agentKey = body.agent_key?.trim();
|
|
1843
|
+
return readJson<OnboardAgentBody>(request).then(async (body) => {
|
|
548
1844
|
const displayName = body.display_name?.trim();
|
|
549
|
-
if (!agentKey) throw new HttpError(400, 'MISSING_AGENT_KEY', 'agent_key is required');
|
|
550
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';
|
|
551
1849
|
const agent = store.upsertAgent({
|
|
552
1850
|
agent_key: agentKey,
|
|
553
1851
|
display_name: displayName,
|
|
554
1852
|
description: body.description ?? null,
|
|
555
|
-
runtime
|
|
1853
|
+
runtime,
|
|
1854
|
+
model: await checkedRuntimeModel(runtime, body.model),
|
|
556
1855
|
});
|
|
557
1856
|
const assignment = body.computer_id?.trim()
|
|
558
1857
|
? store.assignAgentToComputer({ agent: agentKey, computerId: body.computer_id })
|
|
@@ -562,33 +1861,97 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
562
1861
|
}
|
|
563
1862
|
|
|
564
1863
|
if (request.method === 'POST' && pathname === '/api/agents') {
|
|
565
|
-
return readJson<Record<string, unknown>>(request).then((body) => {
|
|
1864
|
+
return readJson<Record<string, unknown>>(request).then(async (body) => {
|
|
566
1865
|
const validation = store.validateAgentSchema(body);
|
|
567
1866
|
if (!validation.ok) {
|
|
568
1867
|
throw new HttpError(400, 'VALIDATION_ERROR', validation.diagnostics.map((d) => `${d.code}: ${d.message}`).join('; '));
|
|
569
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;
|
|
570
1872
|
const agent = store.upsertAgent({
|
|
571
1873
|
id: typeof body.id === 'string' ? body.id : undefined,
|
|
572
1874
|
agent_key: String(body.agent_key),
|
|
573
1875
|
display_name: String(body.display_name),
|
|
574
1876
|
description: body.description === null ? null : typeof body.description === 'string' ? body.description : null,
|
|
575
|
-
runtime
|
|
1877
|
+
runtime,
|
|
1878
|
+
model: await checkedRuntimeModel(runtime, model),
|
|
576
1879
|
});
|
|
577
1880
|
return json({ agent }, 201);
|
|
578
1881
|
});
|
|
579
1882
|
}
|
|
580
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
|
+
|
|
581
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
|
+
|
|
582
1938
|
if (request.method === 'PATCH' && agentPatchMatch) {
|
|
583
|
-
return readJson<Record<string, unknown>>(request).then((body) => {
|
|
584
|
-
const agentKey = agentPatchMatch[1]
|
|
1939
|
+
return readJson<Record<string, unknown>>(request).then(async (body) => {
|
|
1940
|
+
const agentKey = decodeURIComponent(agentPatchMatch[1]!);
|
|
585
1941
|
const existing = store.getAgent(agentKey);
|
|
586
1942
|
if (!existing) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
|
|
587
1943
|
|
|
588
|
-
if ('runtime' in body) {
|
|
1944
|
+
if ('runtime' in body || 'model' in body) {
|
|
589
1945
|
const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : undefined;
|
|
590
|
-
|
|
591
|
-
|
|
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
|
+
});
|
|
592
1955
|
return json({ agent: updated });
|
|
593
1956
|
}
|
|
594
1957
|
}
|
|
@@ -600,7 +1963,8 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
600
1963
|
const agentAssignmentMatch = pathname.match(/^\/api\/agents\/([^/]+)\/assignment$/);
|
|
601
1964
|
if (request.method === 'POST' && agentAssignmentMatch) {
|
|
602
1965
|
return readJson<AssignAgentBody>(request).then((body) => {
|
|
603
|
-
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`);
|
|
604
1968
|
if (!body.computer_id?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
|
|
605
1969
|
const assignment = store.assignAgentToComputer({
|
|
606
1970
|
agent: agentKey,
|
|
@@ -610,6 +1974,13 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
610
1974
|
});
|
|
611
1975
|
}
|
|
612
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
|
+
|
|
613
1984
|
if (request.method === 'GET' && pathname === '/api/sessions') {
|
|
614
1985
|
return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
|
|
615
1986
|
}
|
|
@@ -745,6 +2116,241 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
745
2116
|
});
|
|
746
2117
|
}
|
|
747
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
|
+
|
|
748
2354
|
const roomTopicMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/topics$/);
|
|
749
2355
|
if (request.method === 'POST' && roomTopicMatch) {
|
|
750
2356
|
return readJson<{ name?: string; created_by?: string | null }>(request).then((body) => {
|
|
@@ -757,17 +2363,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
757
2363
|
}
|
|
758
2364
|
|
|
759
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
|
+
}
|
|
760
2374
|
const messages = store.listMessages({
|
|
761
|
-
chatName
|
|
762
|
-
chatId
|
|
2375
|
+
chatName,
|
|
2376
|
+
chatId,
|
|
763
2377
|
parentId: numberParam(url, 'parent_id'),
|
|
764
2378
|
after: numberParam(url, 'after'),
|
|
2379
|
+
before: numberParam(url, 'before'),
|
|
765
2380
|
limit: numberParam(url, 'limit', 50),
|
|
766
2381
|
q: stringParam(url, 'q'),
|
|
767
2382
|
});
|
|
768
2383
|
return json({ messages });
|
|
769
2384
|
}
|
|
770
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
|
+
|
|
771
2397
|
if (request.method === 'GET' && pathname === '/api/inbox') {
|
|
772
2398
|
const agent = stringParam(url, 'agent');
|
|
773
2399
|
if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
@@ -797,17 +2423,126 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
797
2423
|
if (request.method === 'GET' && pathname === '/api/deliveries') {
|
|
798
2424
|
const agent = stringParam(url, 'agent');
|
|
799
2425
|
if (agent) {
|
|
800
|
-
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
|
+
});
|
|
801
2433
|
}
|
|
802
2434
|
return json({ deliveries: store.listAllDeliveries(numberParam(url, 'limit', 50)) });
|
|
803
2435
|
}
|
|
804
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
|
+
|
|
805
2539
|
if (request.method === 'POST' && pathname === '/api/deliveries') {
|
|
806
2540
|
return readJson<CreateDeliveryBody>(request).then((body) => {
|
|
807
2541
|
if (!body.message_id) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
|
|
808
2542
|
if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
809
2543
|
const delivery = store.createDelivery({ messageId: body.message_id, agent: body.agent });
|
|
810
|
-
|
|
2544
|
+
const notify = notifyDeliveries(options, [delivery]);
|
|
2545
|
+
return json({ delivery, notify }, 201);
|
|
811
2546
|
});
|
|
812
2547
|
}
|
|
813
2548
|
|
|
@@ -822,6 +2557,7 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
822
2557
|
connectionId: connection?.connectionId ?? body.connection_id,
|
|
823
2558
|
computerId: connection?.computerId ?? body.computer_id,
|
|
824
2559
|
leaseMs: body.lease_ms,
|
|
2560
|
+
steerRunId: body.steer_run_id,
|
|
825
2561
|
});
|
|
826
2562
|
return json({ delivery });
|
|
827
2563
|
});
|
|
@@ -882,6 +2618,23 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
882
2618
|
});
|
|
883
2619
|
}
|
|
884
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
|
+
|
|
885
2638
|
const deliveryFailMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/fail$/);
|
|
886
2639
|
if (request.method === 'POST' && deliveryFailMatch) {
|
|
887
2640
|
return readJson<FinishDeliveryBody>(request).then((body) => {
|
|
@@ -904,19 +2657,49 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
904
2657
|
return readJson<SendBody>(request).then(async (body) => {
|
|
905
2658
|
const connection = assertDaemonConnection(store, request);
|
|
906
2659
|
const sender = body.sender?.trim();
|
|
907
|
-
const content = body.content?.trim();
|
|
908
2660
|
if (!sender) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
|
|
909
|
-
|
|
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
|
+
}
|
|
910
2685
|
|
|
911
|
-
const
|
|
912
|
-
|
|
2686
|
+
const firstParentId = batch.find((item) => item.parent_id !== undefined)?.parent_id;
|
|
2687
|
+
const targetRoom = firstParentId !== undefined
|
|
2688
|
+
? store.getMessage(firstParentId)?.chat_id
|
|
913
2689
|
: body.room_id ?? body.chat_id;
|
|
914
2690
|
const target = targetRoom
|
|
915
2691
|
? store.getChatById(targetRoom)
|
|
916
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
|
+
}
|
|
917
2696
|
if (target && !store.canSendWebMessageToRoom({ room: target, sender })) {
|
|
918
2697
|
throw new HttpError(403, 'EXTERNAL_ROOM_READ_ONLY', 'external provider rooms are read-only for web human senders');
|
|
919
2698
|
}
|
|
2699
|
+
if (target) {
|
|
2700
|
+
const mentionValidation = validateMessageMentions(store, target.id, batch);
|
|
2701
|
+
if (mentionValidation) return json(mentionValidation);
|
|
2702
|
+
}
|
|
920
2703
|
if (store.getAgent(sender) || store.hasDaemonAgent(sender)) {
|
|
921
2704
|
if (!connection) throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection auth is required for agent-authored messages');
|
|
922
2705
|
if (!store.daemonHasAgent(connection.connectionId, sender)) {
|
|
@@ -924,47 +2707,108 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
924
2707
|
}
|
|
925
2708
|
}
|
|
926
2709
|
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const result = await sendTextMessage({
|
|
949
|
-
client,
|
|
950
|
-
receiveIdType: 'chat_id',
|
|
951
|
-
receiveId: conversation.external_chat_id,
|
|
952
|
-
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,
|
|
953
2731
|
});
|
|
954
|
-
|
|
955
|
-
} catch (err) {
|
|
956
|
-
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);
|
|
957
2733
|
}
|
|
958
2734
|
}
|
|
959
2735
|
|
|
960
|
-
|
|
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);
|
|
961
2759
|
});
|
|
962
2760
|
}
|
|
963
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
|
+
|
|
964
2795
|
if (request.method === 'GET' && pathname === '/api/runs') {
|
|
965
2796
|
return json({ runs: store.listRuns(numberParam(url, 'limit', 50)) });
|
|
966
2797
|
}
|
|
967
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
|
+
|
|
968
2812
|
if (request.method === 'POST' && pathname === '/api/runs') {
|
|
969
2813
|
return readJson<StartRunBody>(request).then((body) => {
|
|
970
2814
|
const connection = requireDaemonConnection(store, request);
|
|
@@ -999,9 +2843,34 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
999
2843
|
if (request.method === 'GET' && messageMatch) {
|
|
1000
2844
|
const message = store.getMessage(Number(messageMatch[1]));
|
|
1001
2845
|
if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
|
|
2846
|
+
requireAgentScopedRead(store, request, url, message.chat_id);
|
|
1002
2847
|
return json({ message });
|
|
1003
2848
|
}
|
|
1004
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
|
+
|
|
1005
2874
|
const runMatch = pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
1006
2875
|
if (request.method === 'GET' && runMatch) {
|
|
1007
2876
|
const run = store.getRun(runMatch[1]!);
|
|
@@ -1038,6 +2907,28 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
1038
2907
|
});
|
|
1039
2908
|
}
|
|
1040
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
|
+
|
|
1041
2932
|
const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart)$/);
|
|
1042
2933
|
if (request.method === 'POST' && actionMatch) {
|
|
1043
2934
|
const run = store.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
|
|
@@ -1047,9 +2938,9 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
1047
2938
|
return routeNotFound();
|
|
1048
2939
|
}
|
|
1049
2940
|
|
|
1050
|
-
export async function handleRequest(store: MessageStore, request: Request): Promise<Response> {
|
|
2941
|
+
export async function handleRequest(store: MessageStore, request: Request, options: AppRouteOptions = {}): Promise<Response> {
|
|
1051
2942
|
try {
|
|
1052
|
-
return await route(store, request);
|
|
2943
|
+
return await route(store, request, options);
|
|
1053
2944
|
} catch (error) {
|
|
1054
2945
|
return failure(error);
|
|
1055
2946
|
}
|