@swarmclawai/swarmclaw 1.9.11 → 1.9.13
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 +19 -1
- package/package.json +3 -2
- package/src/app/api/quality/architecture-health/route.ts +16 -0
- package/src/app/api/quality/release-readiness/route.ts +6 -1
- package/src/app/home/page.tsx +1 -1
- package/src/cli/index.js +1 -0
- package/src/cli/index.ts +1 -1
- package/src/components/connectors/connector-sheet.tsx +36 -5
- package/src/components/quality/quality-workspace.tsx +155 -1
- package/src/components/shared/command-palette.tsx +1 -1
- package/src/components/shared/connector-platform-icon.test.ts +4 -0
- package/src/components/shared/connector-platform-icon.tsx +1 -0
- package/src/lib/connectors/connector-readiness.ts +17 -7
- package/src/lib/quality/architecture-health.test.ts +79 -0
- package/src/lib/quality/architecture-health.ts +451 -0
- package/src/lib/quality/release-readiness.test.ts +13 -0
- package/src/lib/quality/release-readiness.ts +36 -0
- package/src/lib/server/connectors/connector-lifecycle.ts +2 -1
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/connector-service.ts +1 -0
- package/src/lib/server/connectors/email.test.ts +1 -0
- package/src/lib/server/connectors/filequeue.test.ts +141 -0
- package/src/lib/server/connectors/filequeue.ts +324 -0
- package/src/lib/server/session-tools/crud.ts +1 -0
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/validation/schemas.ts +1 -1
- package/src/types/connector.ts +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
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 test from 'node:test'
|
|
6
|
+
|
|
7
|
+
import type { Connector } from '@/types'
|
|
8
|
+
import {
|
|
9
|
+
drainFileQueueOnce,
|
|
10
|
+
normalizeFileQueueEnvelope,
|
|
11
|
+
resolveFileQueuePaths,
|
|
12
|
+
writeFileQueueOutbound,
|
|
13
|
+
} from './filequeue'
|
|
14
|
+
|
|
15
|
+
function makeConnector(rootDir: string): Connector {
|
|
16
|
+
return {
|
|
17
|
+
id: 'filequeue-1',
|
|
18
|
+
name: 'Local Queue',
|
|
19
|
+
platform: 'filequeue',
|
|
20
|
+
agentId: 'agent-1',
|
|
21
|
+
chatroomId: null,
|
|
22
|
+
credentialId: null,
|
|
23
|
+
config: {
|
|
24
|
+
rootDir,
|
|
25
|
+
defaultSenderId: 'queue',
|
|
26
|
+
defaultSenderName: 'Queue',
|
|
27
|
+
defaultChannelId: 'ops',
|
|
28
|
+
},
|
|
29
|
+
isEnabled: true,
|
|
30
|
+
status: 'running',
|
|
31
|
+
createdAt: 1,
|
|
32
|
+
updatedAt: 1,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('normalizeFileQueueEnvelope maps command JSON into an inbound connector message', () => {
|
|
37
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
|
|
38
|
+
try {
|
|
39
|
+
const connector = makeConnector(rootDir)
|
|
40
|
+
const inbound = normalizeFileQueueEnvelope(connector, {
|
|
41
|
+
id: 'cmd-1',
|
|
42
|
+
channelId: 'ops',
|
|
43
|
+
senderId: 'jarvis',
|
|
44
|
+
senderName: 'JARVIS',
|
|
45
|
+
text: 'Summarize current status',
|
|
46
|
+
threadId: 'status-thread',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
assert.equal(inbound.platform, 'filequeue')
|
|
50
|
+
assert.equal(inbound.channelId, 'ops')
|
|
51
|
+
assert.equal(inbound.senderId, 'jarvis')
|
|
52
|
+
assert.equal(inbound.senderName, 'JARVIS')
|
|
53
|
+
assert.equal(inbound.messageId, 'cmd-1')
|
|
54
|
+
assert.equal(inbound.threadId, 'status-thread')
|
|
55
|
+
assert.equal(inbound.text, 'Summarize current status')
|
|
56
|
+
} finally {
|
|
57
|
+
fs.rmSync(rootDir, { recursive: true, force: true })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('drainFileQueueOnce archives processed commands and writes replies to outbox', async () => {
|
|
62
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
|
|
63
|
+
try {
|
|
64
|
+
const connector = makeConnector(rootDir)
|
|
65
|
+
const paths = resolveFileQueuePaths(connector)
|
|
66
|
+
fs.mkdirSync(paths.inboxDir, { recursive: true })
|
|
67
|
+
fs.writeFileSync(path.join(paths.inboxDir, '001.json'), JSON.stringify({
|
|
68
|
+
id: 'cmd-1',
|
|
69
|
+
senderId: 'jarvis',
|
|
70
|
+
senderName: 'JARVIS',
|
|
71
|
+
text: 'Run the release check',
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const result = await drainFileQueueOnce(connector, async (msg) => {
|
|
75
|
+
assert.equal(msg.channelId, 'ops')
|
|
76
|
+
assert.equal(msg.senderId, 'jarvis')
|
|
77
|
+
assert.equal(msg.text, 'Run the release check')
|
|
78
|
+
return 'Release check queued.'
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
assert.equal(result.processed, 1)
|
|
82
|
+
assert.equal(result.failed, 0)
|
|
83
|
+
assert.equal(fs.existsSync(path.join(paths.inboxDir, '001.json')), false)
|
|
84
|
+
assert.equal(fs.existsSync(path.join(paths.archiveDir, '001.json')), true)
|
|
85
|
+
|
|
86
|
+
const outboxFiles = fs.readdirSync(paths.outboxDir).filter((file) => file.endsWith('.json'))
|
|
87
|
+
assert.equal(outboxFiles.length, 1)
|
|
88
|
+
const outbound = JSON.parse(fs.readFileSync(path.join(paths.outboxDir, outboxFiles[0]), 'utf8')) as Record<string, unknown>
|
|
89
|
+
assert.equal(outbound.connectorId, connector.id)
|
|
90
|
+
assert.equal(outbound.channelId, 'ops')
|
|
91
|
+
assert.equal(outbound.text, 'Release check queued.')
|
|
92
|
+
assert.equal(outbound.replyToMessageId, 'cmd-1')
|
|
93
|
+
} finally {
|
|
94
|
+
fs.rmSync(rootDir, { recursive: true, force: true })
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('drainFileQueueOnce moves malformed JSON into the error directory', async () => {
|
|
99
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
|
|
100
|
+
try {
|
|
101
|
+
const connector = makeConnector(rootDir)
|
|
102
|
+
const paths = resolveFileQueuePaths(connector)
|
|
103
|
+
fs.mkdirSync(paths.inboxDir, { recursive: true })
|
|
104
|
+
fs.writeFileSync(path.join(paths.inboxDir, 'broken.json'), '{bad-json')
|
|
105
|
+
|
|
106
|
+
const result = await drainFileQueueOnce(connector, async () => {
|
|
107
|
+
throw new Error('should not route malformed envelopes')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
assert.equal(result.processed, 0)
|
|
111
|
+
assert.equal(result.failed, 1)
|
|
112
|
+
assert.equal(fs.existsSync(path.join(paths.inboxDir, 'broken.json')), false)
|
|
113
|
+
assert.equal(fs.existsSync(path.join(paths.errorDir, 'broken.json')), true)
|
|
114
|
+
assert.equal(fs.existsSync(path.join(paths.errorDir, 'broken.json.error.txt')), true)
|
|
115
|
+
} finally {
|
|
116
|
+
fs.rmSync(rootDir, { recursive: true, force: true })
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('writeFileQueueOutbound stores structured command output in the outbox', async () => {
|
|
121
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
|
|
122
|
+
try {
|
|
123
|
+
const connector = makeConnector(rootDir)
|
|
124
|
+
const written = await writeFileQueueOutbound(connector, {
|
|
125
|
+
channelId: 'ops',
|
|
126
|
+
text: 'Done',
|
|
127
|
+
threadId: 'status-thread',
|
|
128
|
+
replyToMessageId: 'cmd-1',
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const payload = JSON.parse(fs.readFileSync(written.path, 'utf8')) as Record<string, unknown>
|
|
132
|
+
assert.equal(payload.kind, 'swarmclaw.filequeue.outbound')
|
|
133
|
+
assert.equal(payload.connectorId, connector.id)
|
|
134
|
+
assert.equal(payload.channelId, 'ops')
|
|
135
|
+
assert.equal(payload.text, 'Done')
|
|
136
|
+
assert.equal(payload.threadId, 'status-thread')
|
|
137
|
+
assert.equal(payload.replyToMessageId, 'cmd-1')
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(rootDir, { recursive: true, force: true })
|
|
140
|
+
}
|
|
141
|
+
})
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { genId } from '@/lib/id'
|
|
7
|
+
import { CONNECTORS_DATA_DIR } from '@/lib/server/data-dir'
|
|
8
|
+
import { log } from '@/lib/server/logger'
|
|
9
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
10
|
+
import type { Connector } from '@/types'
|
|
11
|
+
import type {
|
|
12
|
+
ConnectorIngressResult,
|
|
13
|
+
ConnectorInstance,
|
|
14
|
+
InboundMessage,
|
|
15
|
+
OutboundSendOptions,
|
|
16
|
+
PlatformConnector,
|
|
17
|
+
} from './types'
|
|
18
|
+
import { resolveConnectorIngressReply } from './ingress-delivery'
|
|
19
|
+
|
|
20
|
+
const TAG = 'filequeue'
|
|
21
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000
|
|
22
|
+
const MIN_POLL_INTERVAL_MS = 250
|
|
23
|
+
|
|
24
|
+
export interface FileQueuePaths {
|
|
25
|
+
rootDir: string
|
|
26
|
+
inboxDir: string
|
|
27
|
+
outboxDir: string
|
|
28
|
+
archiveDir: string
|
|
29
|
+
errorDir: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FileQueueDrainResult {
|
|
33
|
+
processed: number
|
|
34
|
+
failed: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FileQueueOutboundInput {
|
|
38
|
+
channelId: string
|
|
39
|
+
text: string
|
|
40
|
+
threadId?: string
|
|
41
|
+
replyToMessageId?: string
|
|
42
|
+
options?: OutboundSendOptions
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
46
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
47
|
+
? value as Record<string, unknown>
|
|
48
|
+
: null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clean(value: unknown): string {
|
|
52
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function expandHome(input: string): string {
|
|
56
|
+
if (input === '~') return os.homedir()
|
|
57
|
+
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
|
58
|
+
return path.join(os.homedir(), input.slice(2))
|
|
59
|
+
}
|
|
60
|
+
return input
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveConfiguredPath(rootDir: string, configured: unknown, fallback: string): string {
|
|
64
|
+
const value = clean(configured)
|
|
65
|
+
if (!value) return path.join(rootDir, fallback)
|
|
66
|
+
const expanded = expandHome(value)
|
|
67
|
+
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(rootDir, expanded)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parsePollIntervalMs(value: unknown): number {
|
|
71
|
+
const parsed = typeof value === 'number'
|
|
72
|
+
? value
|
|
73
|
+
: (typeof value === 'string' && value.trim() ? Number.parseInt(value.trim(), 10) : DEFAULT_POLL_INTERVAL_MS)
|
|
74
|
+
if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_MS
|
|
75
|
+
return Math.max(MIN_POLL_INTERVAL_MS, parsed)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ensureFileQueueDirs(paths: FileQueuePaths): void {
|
|
79
|
+
for (const dir of [paths.rootDir, paths.inboxDir, paths.outboxDir, paths.archiveDir, paths.errorDir]) {
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveFileQueuePaths(connector: Connector): FileQueuePaths {
|
|
85
|
+
const config = connector.config || {}
|
|
86
|
+
const configuredRoot = clean(config.rootDir)
|
|
87
|
+
const rootDir = configuredRoot
|
|
88
|
+
? path.resolve(expandHome(configuredRoot))
|
|
89
|
+
: path.join(CONNECTORS_DATA_DIR, connector.id, 'filequeue')
|
|
90
|
+
return {
|
|
91
|
+
rootDir,
|
|
92
|
+
inboxDir: resolveConfiguredPath(rootDir, config.inboxDir, 'inbox'),
|
|
93
|
+
outboxDir: resolveConfiguredPath(rootDir, config.outboxDir, 'outbox'),
|
|
94
|
+
archiveDir: resolveConfiguredPath(rootDir, config.archiveDir, 'archive'),
|
|
95
|
+
errorDir: resolveConfiguredPath(rootDir, config.errorDir, 'errors'),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function firstText(...values: unknown[]): string {
|
|
100
|
+
for (const value of values) {
|
|
101
|
+
const text = clean(value)
|
|
102
|
+
if (text) return text
|
|
103
|
+
}
|
|
104
|
+
return ''
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fileNameSafe(value: string): string {
|
|
108
|
+
return value
|
|
109
|
+
.split('')
|
|
110
|
+
.map((char) => {
|
|
111
|
+
const code = char.charCodeAt(0)
|
|
112
|
+
const isDigit = code >= 48 && code <= 57
|
|
113
|
+
const isUpper = code >= 65 && code <= 90
|
|
114
|
+
const isLower = code >= 97 && code <= 122
|
|
115
|
+
return isDigit || isUpper || isLower || char === '-' || char === '_' ? char : '_'
|
|
116
|
+
})
|
|
117
|
+
.join('')
|
|
118
|
+
.slice(0, 96) || 'item'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveUniquePath(dir: string, basename: string): string {
|
|
122
|
+
const ext = path.extname(basename)
|
|
123
|
+
const stem = path.basename(basename, ext)
|
|
124
|
+
let candidate = path.join(dir, basename)
|
|
125
|
+
if (!fs.existsSync(candidate)) return candidate
|
|
126
|
+
for (let i = 1; i < 1_000; i += 1) {
|
|
127
|
+
candidate = path.join(dir, `${stem}-${i}${ext}`)
|
|
128
|
+
if (!fs.existsSync(candidate)) return candidate
|
|
129
|
+
}
|
|
130
|
+
return path.join(dir, `${stem}-${Date.now()}-${genId()}${ext}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function moveFile(source: string, targetDir: string): Promise<string> {
|
|
134
|
+
await fsp.mkdir(targetDir, { recursive: true })
|
|
135
|
+
const target = resolveUniquePath(targetDir, path.basename(source))
|
|
136
|
+
try {
|
|
137
|
+
await fsp.rename(source, target)
|
|
138
|
+
} catch (err: unknown) {
|
|
139
|
+
const code = typeof err === 'object' && err && 'code' in err ? String((err as { code?: unknown }).code) : ''
|
|
140
|
+
if (code !== 'EXDEV') throw err
|
|
141
|
+
await fsp.copyFile(source, target)
|
|
142
|
+
await fsp.unlink(source)
|
|
143
|
+
}
|
|
144
|
+
return target
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readNestedEnvelope(record: Record<string, unknown>): Record<string, unknown> | null {
|
|
148
|
+
return asRecord(record.payload)
|
|
149
|
+
|| asRecord(record.command)
|
|
150
|
+
|| asRecord(record.message)
|
|
151
|
+
|| asRecord(record.data)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function normalizeFileQueueEnvelope(connector: Connector, envelope: unknown): InboundMessage {
|
|
155
|
+
const record = asRecord(envelope)
|
|
156
|
+
if (!record) throw new Error('File queue envelope must be a JSON object')
|
|
157
|
+
const nested = readNestedEnvelope(record)
|
|
158
|
+
const sender = asRecord(record.sender) || asRecord(nested?.sender)
|
|
159
|
+
const config = connector.config || {}
|
|
160
|
+
const id = firstText(record.id, record.messageId, record.commandId, nested?.id, nested?.messageId, genId())
|
|
161
|
+
const text = firstText(
|
|
162
|
+
record.text,
|
|
163
|
+
record.body,
|
|
164
|
+
record.prompt,
|
|
165
|
+
typeof record.command === 'string' ? record.command : '',
|
|
166
|
+
nested?.text,
|
|
167
|
+
nested?.body,
|
|
168
|
+
nested?.prompt,
|
|
169
|
+
typeof nested?.command === 'string' ? nested.command : '',
|
|
170
|
+
)
|
|
171
|
+
if (!text) throw new Error('File queue envelope requires text, body, prompt, or command')
|
|
172
|
+
|
|
173
|
+
const channelId = firstText(record.channelId, record.channel, nested?.channelId, nested?.channel, config.defaultChannelId, 'filequeue')
|
|
174
|
+
const senderId = firstText(record.senderId, sender?.id, sender?.senderId, nested?.senderId, config.defaultSenderId, 'filequeue')
|
|
175
|
+
const senderName = firstText(record.senderName, sender?.name, sender?.senderName, nested?.senderName, config.defaultSenderName, senderId)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
platform: 'filequeue',
|
|
179
|
+
channelId,
|
|
180
|
+
channelName: firstText(record.channelName, nested?.channelName, channelId),
|
|
181
|
+
senderId,
|
|
182
|
+
senderName,
|
|
183
|
+
text,
|
|
184
|
+
messageId: id,
|
|
185
|
+
replyToMessageId: firstText(record.replyToMessageId, nested?.replyToMessageId) || undefined,
|
|
186
|
+
threadId: firstText(record.threadId, nested?.threadId) || undefined,
|
|
187
|
+
isGroup: record.isGroup === true || nested?.isGroup === true,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function writeFileQueueOutbound(
|
|
192
|
+
connector: Connector,
|
|
193
|
+
input: FileQueueOutboundInput,
|
|
194
|
+
): Promise<{ id: string; path: string; payload: Record<string, unknown> }> {
|
|
195
|
+
const paths = resolveFileQueuePaths(connector)
|
|
196
|
+
ensureFileQueueDirs(paths)
|
|
197
|
+
const id = genId()
|
|
198
|
+
const payload: Record<string, unknown> = {
|
|
199
|
+
id,
|
|
200
|
+
kind: 'swarmclaw.filequeue.outbound',
|
|
201
|
+
connectorId: connector.id,
|
|
202
|
+
connectorName: connector.name,
|
|
203
|
+
platform: 'filequeue',
|
|
204
|
+
channelId: input.channelId,
|
|
205
|
+
text: input.text,
|
|
206
|
+
createdAt: new Date().toISOString(),
|
|
207
|
+
}
|
|
208
|
+
if (input.threadId) payload.threadId = input.threadId
|
|
209
|
+
if (input.replyToMessageId) payload.replyToMessageId = input.replyToMessageId
|
|
210
|
+
if (input.options?.imageUrl) payload.imageUrl = input.options.imageUrl
|
|
211
|
+
if (input.options?.fileUrl) payload.fileUrl = input.options.fileUrl
|
|
212
|
+
if (input.options?.mediaPath) payload.mediaPath = input.options.mediaPath
|
|
213
|
+
if (input.options?.mimeType) payload.mimeType = input.options.mimeType
|
|
214
|
+
if (input.options?.fileName) payload.fileName = input.options.fileName
|
|
215
|
+
if (input.options?.caption) payload.caption = input.options.caption
|
|
216
|
+
|
|
217
|
+
const filename = `${Date.now()}-${fileNameSafe(id)}.json`
|
|
218
|
+
const outputPath = resolveUniquePath(paths.outboxDir, filename)
|
|
219
|
+
await fsp.writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
220
|
+
return { id, path: outputPath, payload }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function drainFileQueueOnce(
|
|
224
|
+
connector: Connector,
|
|
225
|
+
onMessage: (msg: InboundMessage) => Promise<ConnectorIngressResult>,
|
|
226
|
+
): Promise<FileQueueDrainResult> {
|
|
227
|
+
const paths = resolveFileQueuePaths(connector)
|
|
228
|
+
ensureFileQueueDirs(paths)
|
|
229
|
+
const entries = await fsp.readdir(paths.inboxDir, { withFileTypes: true })
|
|
230
|
+
const files = entries
|
|
231
|
+
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.json')
|
|
232
|
+
.map((entry) => entry.name)
|
|
233
|
+
.sort((a, b) => a.localeCompare(b))
|
|
234
|
+
let processed = 0
|
|
235
|
+
let failed = 0
|
|
236
|
+
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
const source = path.join(paths.inboxDir, file)
|
|
239
|
+
try {
|
|
240
|
+
const raw = await fsp.readFile(source, 'utf8')
|
|
241
|
+
const envelope = JSON.parse(raw) as unknown
|
|
242
|
+
const inbound = normalizeFileQueueEnvelope(connector, envelope)
|
|
243
|
+
const reply = await resolveConnectorIngressReply(onMessage, inbound)
|
|
244
|
+
if (reply) {
|
|
245
|
+
await writeFileQueueOutbound(connector, {
|
|
246
|
+
channelId: inbound.channelId,
|
|
247
|
+
text: reply.visibleText,
|
|
248
|
+
threadId: inbound.threadId,
|
|
249
|
+
replyToMessageId: inbound.messageId,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
await moveFile(source, paths.archiveDir)
|
|
253
|
+
processed += 1
|
|
254
|
+
} catch (err: unknown) {
|
|
255
|
+
failed += 1
|
|
256
|
+
const message = errorMessage(err)
|
|
257
|
+
log.warn(TAG, `Failed to process file queue command ${file}: ${message}`)
|
|
258
|
+
try {
|
|
259
|
+
const moved = fs.existsSync(source)
|
|
260
|
+
? await moveFile(source, paths.errorDir)
|
|
261
|
+
: path.join(paths.errorDir, file)
|
|
262
|
+
await fsp.writeFile(`${moved}.error.txt`, `${message}\n`, 'utf8')
|
|
263
|
+
} catch (moveErr: unknown) {
|
|
264
|
+
log.warn(TAG, `Failed to move malformed file queue command ${file}: ${errorMessage(moveErr)}`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { processed, failed }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const fileQueue: PlatformConnector = {
|
|
273
|
+
async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
|
|
274
|
+
const paths = resolveFileQueuePaths(connector)
|
|
275
|
+
ensureFileQueueDirs(paths)
|
|
276
|
+
let stopped = false
|
|
277
|
+
let draining = false
|
|
278
|
+
const pollIntervalMs = parsePollIntervalMs(connector.config?.pollIntervalMs)
|
|
279
|
+
|
|
280
|
+
const drain = async () => {
|
|
281
|
+
if (stopped || draining) return
|
|
282
|
+
draining = true
|
|
283
|
+
try {
|
|
284
|
+
const result = await drainFileQueueOnce(connector, onMessage)
|
|
285
|
+
if (result.processed || result.failed) {
|
|
286
|
+
log.info(TAG, `File queue drain for ${connector.name}: ${result.processed} processed, ${result.failed} failed`)
|
|
287
|
+
}
|
|
288
|
+
} catch (err: unknown) {
|
|
289
|
+
log.warn(TAG, `File queue drain failed for ${connector.name}: ${errorMessage(err)}`)
|
|
290
|
+
} finally {
|
|
291
|
+
draining = false
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
void drain()
|
|
296
|
+
const timer = setInterval(() => {
|
|
297
|
+
void drain()
|
|
298
|
+
}, pollIntervalMs)
|
|
299
|
+
timer.unref?.()
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
connector,
|
|
303
|
+
authenticated: true,
|
|
304
|
+
supportsBinaryMedia: false,
|
|
305
|
+
stop: async () => {
|
|
306
|
+
stopped = true
|
|
307
|
+
clearInterval(timer)
|
|
308
|
+
},
|
|
309
|
+
isAlive: () => !stopped,
|
|
310
|
+
sendMessage: async (channelId: string, text: string, options?: OutboundSendOptions) => {
|
|
311
|
+
const written = await writeFileQueueOutbound(connector, {
|
|
312
|
+
channelId,
|
|
313
|
+
text,
|
|
314
|
+
threadId: options?.threadId,
|
|
315
|
+
replyToMessageId: options?.replyToMessageId,
|
|
316
|
+
options,
|
|
317
|
+
})
|
|
318
|
+
return { messageId: written.id }
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export default fileQueue
|
|
@@ -698,7 +698,7 @@ if (!IS_BUILD_BOOTSTRAP) {
|
|
|
698
698
|
- **Tasks** — The Task Board tracks work items. Assign agents and they'll execute autonomously.
|
|
699
699
|
- **Schedules** — Cron-based recurring jobs that run agents or tasks automatically.
|
|
700
700
|
- **Skills** — Reusable markdown instruction files agents can discover and use by default; pin them to keep favorite workflows always-on.
|
|
701
|
-
- **Connectors** — Bridge agents to
|
|
701
|
+
- **Connectors** — Bridge agents to chat platforms, local file queues, and agent channels.
|
|
702
702
|
- **Secrets** — Encrypted vault for API keys (Settings → Secrets).
|
|
703
703
|
|
|
704
704
|
## Tools
|
|
@@ -161,7 +161,7 @@ export const ConnectorCreateSchema = z.object({
|
|
|
161
161
|
name: z.string().min(1, 'Connector name is required').optional(),
|
|
162
162
|
platform: z.enum([
|
|
163
163
|
'discord', 'telegram', 'slack', 'whatsapp', 'openclaw',
|
|
164
|
-
'bluebubbles', 'signal', 'teams', 'googlechat', 'matrix', 'email', 'swarmdock',
|
|
164
|
+
'bluebubbles', 'signal', 'teams', 'googlechat', 'matrix', 'email', 'filequeue', 'swarmdock',
|
|
165
165
|
]),
|
|
166
166
|
agentId: z.string().nullable().optional().default(null),
|
|
167
167
|
chatroomId: z.string().nullable().optional().default(null),
|