@swarmclawai/swarmclaw 1.5.53 → 1.5.55
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 +17 -3
- package/package.json +2 -2
- package/src/app/api/agents/[id]/route.ts +14 -2
- package/src/app/api/agents/agents-route.test.ts +65 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
- package/src/app/api/chatrooms/route.ts +3 -0
- package/src/app/api/missions/[id]/control/route.ts +21 -0
- package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
- package/src/app/api/missions/templates/route.ts +8 -0
- package/src/app/api/tasks/[id]/route.ts +11 -1
- package/src/app/api/tasks/tasks-route.test.ts +81 -0
- package/src/app/api/webhooks/[id]/route.ts +18 -15
- package/src/app/missions/page.tsx +135 -22
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/missions/mission-edit-sheet.tsx +319 -0
- package/src/components/missions/mission-template-gallery.tsx +113 -0
- package/src/components/missions/mission-template-install-dialog.tsx +283 -0
- package/src/lib/server/agents/agent-service.ts +10 -2
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
- package/src/lib/server/agents/main-agent-loop.ts +111 -4
- package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
- package/src/lib/server/chat-execution/message-classifier.ts +11 -7
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
- package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
- package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
- package/src/lib/server/chat-execution/response-completeness.ts +11 -3
- package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
- package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
- package/src/lib/server/chats/chat-session-service.ts +11 -0
- package/src/lib/server/connectors/email.test.ts +64 -0
- package/src/lib/server/connectors/email.ts +35 -6
- package/src/lib/server/connectors/response-media.ts +1 -0
- package/src/lib/server/daemon/daemon-runtime.ts +31 -19
- package/src/lib/server/memory/memory-db.test.ts +8 -0
- package/src/lib/server/memory/memory-db.ts +1 -1
- package/src/lib/server/missions/mission-service.ts +47 -1
- package/src/lib/server/missions/mission-templates.test.ts +208 -0
- package/src/lib/server/missions/mission-templates.ts +186 -0
- package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
- package/src/lib/server/storage-normalization.ts +6 -0
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-validation.test.ts +30 -0
- package/src/lib/server/tasks/task-validation.ts +21 -2
- package/src/lib/server/working-state/normalization.ts +5 -1
- package/src/lib/validation/schemas.ts +40 -0
- package/src/types/mission.ts +27 -0
|
@@ -1,7 +1,84 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
1
2
|
import type { Chatroom, Agent } from '@/types'
|
|
2
3
|
import { patchChatroom } from '@/lib/server/chatrooms/chatroom-repository'
|
|
3
4
|
import { notify } from '@/lib/server/ws-hub'
|
|
4
5
|
|
|
6
|
+
const REACTION_MARKER = '[REACTION]'
|
|
7
|
+
|
|
8
|
+
const ReactionPayloadSchema = z.object({
|
|
9
|
+
emoji: z.string().min(1),
|
|
10
|
+
to: z.string().min(1),
|
|
11
|
+
}).passthrough()
|
|
12
|
+
type ReactionPayload = z.infer<typeof ReactionPayloadSchema>
|
|
13
|
+
|
|
14
|
+
interface ReactionMatch {
|
|
15
|
+
start: number
|
|
16
|
+
end: number
|
|
17
|
+
payload: ReactionPayload
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findBalancedJsonObjectEnd(text: string, start: number): number {
|
|
21
|
+
if (text.charAt(start) !== '{') return -1
|
|
22
|
+
let depth = 0
|
|
23
|
+
let inString = false
|
|
24
|
+
let escaped = false
|
|
25
|
+
for (let i = start; i < text.length; i += 1) {
|
|
26
|
+
const c = text.charAt(i)
|
|
27
|
+
if (inString) {
|
|
28
|
+
if (escaped) escaped = false
|
|
29
|
+
else if (c === '\\') escaped = true
|
|
30
|
+
else if (c === '"') inString = false
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (c === '"') {
|
|
34
|
+
inString = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (c === '{') depth += 1
|
|
38
|
+
else if (c === '}') {
|
|
39
|
+
depth -= 1
|
|
40
|
+
if (depth === 0) return i + 1
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return -1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findReactionMatches(text: string): ReactionMatch[] {
|
|
47
|
+
const matches: ReactionMatch[] = []
|
|
48
|
+
if (!text) return matches
|
|
49
|
+
let cursor = 0
|
|
50
|
+
while (cursor < text.length) {
|
|
51
|
+
const markerAt = text.indexOf(REACTION_MARKER, cursor)
|
|
52
|
+
if (markerAt < 0) break
|
|
53
|
+
let jsonStart = markerAt + REACTION_MARKER.length
|
|
54
|
+
while (jsonStart < text.length && /\s/.test(text.charAt(jsonStart))) jsonStart += 1
|
|
55
|
+
if (text.charAt(jsonStart) !== '{') {
|
|
56
|
+
cursor = markerAt + REACTION_MARKER.length
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const jsonEnd = findBalancedJsonObjectEnd(text, jsonStart)
|
|
60
|
+
if (jsonEnd <= jsonStart) {
|
|
61
|
+
cursor = markerAt + REACTION_MARKER.length
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
let parsed: unknown
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(text.slice(jsonStart, jsonEnd))
|
|
67
|
+
} catch {
|
|
68
|
+
cursor = jsonStart + 1
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const validated = ReactionPayloadSchema.safeParse(parsed)
|
|
72
|
+
if (!validated.success) {
|
|
73
|
+
cursor = jsonEnd
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
matches.push({ start: markerAt, end: jsonEnd, payload: validated.data })
|
|
77
|
+
cursor = jsonEnd
|
|
78
|
+
}
|
|
79
|
+
return matches
|
|
80
|
+
}
|
|
81
|
+
|
|
5
82
|
/**
|
|
6
83
|
* Normalizes text for comparison (lowercase, alphanumeric only)
|
|
7
84
|
*/
|
|
@@ -67,16 +144,35 @@ export function addAgentReaction(chatroomId: string, messageId: string, agentId:
|
|
|
67
144
|
/**
|
|
68
145
|
* Parses [REACTION] tokens from agent output and applies them.
|
|
69
146
|
* Format: [REACTION]{"emoji": "👍", "to": "msg_id"}
|
|
147
|
+
*
|
|
148
|
+
* Uses a balanced-brace walker + zod validation so nested JSON or noisy
|
|
149
|
+
* payloads don't slip past, and so unrelated `[REACTION]something` text
|
|
150
|
+
* doesn't get spuriously consumed.
|
|
70
151
|
*/
|
|
71
152
|
export function applyAgentReactionsFromText(text: string, chatroomId: string, agentId: string) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
153
|
+
for (const match of findReactionMatches(text)) {
|
|
154
|
+
addAgentReaction(chatroomId, match.payload.to, agentId, match.payload.emoji)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Removes [REACTION]{...} markers from agent output so they don't bleed into
|
|
160
|
+
* the visible message body. Reactions are persisted separately by
|
|
161
|
+
* applyAgentReactionsFromText.
|
|
162
|
+
*/
|
|
163
|
+
export function stripAgentReactionTokens(text: string): string {
|
|
164
|
+
if (!text) return text
|
|
165
|
+
const matches = findReactionMatches(text)
|
|
166
|
+
if (matches.length === 0) return text
|
|
167
|
+
let out = ''
|
|
168
|
+
let cursor = 0
|
|
169
|
+
for (const match of matches) {
|
|
170
|
+
out += text.slice(cursor, match.start)
|
|
171
|
+
cursor = match.end
|
|
81
172
|
}
|
|
173
|
+
out += text.slice(cursor)
|
|
174
|
+
return out
|
|
175
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
176
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
177
|
+
.trim()
|
|
82
178
|
}
|
|
@@ -7,6 +7,7 @@ import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/
|
|
|
7
7
|
import { loadAgent } from '@/lib/server/agents/agent-repository'
|
|
8
8
|
import { clearMainLoopStateForSession } from '@/lib/server/agents/main-agent-loop'
|
|
9
9
|
import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
|
|
10
|
+
import { loadCredentials } from '@/lib/server/credentials/credential-repository'
|
|
10
11
|
import { cleanupSessionProcesses } from '@/lib/server/runtime/process-manager'
|
|
11
12
|
import { stopActiveSessionProcess } from '@/lib/server/runtime/runtime-state'
|
|
12
13
|
import {
|
|
@@ -235,6 +236,13 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
|
|
|
235
236
|
if (updates.credentialId !== undefined) session.credentialId = updates.credentialId
|
|
236
237
|
else if (agentIdUpdateProvided && linkedRoute) session.credentialId = linkedRoute.credentialId ?? null
|
|
237
238
|
else if (agentIdUpdateProvided && linkedAgent) session.credentialId = linkedAgent.credentialId ?? null
|
|
239
|
+
else if (updates.provider !== undefined && updates.provider !== session.provider) {
|
|
240
|
+
// Provider changed without an explicit credentialId — find a stored credential
|
|
241
|
+
// for the new provider so API-key-based providers (Groq, OpenAI, …) work.
|
|
242
|
+
const allCreds = loadCredentials()
|
|
243
|
+
const providerCred = Object.values(allCreds).find(c => c.provider === updates.provider)
|
|
244
|
+
session.credentialId = providerCred?.id ?? null
|
|
245
|
+
}
|
|
238
246
|
if (updates.fallbackCredentialIds !== undefined) session.fallbackCredentialIds = updates.fallbackCredentialIds
|
|
239
247
|
else if (agentIdUpdateProvided && linkedRoute) session.fallbackCredentialIds = [...linkedRoute.fallbackCredentialIds]
|
|
240
248
|
if (updates.gatewayProfileId !== undefined) session.gatewayProfileId = updates.gatewayProfileId
|
|
@@ -264,6 +272,9 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
|
|
|
264
272
|
session.apiEndpoint = linkedRoute.apiEndpoint ?? null
|
|
265
273
|
} else if (agentIdUpdateProvided && linkedAgent) {
|
|
266
274
|
session.apiEndpoint = normalizeProviderEndpoint(linkedAgent.provider, linkedAgent.apiEndpoint ?? null)
|
|
275
|
+
} else if (updates.provider !== undefined && updates.provider !== session.provider) {
|
|
276
|
+
// Provider changed — clear stale endpoint so the new provider uses its own default.
|
|
277
|
+
session.apiEndpoint = null
|
|
267
278
|
}
|
|
268
279
|
if (updates.heartbeatEnabled !== undefined) session.heartbeatEnabled = updates.heartbeatEnabled
|
|
269
280
|
if (updates.heartbeatIntervalSec !== undefined) session.heartbeatIntervalSec = updates.heartbeatIntervalSec
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { describe, it } from 'node:test'
|
|
6
|
+
import { buildAttachments } from './email'
|
|
7
|
+
import { connectorSupportsBinaryMedia } from './response-media'
|
|
8
|
+
|
|
9
|
+
describe('connectorSupportsBinaryMedia — email', () => {
|
|
10
|
+
it('marks email as supporting outbound binary media', () => {
|
|
11
|
+
assert.equal(connectorSupportsBinaryMedia('email'), true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('still returns false for platforms that do not support outbound binary', () => {
|
|
15
|
+
assert.equal(connectorSupportsBinaryMedia('signal'), false)
|
|
16
|
+
assert.equal(connectorSupportsBinaryMedia('matrix'), false)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('email buildAttachments', () => {
|
|
21
|
+
it('returns an empty array when no mediaPath is set', () => {
|
|
22
|
+
assert.deepEqual(buildAttachments(), [])
|
|
23
|
+
assert.deepEqual(buildAttachments({}), [])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns an empty array when mediaPath points at a missing file', () => {
|
|
27
|
+
const missing = path.join(os.tmpdir(), `swarmclaw-email-missing-${Date.now()}.bin`)
|
|
28
|
+
assert.equal(fs.existsSync(missing), false)
|
|
29
|
+
assert.deepEqual(buildAttachments({ mediaPath: missing }), [])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('builds a single attachment entry from mediaPath, defaulting the filename to basename', () => {
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-email-test-'))
|
|
34
|
+
const file = path.join(dir, 'report.pdf')
|
|
35
|
+
fs.writeFileSync(file, '%PDF-1.4 test')
|
|
36
|
+
try {
|
|
37
|
+
const attachments = buildAttachments({ mediaPath: file })
|
|
38
|
+
assert.equal(attachments.length, 1)
|
|
39
|
+
assert.equal(attachments[0].path, file)
|
|
40
|
+
assert.equal(attachments[0].filename, 'report.pdf')
|
|
41
|
+
assert.equal(attachments[0].contentType, undefined)
|
|
42
|
+
} finally {
|
|
43
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('respects explicit fileName and mimeType when provided', () => {
|
|
48
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-email-test-'))
|
|
49
|
+
const file = path.join(dir, 'raw.bin')
|
|
50
|
+
fs.writeFileSync(file, 'x')
|
|
51
|
+
try {
|
|
52
|
+
const attachments = buildAttachments({
|
|
53
|
+
mediaPath: file,
|
|
54
|
+
fileName: 'quarterly-report.pdf',
|
|
55
|
+
mimeType: 'application/pdf',
|
|
56
|
+
})
|
|
57
|
+
assert.equal(attachments.length, 1)
|
|
58
|
+
assert.equal(attachments[0].filename, 'quarterly-report.pdf')
|
|
59
|
+
assert.equal(attachments[0].contentType, 'application/pdf')
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
1
3
|
import { ImapFlow } from 'imapflow'
|
|
2
4
|
import { createTransport, type Transporter } from 'nodemailer'
|
|
3
5
|
import { simpleParser } from 'mailparser'
|
|
4
6
|
import type { Connector } from '@/types'
|
|
5
|
-
import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
|
|
7
|
+
import type { PlatformConnector, ConnectorInstance, InboundMessage, OutboundSendOptions } from './types'
|
|
6
8
|
import { resolveConnectorIngressReply } from './ingress-delivery'
|
|
7
9
|
import { errorMessage } from '@/lib/shared-utils'
|
|
8
10
|
import { log } from '@/lib/server/logger'
|
|
@@ -21,6 +23,26 @@ interface EmailConfig {
|
|
|
21
23
|
subjectPrefix?: string
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
interface MailAttachment {
|
|
27
|
+
path: string
|
|
28
|
+
filename: string
|
|
29
|
+
contentType?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildAttachments(options?: OutboundSendOptions): MailAttachment[] {
|
|
33
|
+
const source = options?.mediaPath
|
|
34
|
+
if (!source) return []
|
|
35
|
+
if (!fs.existsSync(source)) {
|
|
36
|
+
log.warn(TAG, `Attachment file not found: ${source}`)
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
return [{
|
|
40
|
+
path: source,
|
|
41
|
+
filename: options?.fileName || path.basename(source),
|
|
42
|
+
...(options?.mimeType ? { contentType: options.mimeType } : {}),
|
|
43
|
+
}]
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
function getConfig(connector: Connector): EmailConfig {
|
|
25
47
|
const c = connector.config as Record<string, unknown>
|
|
26
48
|
return {
|
|
@@ -206,7 +228,11 @@ const email: PlatformConnector = {
|
|
|
206
228
|
}
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
async function sendReply(
|
|
231
|
+
async function sendReply(
|
|
232
|
+
channelId: string,
|
|
233
|
+
text: string,
|
|
234
|
+
options?: OutboundSendOptions,
|
|
235
|
+
): Promise<void> {
|
|
210
236
|
const sender = senderMap.get(channelId)
|
|
211
237
|
const to = sender?.address || channelId
|
|
212
238
|
const subject = sender?.subject ? `Re: ${sender.subject.replace(/^Re:\s*/i, '')}` : 'Re: SwarmClaw'
|
|
@@ -215,7 +241,7 @@ const email: PlatformConnector = {
|
|
|
215
241
|
from: config.user,
|
|
216
242
|
to,
|
|
217
243
|
subject,
|
|
218
|
-
text,
|
|
244
|
+
text: options?.caption || text,
|
|
219
245
|
}
|
|
220
246
|
|
|
221
247
|
// Thread the reply using In-Reply-To header
|
|
@@ -224,8 +250,11 @@ const email: PlatformConnector = {
|
|
|
224
250
|
mailOptions['references'] = sender.messageId
|
|
225
251
|
}
|
|
226
252
|
|
|
253
|
+
const attachments = buildAttachments(options)
|
|
254
|
+
if (attachments.length > 0) mailOptions['attachments'] = attachments
|
|
255
|
+
|
|
227
256
|
await smtp.sendMail(mailOptions)
|
|
228
|
-
log.info(TAG, `Reply sent to ${to}`)
|
|
257
|
+
log.info(TAG, `Reply sent to ${to}${attachments.length ? ` with ${attachments.length} attachment(s)` : ''}`)
|
|
229
258
|
}
|
|
230
259
|
|
|
231
260
|
// Connect and start polling
|
|
@@ -247,8 +276,8 @@ const email: PlatformConnector = {
|
|
|
247
276
|
return connected && imap.usable
|
|
248
277
|
},
|
|
249
278
|
|
|
250
|
-
async sendMessage(channelId, text) {
|
|
251
|
-
await sendReply(channelId, text)
|
|
279
|
+
async sendMessage(channelId, text, options) {
|
|
280
|
+
await sendReply(channelId, text, options)
|
|
252
281
|
},
|
|
253
282
|
|
|
254
283
|
async stop() {
|
|
@@ -226,6 +226,7 @@ export function connectorSupportsBinaryMedia(platform: string): boolean {
|
|
|
226
226
|
|| platform === 'slack'
|
|
227
227
|
|| platform === 'discord'
|
|
228
228
|
|| platform === 'openclaw'
|
|
229
|
+
|| platform === 'email'
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
export function formatMediaLine(media: InboundMedia): string {
|
|
@@ -319,33 +319,45 @@ async function main(): Promise<void> {
|
|
|
319
319
|
})
|
|
320
320
|
}
|
|
321
321
|
process.on('uncaughtException', (err: Error) => {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
322
|
+
try {
|
|
323
|
+
patchDaemonStatusRecord((current) => ({
|
|
324
|
+
...current,
|
|
325
|
+
lastError: err.message,
|
|
326
|
+
updatedAt: Date.now(),
|
|
327
|
+
}))
|
|
328
|
+
} catch (patchErr) {
|
|
329
|
+
log.error(TAG, 'Failed to record uncaughtException in daemon status', patchErr)
|
|
330
|
+
}
|
|
327
331
|
void shutdown('uncaughtException').finally(() => process.exit(1))
|
|
328
332
|
})
|
|
329
333
|
process.on('unhandledRejection', (reason: unknown) => {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
334
|
+
try {
|
|
335
|
+
patchDaemonStatusRecord((current) => ({
|
|
336
|
+
...current,
|
|
337
|
+
lastError: reason instanceof Error ? reason.message : String(reason),
|
|
338
|
+
updatedAt: Date.now(),
|
|
339
|
+
}))
|
|
340
|
+
} catch (patchErr) {
|
|
341
|
+
log.error(TAG, 'Failed to record unhandledRejection in daemon status', patchErr)
|
|
342
|
+
}
|
|
335
343
|
void shutdown('unhandledRejection').finally(() => process.exit(1))
|
|
336
344
|
})
|
|
337
345
|
}
|
|
338
346
|
|
|
339
347
|
void main().catch((err: unknown) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
348
|
+
try {
|
|
349
|
+
patchDaemonStatusRecord((current) => ({
|
|
350
|
+
...current,
|
|
351
|
+
pid: null,
|
|
352
|
+
adminPort: null,
|
|
353
|
+
desiredState: 'stopped',
|
|
354
|
+
stoppedAt: Date.now(),
|
|
355
|
+
updatedAt: Date.now(),
|
|
356
|
+
lastError: err instanceof Error ? err.message : 'Daemon runtime failed to start',
|
|
357
|
+
}))
|
|
358
|
+
} catch (patchErr) {
|
|
359
|
+
log.error(TAG, 'Failed to record fatal daemon error in status', patchErr)
|
|
360
|
+
}
|
|
349
361
|
clearDaemonAdminMetadata()
|
|
350
362
|
log.error(TAG, 'Fatal daemon runtime error', err)
|
|
351
363
|
process.exit(1)
|
|
@@ -214,6 +214,14 @@ describe('memory-db', () => {
|
|
|
214
214
|
// All terms are <3 chars or stop words
|
|
215
215
|
assert.equal(query, '')
|
|
216
216
|
})
|
|
217
|
+
|
|
218
|
+
it('returns a single-term FTS query for short (3-4 char) words', () => {
|
|
219
|
+
// Single words like "cats", "blue", "dog" must produce a non-empty FTS
|
|
220
|
+
// query so the memory lookup UI works for short meaningful terms.
|
|
221
|
+
assert.equal(memDb.buildFtsQuery('cats'), '"cats"')
|
|
222
|
+
assert.equal(memDb.buildFtsQuery('blue'), '"blue"')
|
|
223
|
+
assert.equal(memDb.buildFtsQuery('dog'), '"dog"')
|
|
224
|
+
})
|
|
217
225
|
})
|
|
218
226
|
|
|
219
227
|
// --- Content hash dedup ---
|
|
@@ -388,7 +388,7 @@ export function buildFtsQuery(input: string): string {
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
if (unique.length === 1) {
|
|
391
|
-
return
|
|
391
|
+
return `"${unique[0].replace(/"/g, '')}"`
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
const selected = unique.slice(0, Math.min(4, MAX_FTS_QUERY_TERMS))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'crypto'
|
|
2
|
-
import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus } from '@/types'
|
|
2
|
+
import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus, MissionTemplate } from '@/types'
|
|
3
3
|
import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
|
|
4
4
|
import { hmrSingleton } from '@/lib/shared-utils'
|
|
5
5
|
import { log } from '@/lib/server/logger'
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
patchMission,
|
|
12
12
|
upsertMission,
|
|
13
13
|
} from './mission-repository'
|
|
14
|
+
import { getMissionTemplate } from './mission-templates'
|
|
14
15
|
|
|
15
16
|
const TAG = 'mission-service'
|
|
16
17
|
|
|
@@ -59,6 +60,7 @@ export interface CreateMissionInput {
|
|
|
59
60
|
budget?: Partial<MissionBudget>
|
|
60
61
|
reportSchedule?: MissionReportSchedule | null
|
|
61
62
|
reportConnectorIds?: string[]
|
|
63
|
+
templateId?: string | null
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
function newMissionId(): string {
|
|
@@ -82,6 +84,9 @@ function sanitizeBudget(input: Partial<MissionBudget> = {}): MissionBudget {
|
|
|
82
84
|
|
|
83
85
|
export function createMission(input: CreateMissionInput): Mission {
|
|
84
86
|
const now = Date.now()
|
|
87
|
+
const templateId = typeof input.templateId === 'string' && input.templateId.trim()
|
|
88
|
+
? input.templateId.trim().slice(0, 64)
|
|
89
|
+
: null
|
|
85
90
|
const mission: Mission = {
|
|
86
91
|
id: newMissionId(),
|
|
87
92
|
title: input.title.trim(),
|
|
@@ -106,12 +111,53 @@ export function createMission(input: CreateMissionInput): Mission {
|
|
|
106
111
|
reportConnectorIds: input.reportConnectorIds ?? [],
|
|
107
112
|
createdAt: now,
|
|
108
113
|
updatedAt: now,
|
|
114
|
+
templateId,
|
|
109
115
|
}
|
|
110
116
|
upsertMission(mission)
|
|
111
117
|
log.info(TAG, `Created mission ${mission.id} (goal: ${mission.goal.slice(0, 80)})`)
|
|
112
118
|
return mission
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
export interface CreateMissionFromTemplateInput {
|
|
122
|
+
templateId: string
|
|
123
|
+
rootSessionId: string
|
|
124
|
+
overrides?: {
|
|
125
|
+
title?: string
|
|
126
|
+
goal?: string
|
|
127
|
+
successCriteria?: string[]
|
|
128
|
+
budget?: Partial<MissionBudget>
|
|
129
|
+
reportSchedule?: MissionReportSchedule | null
|
|
130
|
+
agentIds?: string[]
|
|
131
|
+
reportConnectorIds?: string[]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface CreateMissionFromTemplateResult {
|
|
136
|
+
mission: Mission
|
|
137
|
+
template: MissionTemplate
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createMissionFromTemplate(input: CreateMissionFromTemplateInput): CreateMissionFromTemplateResult | null {
|
|
141
|
+
const template = getMissionTemplate(input.templateId)
|
|
142
|
+
if (!template) return null
|
|
143
|
+
const overrides = input.overrides ?? {}
|
|
144
|
+
const mergedBudget: Partial<MissionBudget> = { ...template.defaults.budget, ...(overrides.budget ?? {}) }
|
|
145
|
+
const mission = createMission({
|
|
146
|
+
title: overrides.title?.trim() || template.defaults.title,
|
|
147
|
+
goal: overrides.goal?.trim() || template.defaults.goal,
|
|
148
|
+
successCriteria: overrides.successCriteria ?? template.defaults.successCriteria,
|
|
149
|
+
rootSessionId: input.rootSessionId,
|
|
150
|
+
agentIds: overrides.agentIds ?? [],
|
|
151
|
+
budget: mergedBudget,
|
|
152
|
+
reportSchedule: overrides.reportSchedule === undefined
|
|
153
|
+
? template.defaults.reportSchedule
|
|
154
|
+
: overrides.reportSchedule,
|
|
155
|
+
reportConnectorIds: overrides.reportConnectorIds ?? [],
|
|
156
|
+
templateId: template.id,
|
|
157
|
+
})
|
|
158
|
+
return { mission, template }
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
function applyStatusTransition(
|
|
116
162
|
id: string,
|
|
117
163
|
next: MissionStatus,
|