@controlflow-ai/daemon 0.1.0
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 +360 -0
- package/bin/console.js +2 -0
- package/bin/daemon.js +2 -0
- package/bin/pal.js +2 -0
- package/bin/server.js +2 -0
- package/package.json +31 -0
- package/src/agent-runtime.ts +285 -0
- package/src/app.ts +745 -0
- package/src/args.ts +54 -0
- package/src/artifacts.ts +85 -0
- package/src/cli.ts +284 -0
- package/src/client.ts +310 -0
- package/src/coco.ts +52 -0
- package/src/codex.ts +41 -0
- package/src/coding-agent-runtime.ts +20 -0
- package/src/config.ts +106 -0
- package/src/console.ts +349 -0
- package/src/daemon-client.ts +91 -0
- package/src/daemon.ts +580 -0
- package/src/db.ts +2830 -0
- package/src/failure-message.ts +17 -0
- package/src/format.ts +13 -0
- package/src/http.ts +55 -0
- package/src/lark/agent-runtime.ts +142 -0
- package/src/lark/cli.ts +549 -0
- package/src/lark/credentials.ts +105 -0
- package/src/lark/daemon-integration.ts +108 -0
- package/src/lark/dispatcher.ts +374 -0
- package/src/lark/event-router.ts +329 -0
- package/src/lark/inbound-events.ts +131 -0
- package/src/lark/server-integration.ts +445 -0
- package/src/lark/setup.ts +326 -0
- package/src/lark/ws-daemon.ts +224 -0
- package/src/lark-fixture-diagnostics.ts +56 -0
- package/src/lark-fixture.ts +277 -0
- package/src/local-api.ts +155 -0
- package/src/local-auth.ts +45 -0
- package/src/migrations/001_initial.ts +61 -0
- package/src/migrations/002_daemon_deliveries.ts +52 -0
- package/src/migrations/003_sessions_runs.ts +49 -0
- package/src/migrations/004_message_idempotency.ts +21 -0
- package/src/migrations/005_artifacts.ts +24 -0
- package/src/migrations/006_lark_channel_foundation.ts +119 -0
- package/src/migrations/007_agents_a0.ts +17 -0
- package/src/migrations/008_b0_chat_history.ts +31 -0
- package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
- package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
- package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
- package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
- package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
- package/src/migrations/014_agents_runtime.ts +10 -0
- package/src/migrations/015_agent_runtime_sessions.ts +15 -0
- package/src/migrations/016_room_participants.ts +27 -0
- package/src/migrations/017_unified_room_delivery.ts +203 -0
- package/src/migrations/018_room_display_names.ts +36 -0
- package/src/migrations/019_computer_connections.ts +63 -0
- package/src/migrations/020_computer_agent_assignments.ts +20 -0
- package/src/migrations/021_provider_identity_bindings.ts +32 -0
- package/src/migrations.ts +85 -0
- package/src/neeko.ts +23 -0
- package/src/provider-identity.ts +40 -0
- package/src/runtime-registry.ts +41 -0
- package/src/server-auth.ts +13 -0
- package/src/server.ts +63 -0
- package/src/token-file.ts +57 -0
- package/src/types.ts +408 -0
- package/src/web.ts +565 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { MessageDelivery } from './types.js';
|
|
2
|
+
import type { MessageStore } from './db.js';
|
|
3
|
+
|
|
4
|
+
export const LARK_FIXTURE_DISCLAIMER = 'Synthetic local fixture contract only; does not prove real Lark provider payloads or semantics.';
|
|
5
|
+
|
|
6
|
+
export type LarkFixtureChatType = 'group' | 'p2p';
|
|
7
|
+
export type LarkFixtureMessageType = 'text' | 'post' | 'image' | 'file' | 'unknown';
|
|
8
|
+
export type LarkFixtureScope = 'thread' | 'p2p' | 'ignored' | 'rejected';
|
|
9
|
+
|
|
10
|
+
export interface LarkFixtureEvent {
|
|
11
|
+
fixture_id: string;
|
|
12
|
+
app_id: string;
|
|
13
|
+
chat_id: string;
|
|
14
|
+
chat_type: LarkFixtureChatType;
|
|
15
|
+
message_id: string;
|
|
16
|
+
root_id?: string | null;
|
|
17
|
+
parent_id?: string | null;
|
|
18
|
+
thread_id?: string | null;
|
|
19
|
+
sender_open_id: string;
|
|
20
|
+
sender_type: 'user' | 'bot' | 'app' | 'unknown';
|
|
21
|
+
mentions?: string[];
|
|
22
|
+
message_type: LarkFixtureMessageType;
|
|
23
|
+
content: string;
|
|
24
|
+
quote_root_id?: string | null;
|
|
25
|
+
quote_message_id?: string | null;
|
|
26
|
+
allowed_sender?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NormalizedLarkFixtureEvent {
|
|
30
|
+
fixture_id: string;
|
|
31
|
+
app_id: string;
|
|
32
|
+
chat_id: string;
|
|
33
|
+
chat_type: LarkFixtureChatType;
|
|
34
|
+
message_id: string;
|
|
35
|
+
root_id: string | null;
|
|
36
|
+
parent_id: string | null;
|
|
37
|
+
thread_id: string | null;
|
|
38
|
+
sender_open_id: string;
|
|
39
|
+
sender_type: LarkFixtureEvent['sender_type'];
|
|
40
|
+
mentions: string[];
|
|
41
|
+
message_type: LarkFixtureMessageType;
|
|
42
|
+
content: string;
|
|
43
|
+
quote_root_id: string | null;
|
|
44
|
+
quote_message_id: string | null;
|
|
45
|
+
reply_external_message_id: string | null;
|
|
46
|
+
scope: LarkFixtureScope;
|
|
47
|
+
conversation_key: string | null;
|
|
48
|
+
delivery_recipient: 'neeko' | null;
|
|
49
|
+
audit_status: 'mapped' | 'ignored' | 'rejected';
|
|
50
|
+
rejection_reason: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApplyFixtureResult {
|
|
54
|
+
normalized: NormalizedLarkFixtureEvent;
|
|
55
|
+
transcriptId: string | null;
|
|
56
|
+
mappingId: string | null;
|
|
57
|
+
delivery: MessageDelivery | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const REQUIRED_FIELDS = ['fixture_id', 'app_id', 'chat_id', 'chat_type', 'message_id', 'sender_open_id', 'sender_type', 'message_type', 'content'] as const;
|
|
61
|
+
|
|
62
|
+
export function normalizeLarkFixtureEvent(event: LarkFixtureEvent, botOpenId: string): NormalizedLarkFixtureEvent {
|
|
63
|
+
const result = normalizeLarkFixtureEventInner(event, botOpenId);
|
|
64
|
+
// B0 spec lock round 2 invariant: ignored events must never carry a conversation_key.
|
|
65
|
+
if (result.audit_status === 'ignored' && result.conversation_key !== null) {
|
|
66
|
+
throw new Error(`normalizeLarkFixtureEvent invariant violated: audit_status='ignored' but conversation_key is not null (${result.conversation_key})`);
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeLarkFixtureEventInner(event: LarkFixtureEvent, botOpenId: string): NormalizedLarkFixtureEvent {
|
|
72
|
+
for (const field of REQUIRED_FIELDS) {
|
|
73
|
+
if (!String(event[field] ?? '').trim()) throw new Error(`fixture field ${field} is required`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const mentions = event.mentions ?? [];
|
|
77
|
+
const allowed = event.allowed_sender !== false;
|
|
78
|
+
const mentionedBot = mentions.includes(botOpenId);
|
|
79
|
+
const rootId = event.root_id ?? null;
|
|
80
|
+
const threadId = event.thread_id ?? null;
|
|
81
|
+
const parentId = event.parent_id ?? null;
|
|
82
|
+
const quoteRootId = event.quote_root_id ?? null;
|
|
83
|
+
const quoteMessageId = event.quote_message_id ?? null;
|
|
84
|
+
|
|
85
|
+
if (!allowed) {
|
|
86
|
+
return normalized(event, mentions, 'rejected', null, null, 'rejected', 'sender_not_allowed');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (event.chat_type === 'p2p') {
|
|
90
|
+
return normalized(event, mentions, 'p2p', `lark:${event.app_id}:p2p:${event.sender_open_id}`, 'neeko', 'mapped', null);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (quoteRootId || quoteMessageId) {
|
|
94
|
+
if (!quoteRootId || !quoteMessageId) return normalized(event, mentions, 'rejected', null, null, 'rejected', 'quote_root_only_missing_required_quote_fields');
|
|
95
|
+
if (threadId || rootId || parentId) return normalized(event, mentions, 'rejected', null, null, 'rejected', 'quote_root_only_conflicts_with_thread_or_parent_fields');
|
|
96
|
+
if (!mentionedBot) return normalized(event, mentions, 'ignored', null, null, 'ignored', 'quote_root_only_without_bot_mention');
|
|
97
|
+
const key = `lark:${event.app_id}:${event.chat_id}:quote:${quoteRootId}:${quoteMessageId}`;
|
|
98
|
+
return normalized(event, mentions, 'thread', key, 'neeko', 'mapped', null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (threadId) {
|
|
102
|
+
const admitsByContext = Boolean(mentionedBot || parentId || rootId);
|
|
103
|
+
const key = admitsByContext ? `lark:${event.app_id}:${event.chat_id}:thread:${threadId}` : null;
|
|
104
|
+
return normalized(
|
|
105
|
+
event,
|
|
106
|
+
mentions,
|
|
107
|
+
'thread',
|
|
108
|
+
key,
|
|
109
|
+
admitsByContext ? 'neeko' : null,
|
|
110
|
+
admitsByContext ? 'mapped' : 'ignored',
|
|
111
|
+
admitsByContext ? null : 'group_message_without_mention_or_thread_context',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!mentionedBot) {
|
|
116
|
+
return normalized(event, mentions, 'ignored', null, null, 'ignored', 'group_message_without_bot_mention');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return normalized(event, mentions, 'thread', `lark:${event.app_id}:${event.chat_id}:root:${event.message_id}`, 'neeko', 'mapped', null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalized(
|
|
123
|
+
event: LarkFixtureEvent,
|
|
124
|
+
mentions: string[],
|
|
125
|
+
scope: LarkFixtureScope,
|
|
126
|
+
conversationKey: string | null,
|
|
127
|
+
deliveryRecipient: 'neeko' | null,
|
|
128
|
+
auditStatus: NormalizedLarkFixtureEvent['audit_status'],
|
|
129
|
+
rejectionReason: string | null,
|
|
130
|
+
): NormalizedLarkFixtureEvent {
|
|
131
|
+
return {
|
|
132
|
+
fixture_id: event.fixture_id,
|
|
133
|
+
app_id: event.app_id,
|
|
134
|
+
chat_id: event.chat_id,
|
|
135
|
+
chat_type: event.chat_type,
|
|
136
|
+
message_id: event.message_id,
|
|
137
|
+
root_id: event.root_id ?? null,
|
|
138
|
+
parent_id: event.parent_id ?? null,
|
|
139
|
+
thread_id: event.thread_id ?? null,
|
|
140
|
+
sender_open_id: event.sender_open_id,
|
|
141
|
+
sender_type: event.sender_type,
|
|
142
|
+
mentions,
|
|
143
|
+
message_type: event.message_type,
|
|
144
|
+
content: event.content,
|
|
145
|
+
quote_root_id: event.quote_root_id ?? null,
|
|
146
|
+
quote_message_id: event.quote_message_id ?? null,
|
|
147
|
+
reply_external_message_id: event.parent_id ?? null,
|
|
148
|
+
scope,
|
|
149
|
+
conversation_key: conversationKey,
|
|
150
|
+
delivery_recipient: deliveryRecipient,
|
|
151
|
+
audit_status: auditStatus,
|
|
152
|
+
rejection_reason: rejectionReason,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveReplyToTranscriptId(store: MessageStore, accountId: string, conversationId: string, normalized: NormalizedLarkFixtureEvent): string | null {
|
|
157
|
+
const externalParent = normalized.reply_external_message_id;
|
|
158
|
+
if (!externalParent) return null;
|
|
159
|
+
const found = store.findTranscriptByExternalMessage({ accountId, externalChatId: normalized.chat_id, externalMessageId: externalParent });
|
|
160
|
+
if (!found) return null;
|
|
161
|
+
return found.conversation_id === conversationId ? found.id : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolveQuoteTranscriptId(store: MessageStore, accountId: string, conversationId: string, externalChatId: string, externalMessageId: string | null): string | null {
|
|
165
|
+
if (!externalMessageId) return null;
|
|
166
|
+
const found = store.findTranscriptByExternalMessage({ accountId, externalChatId, externalMessageId });
|
|
167
|
+
if (!found) return null;
|
|
168
|
+
return found.conversation_id === conversationId ? found.id : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function applyLarkFixtureToStore(store: MessageStore, event: LarkFixtureEvent, options: { accountName: string; botOpenId: string }): ApplyFixtureResult {
|
|
172
|
+
const normalized = normalizeLarkFixtureEvent(event, options.botOpenId);
|
|
173
|
+
const account = store.registerChannelAccount({ name: options.accountName, appId: normalized.app_id, botOpenId: options.botOpenId });
|
|
174
|
+
|
|
175
|
+
if (normalized.audit_status !== 'mapped' || !normalized.conversation_key) {
|
|
176
|
+
const result = store.ingestAuditEnvelope({
|
|
177
|
+
conversation: {
|
|
178
|
+
accountId: account.id,
|
|
179
|
+
chatName: normalized.chat_type === 'p2p' ? `p2p-${normalized.sender_open_id}` : `lark-${normalized.chat_id}`,
|
|
180
|
+
conversationKey: `lark:${normalized.app_id}:audit:${normalized.chat_id}:${normalized.message_id}`,
|
|
181
|
+
externalChatId: normalized.chat_id,
|
|
182
|
+
externalRootId: null,
|
|
183
|
+
externalThreadId: null,
|
|
184
|
+
scope: normalized.chat_type === 'p2p' ? 'p2p' : 'chat',
|
|
185
|
+
chatType: normalized.chat_type,
|
|
186
|
+
},
|
|
187
|
+
envelope: {
|
|
188
|
+
accountId: account.id,
|
|
189
|
+
conversationId: '',
|
|
190
|
+
direction: 'inbound',
|
|
191
|
+
externalMessageId: normalized.message_id,
|
|
192
|
+
externalChatId: normalized.chat_id,
|
|
193
|
+
externalRootId: normalized.root_id,
|
|
194
|
+
externalThreadId: normalized.thread_id,
|
|
195
|
+
senderOpenId: normalized.sender_open_id,
|
|
196
|
+
senderType: normalized.sender_type,
|
|
197
|
+
rawType: normalized.message_type,
|
|
198
|
+
status: normalized.audit_status,
|
|
199
|
+
reasonCode: normalized.rejection_reason,
|
|
200
|
+
admitted: false,
|
|
201
|
+
rawPayloadRedactedJson: JSON.stringify({ fixture_id: normalized.fixture_id }),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
return { normalized, transcriptId: null, mappingId: result.mapping.id, delivery: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const conversationInput = {
|
|
208
|
+
accountId: account.id,
|
|
209
|
+
chatName: normalized.chat_type === 'p2p' ? `p2p-${normalized.sender_open_id}` : `lark-${normalized.chat_id}`,
|
|
210
|
+
conversationKey: normalized.conversation_key,
|
|
211
|
+
externalChatId: normalized.chat_id,
|
|
212
|
+
externalRootId: normalized.root_id ?? normalized.message_id,
|
|
213
|
+
externalThreadId: normalized.thread_id,
|
|
214
|
+
scope: (normalized.chat_type === 'p2p' ? 'p2p' : 'thread') as 'p2p' | 'thread',
|
|
215
|
+
chatType: normalized.chat_type,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = store.ingestCanonicalEnvelope({
|
|
219
|
+
conversation: conversationInput,
|
|
220
|
+
transcript: {
|
|
221
|
+
direction: 'inbound',
|
|
222
|
+
senderType: normalized.sender_type === 'user' ? 'user' : 'bot',
|
|
223
|
+
senderId: normalized.sender_open_id,
|
|
224
|
+
content: normalized.content,
|
|
225
|
+
contentType: normalized.message_type,
|
|
226
|
+
sourceType: 'lark_fixture_event',
|
|
227
|
+
sourceUniqueKey: `${normalized.app_id}:${normalized.message_id}`,
|
|
228
|
+
mentions: normalized.mentions,
|
|
229
|
+
replyToExternalMessageId: normalized.reply_external_message_id,
|
|
230
|
+
quoteRootExternalMessageId: normalized.quote_root_id,
|
|
231
|
+
quoteMessageExternalMessageId: normalized.quote_message_id,
|
|
232
|
+
},
|
|
233
|
+
envelope: {
|
|
234
|
+
accountId: account.id,
|
|
235
|
+
direction: 'inbound',
|
|
236
|
+
externalMessageId: normalized.message_id,
|
|
237
|
+
externalChatId: normalized.chat_id,
|
|
238
|
+
externalRootId: normalized.root_id,
|
|
239
|
+
externalThreadId: normalized.thread_id,
|
|
240
|
+
senderOpenId: normalized.sender_open_id,
|
|
241
|
+
senderType: normalized.sender_type,
|
|
242
|
+
rawType: normalized.message_type,
|
|
243
|
+
status: normalized.audit_status,
|
|
244
|
+
reasonCode: normalized.rejection_reason,
|
|
245
|
+
admitted: true,
|
|
246
|
+
rawPayloadRedactedJson: JSON.stringify({ fixture_id: normalized.fixture_id }),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// P1-D: backfill same-conversation reply/quote transcript ids that arrived before their parents.
|
|
251
|
+
store.resolvePendingTranscriptRelations(result.conversation.id);
|
|
252
|
+
|
|
253
|
+
// Re-read transcript to capture any same-event same-conversation relation ids that this insertion
|
|
254
|
+
// unblocked for prior orphans, and to expose the resolved reply/quote transcript ids for the caller.
|
|
255
|
+
const created = result.transcript!;
|
|
256
|
+
const transcriptId = created.id;
|
|
257
|
+
const replyParent = resolveReplyToTranscriptId(store, account.id, result.conversation.id, normalized);
|
|
258
|
+
const quoteRoot = resolveQuoteTranscriptId(store, account.id, result.conversation.id, normalized.chat_id, normalized.quote_root_id);
|
|
259
|
+
const quoteMessage = resolveQuoteTranscriptId(store, account.id, result.conversation.id, normalized.chat_id, normalized.quote_message_id);
|
|
260
|
+
if (replyParent || quoteRoot || quoteMessage) {
|
|
261
|
+
store.createTranscript({
|
|
262
|
+
conversationId: result.conversation.id,
|
|
263
|
+
direction: created.direction,
|
|
264
|
+
senderType: created.sender_type,
|
|
265
|
+
senderId: created.sender_id,
|
|
266
|
+
content: created.content,
|
|
267
|
+
contentType: created.content_type,
|
|
268
|
+
sourceType: created.source_type,
|
|
269
|
+
sourceUniqueKey: created.source_unique_key,
|
|
270
|
+
replyToTranscriptId: replyParent,
|
|
271
|
+
quoteRootTranscriptId: quoteRoot,
|
|
272
|
+
quoteMessageTranscriptId: quoteMessage,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { normalized, transcriptId, mappingId: result.mapping.id, delivery: null };
|
|
277
|
+
}
|
package/src/local-api.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { LockClient } from './client.js';
|
|
2
|
+
import { assertLocalRequest, assertLoopbackBindHost } from './local-auth.js';
|
|
3
|
+
import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
|
|
4
|
+
import type { RunAction } from './types.js';
|
|
5
|
+
|
|
6
|
+
interface LocalApiOptions {
|
|
7
|
+
serverUrl: string;
|
|
8
|
+
token?: string;
|
|
9
|
+
daemonAuth?: ConstructorParameters<typeof LockClient>[1];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ArtifactBody {
|
|
13
|
+
content_base64?: string;
|
|
14
|
+
mime_type?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
filename?: string;
|
|
17
|
+
ttl_seconds?: number;
|
|
18
|
+
source_path?: string;
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SendBody {
|
|
23
|
+
chat?: string;
|
|
24
|
+
room?: string;
|
|
25
|
+
chat_id?: string;
|
|
26
|
+
room_id?: string;
|
|
27
|
+
parent_id?: number;
|
|
28
|
+
channel_id?: string | null;
|
|
29
|
+
sender?: string;
|
|
30
|
+
recipient?: string | null;
|
|
31
|
+
content?: string;
|
|
32
|
+
type?: 'message' | 'system';
|
|
33
|
+
idempotency_key?: string | null;
|
|
34
|
+
mentions?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function routeNotFound(): Response {
|
|
38
|
+
return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function localRoute(request: Request, options: LocalApiOptions): Promise<Response> {
|
|
42
|
+
try {
|
|
43
|
+
assertLocalRequest(request, options.token);
|
|
44
|
+
const client = new LockClient(options.serverUrl, options.daemonAuth ?? null);
|
|
45
|
+
const url = new URL(request.url);
|
|
46
|
+
const { pathname } = url;
|
|
47
|
+
|
|
48
|
+
if (request.method === 'GET' && pathname === '/local/health') {
|
|
49
|
+
return json({ status: 'ok', mode: 'daemon' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (request.method === 'GET' && pathname === '/local/chats') {
|
|
53
|
+
return json({ chats: await client.listChats() });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (request.method === 'GET' && pathname === '/local/rooms') {
|
|
57
|
+
return json({ rooms: await client.listRooms() });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const roomMembersMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/members$/);
|
|
61
|
+
if (request.method === 'GET' && roomMembersMatch) {
|
|
62
|
+
return json(await client.listRoomMembers(decodeURIComponent(roomMembersMatch[1]!)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
const roomInviteMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents$/);
|
|
67
|
+
if (request.method === 'POST' && roomInviteMatch) {
|
|
68
|
+
const body = await readJson<{ agent?: string; mode?: string }>(request);
|
|
69
|
+
return json(await client.inviteAgentToRoom(decodeURIComponent(roomInviteMatch[1]!), { agent: body.agent ?? '', mode: body.mode as never }), 201);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const roomTopicMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/topics$/);
|
|
73
|
+
if (request.method === 'POST' && roomTopicMatch) {
|
|
74
|
+
const body = await readJson<{ name?: string; created_by?: string | null }>(request);
|
|
75
|
+
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_TOPIC_NAME', 'name is required');
|
|
76
|
+
const channel = await client.createTopic(decodeURIComponent(roomTopicMatch[1]!), { name: body.name, created_by: body.created_by });
|
|
77
|
+
return json({ channel }, 201);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (request.method === 'GET' && pathname === '/local/messages') {
|
|
81
|
+
const params = new URLSearchParams();
|
|
82
|
+
for (const key of ['chat', 'room', 'chat_id', 'room_id', 'parent_id', 'channel_id', 'after', 'limit', 'q']) {
|
|
83
|
+
const value = url.searchParams.get(key);
|
|
84
|
+
if (value !== null) params.set(key, value);
|
|
85
|
+
}
|
|
86
|
+
return json({ messages: await client.getMessages(params) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const messageMatch = pathname.match(/^\/local\/messages\/(\d+)$/);
|
|
90
|
+
if (request.method === 'GET' && messageMatch) {
|
|
91
|
+
return json({ message: await client.getMessage(Number(messageMatch[1])) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (request.method === 'POST' && pathname === '/local/messages') {
|
|
95
|
+
const body = await readJson<SendBody>(request);
|
|
96
|
+
if (!body.sender?.trim()) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
|
|
97
|
+
if (!body.content?.trim()) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
|
|
98
|
+
const message = await client.sendMessage({
|
|
99
|
+
chat: body.room ?? body.chat,
|
|
100
|
+
room: body.room,
|
|
101
|
+
chat_id: body.room_id ?? body.chat_id,
|
|
102
|
+
room_id: body.room_id,
|
|
103
|
+
parent_id: body.parent_id,
|
|
104
|
+
channel_id: body.channel_id,
|
|
105
|
+
sender: body.sender,
|
|
106
|
+
recipient: body.recipient,
|
|
107
|
+
content: body.content,
|
|
108
|
+
type: body.type,
|
|
109
|
+
idempotency_key: body.idempotency_key,
|
|
110
|
+
mentions: body.mentions,
|
|
111
|
+
});
|
|
112
|
+
return json({ message }, 201);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (request.method === 'GET' && pathname === '/local/runs') {
|
|
116
|
+
return json({ runs: await client.listRuns() });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (request.method === 'POST' && pathname === '/local/artifacts') {
|
|
120
|
+
const body = await readJson<ArtifactBody>(request);
|
|
121
|
+
if (body.source_path || body.path) throw new HttpError(400, 'PATH_NOT_ALLOWED', 'artifact upload must include content, not a path');
|
|
122
|
+
if (!body.content_base64) throw new HttpError(400, 'MISSING_CONTENT', 'content_base64 is required');
|
|
123
|
+
if (!body.mime_type?.trim()) throw new HttpError(400, 'MISSING_MIME', 'mime_type is required');
|
|
124
|
+
const artifact = await client.createArtifact({
|
|
125
|
+
content_base64: body.content_base64,
|
|
126
|
+
mime_type: body.mime_type,
|
|
127
|
+
title: body.title,
|
|
128
|
+
filename: body.filename,
|
|
129
|
+
ttl_seconds: body.ttl_seconds,
|
|
130
|
+
});
|
|
131
|
+
return json(artifact, 201);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const actionMatch = pathname.match(/^\/local\/runs\/([^/]+)\/(kill|restart)$/);
|
|
135
|
+
if (request.method === 'POST' && actionMatch) {
|
|
136
|
+
const run = await client.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
|
|
137
|
+
return json({ run });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return routeNotFound();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return failure(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function startLocalApi(options: LocalApiOptions & { host: string; port: number }): ReturnType<typeof Bun.serve> {
|
|
147
|
+
assertLoopbackBindHost(options.host);
|
|
148
|
+
return Bun.serve({
|
|
149
|
+
hostname: options.host,
|
|
150
|
+
port: options.port,
|
|
151
|
+
fetch(request) {
|
|
152
|
+
return localRoute(request, options);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { HttpError } from './http.js';
|
|
2
|
+
|
|
3
|
+
const ALLOWED_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
4
|
+
const LOOPBACK_BIND_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
5
|
+
|
|
6
|
+
function hostName(value: string | null): string {
|
|
7
|
+
if (!value) return '';
|
|
8
|
+
const withoutPort = value.startsWith('[') ? value.slice(0, value.indexOf(']') + 1) : value.split(':')[0]!;
|
|
9
|
+
return withoutPort.toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertLocalRequest(request: Request, token: string | undefined): void {
|
|
13
|
+
const host = hostName(request.headers.get('host'));
|
|
14
|
+
if (!ALLOWED_HOSTS.has(host)) {
|
|
15
|
+
throw new HttpError(403, 'BAD_HOST', 'local daemon host is not allowed');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const origin = request.headers.get('origin');
|
|
19
|
+
if (origin) {
|
|
20
|
+
throw new HttpError(403, 'CORS_DENIED', 'browser origins are not allowed');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (request.method === 'OPTIONS') {
|
|
24
|
+
throw new HttpError(403, 'CORS_DENIED', 'CORS preflight is not allowed');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!token) {
|
|
28
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'local daemon token is required');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const header = request.headers.get('authorization');
|
|
32
|
+
if (header !== `Bearer ${token}`) {
|
|
33
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'invalid local daemon token');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertLoopbackBindHost(host: string): void {
|
|
38
|
+
if (!LOOPBACK_BIND_HOSTS.has(host.toLowerCase())) {
|
|
39
|
+
throw new Error('local daemon API must bind to a loopback host');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function localHeaders(token: string | undefined): HeadersInit {
|
|
44
|
+
return token ? { authorization: `Bearer ${token}`, 'x-lock-client': 'cli' } : { 'x-lock-client': 'cli' };
|
|
45
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export const version = 1;
|
|
4
|
+
export const name = 'initial';
|
|
5
|
+
|
|
6
|
+
export function up(db: Database): void {
|
|
7
|
+
db.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS chats (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL UNIQUE,
|
|
11
|
+
kind TEXT NOT NULL DEFAULT 'group' CHECK (kind IN ('group', 'dm')),
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
|
18
|
+
parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
|
|
19
|
+
depth INTEGER NOT NULL DEFAULT 0 CHECK (depth IN (0, 1)),
|
|
20
|
+
sender TEXT NOT NULL,
|
|
21
|
+
recipient TEXT,
|
|
22
|
+
content TEXT NOT NULL,
|
|
23
|
+
type TEXT NOT NULL DEFAULT 'message' CHECK (type IN ('message', 'system')),
|
|
24
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS agent_runs (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
30
|
+
agent TEXT NOT NULL,
|
|
31
|
+
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'killed', 'restarted')),
|
|
32
|
+
action TEXT CHECK (action IN ('kill', 'restart')),
|
|
33
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
34
|
+
pid INTEGER,
|
|
35
|
+
cwd TEXT NOT NULL,
|
|
36
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
37
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
38
|
+
ended_at TEXT,
|
|
39
|
+
exit_code INTEGER,
|
|
40
|
+
output TEXT NOT NULL DEFAULT ''
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, id);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_messages_parent_id ON messages(parent_id, id);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_messages_recipient ON messages(recipient, id);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_agent_runs_message_id ON agent_runs(message_id, attempt);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status, updated_at);
|
|
48
|
+
|
|
49
|
+
CREATE VIEW IF NOT EXISTS chat_stats AS
|
|
50
|
+
SELECT
|
|
51
|
+
c.id,
|
|
52
|
+
c.name,
|
|
53
|
+
c.kind,
|
|
54
|
+
c.created_at,
|
|
55
|
+
COUNT(m.id) AS message_count,
|
|
56
|
+
MAX(m.created_at) AS last_message_at
|
|
57
|
+
FROM chats c
|
|
58
|
+
LEFT JOIN messages m ON m.chat_id = c.id
|
|
59
|
+
GROUP BY c.id;
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export const version = 2;
|
|
4
|
+
export const name = 'daemon_deliveries';
|
|
5
|
+
|
|
6
|
+
export function up(db: Database): void {
|
|
7
|
+
db.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS daemon_instances (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL,
|
|
11
|
+
host TEXT NOT NULL DEFAULT '',
|
|
12
|
+
local_url TEXT NOT NULL DEFAULT '',
|
|
13
|
+
server_url TEXT NOT NULL DEFAULT '',
|
|
14
|
+
status TEXT NOT NULL DEFAULT 'online' CHECK (status IN ('online', 'offline')),
|
|
15
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
16
|
+
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS daemon_agents (
|
|
20
|
+
daemon_id TEXT NOT NULL REFERENCES daemon_instances(id) ON DELETE CASCADE,
|
|
21
|
+
agent TEXT NOT NULL,
|
|
22
|
+
cwd TEXT NOT NULL DEFAULT '',
|
|
23
|
+
capabilities TEXT NOT NULL DEFAULT '{}',
|
|
24
|
+
status TEXT NOT NULL DEFAULT 'online' CHECK (status IN ('online', 'offline')),
|
|
25
|
+
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
26
|
+
PRIMARY KEY (daemon_id, agent)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS message_deliveries (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
32
|
+
chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
|
33
|
+
agent TEXT NOT NULL,
|
|
34
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'processing_completed', 'acked', 'failed', 'canceled')),
|
|
35
|
+
daemon_id TEXT REFERENCES daemon_instances(id) ON DELETE SET NULL,
|
|
36
|
+
claim_token TEXT,
|
|
37
|
+
lease_until TEXT,
|
|
38
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
40
|
+
run_id TEXT,
|
|
41
|
+
last_error TEXT NOT NULL DEFAULT '',
|
|
42
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
43
|
+
claimed_at TEXT,
|
|
44
|
+
acked_at TEXT,
|
|
45
|
+
failed_at TEXT
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_message_deliveries_status_lease ON message_deliveries(status, lease_until);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_message_deliveries_agent ON message_deliveries(agent, status, created_at);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_message_deliveries_message_id ON message_deliveries(message_id);
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export const version = 3;
|
|
4
|
+
export const name = 'sessions_runs';
|
|
5
|
+
|
|
6
|
+
function hasColumn(db: Database, table: string, column: string): boolean {
|
|
7
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
8
|
+
return rows.some((row) => row.name === column);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function addColumnIfMissing(db: Database, table: string, column: string, ddl: string): void {
|
|
12
|
+
if (!hasColumn(db, table, column)) {
|
|
13
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function up(db: Database): void {
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
|
22
|
+
agent TEXT NOT NULL,
|
|
23
|
+
daemon_id TEXT NOT NULL REFERENCES daemon_instances(id) ON DELETE CASCADE,
|
|
24
|
+
cwd TEXT NOT NULL DEFAULT '',
|
|
25
|
+
session_key TEXT NOT NULL,
|
|
26
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
27
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
28
|
+
last_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
|
29
|
+
UNIQUE(chat_id, agent, daemon_id)
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
addColumnIfMissing(db, 'agent_runs', 'session_id', 'session_id TEXT REFERENCES agent_sessions(id) ON DELETE SET NULL');
|
|
34
|
+
addColumnIfMissing(db, 'agent_runs', 'trigger_message_id', 'trigger_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL');
|
|
35
|
+
addColumnIfMissing(db, 'agent_runs', 'daemon_id', 'daemon_id TEXT REFERENCES daemon_instances(id) ON DELETE SET NULL');
|
|
36
|
+
addColumnIfMissing(db, 'agent_runs', 'delivery_id', 'delivery_id TEXT REFERENCES message_deliveries(id) ON DELETE SET NULL');
|
|
37
|
+
|
|
38
|
+
db.exec(`
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_agent_sessions_lookup ON agent_sessions(chat_id, agent, daemon_id);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_agent_runs_session_id ON agent_runs(session_id, attempt);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_agent_runs_delivery_id ON agent_runs(delivery_id);
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
db.exec(`
|
|
45
|
+
UPDATE agent_runs
|
|
46
|
+
SET trigger_message_id = message_id
|
|
47
|
+
WHERE trigger_message_id IS NULL;
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export const version = 4;
|
|
4
|
+
export const name = 'message_idempotency';
|
|
5
|
+
|
|
6
|
+
function hasColumn(db: Database, table: string, column: string): boolean {
|
|
7
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
8
|
+
return rows.some((row) => row.name === column);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function up(db: Database): void {
|
|
12
|
+
if (!hasColumn(db, 'messages', 'idempotency_key')) {
|
|
13
|
+
db.exec('ALTER TABLE messages ADD COLUMN idempotency_key TEXT');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_idempotency_key
|
|
18
|
+
ON messages(idempotency_key)
|
|
19
|
+
WHERE idempotency_key IS NOT NULL;
|
|
20
|
+
`);
|
|
21
|
+
}
|