@swarmclawai/swarmclaw 0.7.4 → 0.7.6
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 +32 -9
- package/package.json +2 -2
- package/src/app/api/agents/[id]/thread/route.ts +4 -89
- package/src/app/api/openclaw/deploy/route.ts +101 -0
- package/src/cli/index.js +13 -0
- package/src/cli/index.test.js +34 -0
- package/src/cli/spec.js +19 -0
- package/src/components/auth/setup-wizard.tsx +36 -52
- package/src/components/gateways/gateway-sheet.tsx +63 -3
- package/src/components/openclaw/openclaw-deploy-panel.tsx +626 -0
- package/src/components/providers/provider-list.tsx +103 -8
- package/src/lib/server/agent-thread-session.test.ts +85 -0
- package/src/lib/server/agent-thread-session.ts +123 -0
- package/src/lib/server/data-dir.test.ts +56 -0
- package/src/lib/server/data-dir.ts +15 -9
- package/src/lib/server/heartbeat-service.ts +18 -5
- package/src/lib/server/heartbeat-wake.ts +6 -2
- package/src/lib/server/openclaw-deploy.test.ts +67 -0
- package/src/lib/server/openclaw-deploy.ts +724 -0
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
|
|
4
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
7
|
import { useWs } from '@/hooks/use-ws'
|
|
6
8
|
import { api } from '@/lib/api-client'
|
|
9
|
+
import type { Credential } from '@/types'
|
|
10
|
+
|
|
11
|
+
interface OpenClawDeployDraft {
|
|
12
|
+
endpoint: string
|
|
13
|
+
token?: string
|
|
14
|
+
name?: string
|
|
15
|
+
notes?: string
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
9
19
|
const providers = useAppStore((s) => s.providers)
|
|
@@ -21,6 +31,8 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
21
31
|
const setGatewaySheetOpen = useAppStore((s) => s.setGatewaySheetOpen)
|
|
22
32
|
const setEditingGatewayId = useAppStore((s) => s.setEditingGatewayId)
|
|
23
33
|
const [loaded, setLoaded] = useState(false)
|
|
34
|
+
const [deployDraft, setDeployDraft] = useState<OpenClawDeployDraft | null>(null)
|
|
35
|
+
const [savingDeploy, setSavingDeploy] = useState(false)
|
|
24
36
|
|
|
25
37
|
const refresh = useCallback(async () => {
|
|
26
38
|
await Promise.all([loadProviders(), loadProviderConfigs(), loadGatewayProfiles(), loadExternalAgents(), loadCredentials()])
|
|
@@ -66,6 +78,57 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
66
78
|
await loadGatewayProfiles()
|
|
67
79
|
}
|
|
68
80
|
|
|
81
|
+
const handleDeployApply = (patch: { endpoint?: string; token?: string; name?: string; notes?: string }) => {
|
|
82
|
+
if (!patch.endpoint) return
|
|
83
|
+
setDeployDraft({
|
|
84
|
+
endpoint: patch.endpoint,
|
|
85
|
+
token: patch.token,
|
|
86
|
+
name: patch.name,
|
|
87
|
+
notes: patch.notes,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handleSavePreparedGateway = async () => {
|
|
92
|
+
if (!deployDraft?.endpoint) return
|
|
93
|
+
setSavingDeploy(true)
|
|
94
|
+
try {
|
|
95
|
+
let nextCredentialId: string | null = null
|
|
96
|
+
if (deployDraft.token?.trim()) {
|
|
97
|
+
const credential = await api<Credential>('POST', '/credentials', {
|
|
98
|
+
provider: 'openclaw',
|
|
99
|
+
name: `${deployDraft.name || 'OpenClaw Gateway'} token`,
|
|
100
|
+
apiKey: deployDraft.token.trim(),
|
|
101
|
+
})
|
|
102
|
+
nextCredentialId = credential.id
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const existing = gatewayProfiles.find((gateway) => gateway.endpoint === deployDraft.endpoint) || null
|
|
106
|
+
const nextTags = Array.from(new Set([...(existing?.tags || []), 'managed-deploy']))
|
|
107
|
+
const payload = {
|
|
108
|
+
name: deployDraft.name || existing?.name || 'OpenClaw Gateway',
|
|
109
|
+
endpoint: deployDraft.endpoint,
|
|
110
|
+
credentialId: nextCredentialId || existing?.credentialId || null,
|
|
111
|
+
notes: deployDraft.notes || existing?.notes || 'Managed OpenClaw deploy prepared from SwarmClaw.',
|
|
112
|
+
tags: nextTags,
|
|
113
|
+
isDefault: existing?.isDefault === true || gatewayProfiles.length === 0,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (existing) {
|
|
117
|
+
await api('PUT', `/gateways/${existing.id}`, payload)
|
|
118
|
+
} else {
|
|
119
|
+
await api('POST', '/gateways', payload)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await Promise.all([loadGatewayProfiles(), loadCredentials()])
|
|
123
|
+
setDeployDraft(null)
|
|
124
|
+
toast.success(existing ? 'Gateway profile updated' : 'Gateway profile saved')
|
|
125
|
+
} catch (err: unknown) {
|
|
126
|
+
toast.error(err instanceof Error ? err.message : 'Failed to save prepared gateway')
|
|
127
|
+
} finally {
|
|
128
|
+
setSavingDeploy(false)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
69
132
|
// Merge built-in providers with custom configs
|
|
70
133
|
const builtinItems = providers.map((p) => ({
|
|
71
134
|
id: p.id,
|
|
@@ -180,15 +243,47 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
180
243
|
<div className="mt-8 mb-4 flex items-center justify-between">
|
|
181
244
|
<div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div>
|
|
182
245
|
{!inSidebar && (
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
246
|
+
<div className="flex items-center gap-2">
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={() => handleEditGateway(null)}
|
|
250
|
+
className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer"
|
|
251
|
+
>
|
|
252
|
+
+ New Gateway
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
190
255
|
)}
|
|
191
256
|
</div>
|
|
257
|
+
{!inSidebar && (
|
|
258
|
+
<div className="mb-4 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
|
|
259
|
+
<OpenClawDeployPanel
|
|
260
|
+
compact
|
|
261
|
+
title="Deploy OpenClaw Control Planes"
|
|
262
|
+
description="Use official OpenClaw sources only. Start a local control plane on this machine, or generate a pre-configured remote bundle for Docker VPS hosts like Hetzner, DigitalOcean, Vultr, Linode, Lightsail, plus Render, Fly.io, and Railway."
|
|
263
|
+
onApply={handleDeployApply}
|
|
264
|
+
/>
|
|
265
|
+
{deployDraft?.endpoint && (
|
|
266
|
+
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-[12px] border border-emerald-500/20 bg-emerald-500/[0.05] px-4 py-3">
|
|
267
|
+
<div>
|
|
268
|
+
<div className="text-[13px] font-700 text-emerald-300">Prepared gateway profile</div>
|
|
269
|
+
<div className="mt-1 text-[12px] text-text-3">
|
|
270
|
+
{deployDraft.name || 'OpenClaw Gateway'} · <code className="text-text-2">{deployDraft.endpoint}</code>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="flex flex-wrap gap-2">
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
onClick={() => void handleSavePreparedGateway()}
|
|
277
|
+
disabled={savingDeploy}
|
|
278
|
+
className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-700 text-white border-none cursor-pointer hover:brightness-110 transition-all disabled:opacity-40"
|
|
279
|
+
>
|
|
280
|
+
{savingDeploy ? 'Saving…' : 'Save Prepared Gateway'}
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
192
287
|
<div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
|
|
193
288
|
{gatewayProfiles.map((gateway, idx) => (
|
|
194
289
|
<div
|
|
@@ -246,7 +341,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
246
341
|
))}
|
|
247
342
|
{gatewayProfiles.length === 0 && (
|
|
248
343
|
<div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70">
|
|
249
|
-
No gateway profiles yet.
|
|
344
|
+
No gateway profiles yet. Use Smart Deploy above for a local runtime, a Docker VPS bundle, or a hosted OpenClaw deployment profile.
|
|
250
345
|
</div>
|
|
251
346
|
)}
|
|
252
347
|
</div>
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
|
|
9
|
+
|
|
10
|
+
function runWithTempDataDir(script: string) {
|
|
11
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-agent-thread-'))
|
|
12
|
+
try {
|
|
13
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
DATA_DIR: path.join(tempDir, 'data'),
|
|
18
|
+
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
19
|
+
BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
|
|
20
|
+
},
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
})
|
|
23
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
24
|
+
const lines = (result.stdout || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => line.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
30
|
+
return JSON.parse(jsonLine || '{}')
|
|
31
|
+
} finally {
|
|
32
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('ensureAgentThreadSession', () => {
|
|
37
|
+
it('creates and reuses an agent shortcut chat for heartbeat-enabled agents', () => {
|
|
38
|
+
const output = runWithTempDataDir(`
|
|
39
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
40
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
41
|
+
const helperMod = await import('./src/lib/server/agent-thread-session.ts')
|
|
42
|
+
const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
|
|
43
|
+
|| helperMod.default?.ensureAgentThreadSession
|
|
44
|
+
|| helperMod['module.exports']?.ensureAgentThreadSession
|
|
45
|
+
|
|
46
|
+
const now = Date.now()
|
|
47
|
+
storage.saveAgents({
|
|
48
|
+
molly: {
|
|
49
|
+
id: 'molly',
|
|
50
|
+
name: 'Molly',
|
|
51
|
+
description: 'Autonomous helper',
|
|
52
|
+
provider: 'openai',
|
|
53
|
+
model: 'gpt-test',
|
|
54
|
+
credentialId: null,
|
|
55
|
+
apiEndpoint: null,
|
|
56
|
+
fallbackCredentialIds: [],
|
|
57
|
+
heartbeatEnabled: true,
|
|
58
|
+
heartbeatIntervalSec: 600,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
updatedAt: now,
|
|
61
|
+
plugins: ['memory', 'web_search'],
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const first = ensureAgentThreadSession('molly')
|
|
66
|
+
const second = ensureAgentThreadSession('molly')
|
|
67
|
+
const agents = storage.loadAgents()
|
|
68
|
+
const sessions = storage.loadSessions()
|
|
69
|
+
|
|
70
|
+
console.log(JSON.stringify({
|
|
71
|
+
firstId: first?.id,
|
|
72
|
+
secondId: second?.id,
|
|
73
|
+
threadSessionId: agents.molly?.threadSessionId || null,
|
|
74
|
+
session: first ? sessions[first.id] : null,
|
|
75
|
+
}))
|
|
76
|
+
`)
|
|
77
|
+
|
|
78
|
+
assert.equal(output.firstId, output.secondId)
|
|
79
|
+
assert.equal(output.threadSessionId, output.firstId)
|
|
80
|
+
assert.equal(output.session.shortcutForAgentId, 'molly')
|
|
81
|
+
assert.equal(output.session.agentId, 'molly')
|
|
82
|
+
assert.equal(output.session.heartbeatEnabled, true)
|
|
83
|
+
assert.deepEqual(output.session.plugins, ['memory', 'web_search'])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { genId } from '@/lib/id'
|
|
2
|
+
import type { Agent, Session } from '@/types'
|
|
3
|
+
import { applyResolvedRoute, resolvePrimaryAgentRoute } from './agent-runtime-config'
|
|
4
|
+
import { WORKSPACE_DIR } from './data-dir'
|
|
5
|
+
import { loadAgents, loadSessions, saveAgents, saveSessions } from './storage'
|
|
6
|
+
|
|
7
|
+
function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
|
|
8
|
+
return {
|
|
9
|
+
claudeCode: null,
|
|
10
|
+
codex: null,
|
|
11
|
+
opencode: null,
|
|
12
|
+
gemini: null,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildThreadSession(agent: Agent, sessionId: string, user: string, createdAt: number, existing?: Session): Session {
|
|
17
|
+
const baseSession: Session = {
|
|
18
|
+
id: sessionId,
|
|
19
|
+
name: agent.name,
|
|
20
|
+
shortcutForAgentId: agent.id,
|
|
21
|
+
cwd: existing?.cwd || WORKSPACE_DIR,
|
|
22
|
+
user: existing?.user || user,
|
|
23
|
+
provider: agent.provider,
|
|
24
|
+
model: agent.model,
|
|
25
|
+
credentialId: agent.credentialId || null,
|
|
26
|
+
fallbackCredentialIds: agent.fallbackCredentialIds || [],
|
|
27
|
+
apiEndpoint: agent.apiEndpoint || null,
|
|
28
|
+
gatewayProfileId: agent.gatewayProfileId || null,
|
|
29
|
+
claudeSessionId: existing?.claudeSessionId || null,
|
|
30
|
+
codexThreadId: existing?.codexThreadId || null,
|
|
31
|
+
opencodeSessionId: existing?.opencodeSessionId || null,
|
|
32
|
+
delegateResumeIds: existing?.delegateResumeIds || buildEmptyDelegateResumeIds(),
|
|
33
|
+
messages: Array.isArray(existing?.messages) ? existing.messages : [],
|
|
34
|
+
createdAt: existing?.createdAt || createdAt,
|
|
35
|
+
lastActiveAt: createdAt,
|
|
36
|
+
active: existing?.active || false,
|
|
37
|
+
sessionType: existing?.sessionType || 'human',
|
|
38
|
+
agentId: agent.id,
|
|
39
|
+
parentSessionId: existing?.parentSessionId || null,
|
|
40
|
+
plugins: agent.plugins || agent.tools || [],
|
|
41
|
+
tools: agent.plugins || agent.tools || [],
|
|
42
|
+
heartbeatEnabled: agent.heartbeatEnabled || false,
|
|
43
|
+
heartbeatIntervalSec: agent.heartbeatIntervalSec || null,
|
|
44
|
+
heartbeatTarget: existing?.heartbeatTarget || null,
|
|
45
|
+
sessionResetMode: existing?.sessionResetMode || null,
|
|
46
|
+
sessionIdleTimeoutSec: existing?.sessionIdleTimeoutSec || null,
|
|
47
|
+
sessionMaxAgeSec: existing?.sessionMaxAgeSec || null,
|
|
48
|
+
sessionDailyResetAt: existing?.sessionDailyResetAt || null,
|
|
49
|
+
sessionResetTimezone: existing?.sessionResetTimezone || null,
|
|
50
|
+
thinkingLevel: existing?.thinkingLevel || null,
|
|
51
|
+
browserProfileId: existing?.browserProfileId || null,
|
|
52
|
+
connectorThinkLevel: existing?.connectorThinkLevel || null,
|
|
53
|
+
connectorSessionScope: existing?.connectorSessionScope || null,
|
|
54
|
+
connectorReplyMode: existing?.connectorReplyMode || null,
|
|
55
|
+
connectorThreadBinding: existing?.connectorThreadBinding || null,
|
|
56
|
+
connectorGroupPolicy: existing?.connectorGroupPolicy || null,
|
|
57
|
+
connectorIdleTimeoutSec: existing?.connectorIdleTimeoutSec || null,
|
|
58
|
+
connectorMaxAgeSec: existing?.connectorMaxAgeSec || null,
|
|
59
|
+
mailbox: existing?.mailbox || null,
|
|
60
|
+
connectorContext: existing?.connectorContext || undefined,
|
|
61
|
+
lastAutoMemoryAt: existing?.lastAutoMemoryAt || null,
|
|
62
|
+
lastHeartbeatText: existing?.lastHeartbeatText || null,
|
|
63
|
+
lastHeartbeatSentAt: existing?.lastHeartbeatSentAt || null,
|
|
64
|
+
lastSessionResetAt: existing?.lastSessionResetAt || null,
|
|
65
|
+
lastSessionResetReason: existing?.lastSessionResetReason || null,
|
|
66
|
+
identityState: existing?.identityState || null,
|
|
67
|
+
sessionArchiveState: existing?.sessionArchiveState || null,
|
|
68
|
+
pinned: existing?.pinned || false,
|
|
69
|
+
file: existing?.file || null,
|
|
70
|
+
queuedCount: existing?.queuedCount,
|
|
71
|
+
currentRunId: existing?.currentRunId || null,
|
|
72
|
+
conversationTone: existing?.conversationTone,
|
|
73
|
+
emoji: existing?.emoji,
|
|
74
|
+
creature: existing?.creature,
|
|
75
|
+
vibe: existing?.vibe,
|
|
76
|
+
theme: existing?.theme,
|
|
77
|
+
avatar: existing?.avatar,
|
|
78
|
+
canvasContent: existing?.canvasContent || null,
|
|
79
|
+
}
|
|
80
|
+
return applyResolvedRoute(baseSession, resolvePrimaryAgentRoute(agent))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ensureAgentThreadSession(agentId: string, user = 'default'): Session | null {
|
|
84
|
+
const agents = loadAgents()
|
|
85
|
+
const agent = agents[agentId] as Agent | undefined
|
|
86
|
+
if (!agent) return null
|
|
87
|
+
|
|
88
|
+
const sessions = loadSessions()
|
|
89
|
+
const now = Date.now()
|
|
90
|
+
|
|
91
|
+
const existingId = typeof agent.threadSessionId === 'string' ? agent.threadSessionId : ''
|
|
92
|
+
if (existingId && sessions[existingId]) {
|
|
93
|
+
const session = buildThreadSession(agent, existingId, user, now, sessions[existingId] as Session)
|
|
94
|
+
sessions[existingId] = session
|
|
95
|
+
saveSessions(sessions)
|
|
96
|
+
return session
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const legacySession = Object.values(sessions).find((session) => (
|
|
100
|
+
(session.shortcutForAgentId === agentId || session.name === `agent-thread:${agentId}`)
|
|
101
|
+
&& session.user === user
|
|
102
|
+
)) as Session | undefined
|
|
103
|
+
|
|
104
|
+
if (legacySession) {
|
|
105
|
+
agent.threadSessionId = legacySession.id
|
|
106
|
+
agent.updatedAt = now
|
|
107
|
+
saveAgents(agents)
|
|
108
|
+
const session = buildThreadSession(agent, legacySession.id, user, now, legacySession)
|
|
109
|
+
sessions[legacySession.id] = session
|
|
110
|
+
saveSessions(sessions)
|
|
111
|
+
return session
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sessionId = `agent-chat-${agentId}-${genId()}`
|
|
115
|
+
const session = buildThreadSession(agent, sessionId, user, now)
|
|
116
|
+
sessions[sessionId] = session
|
|
117
|
+
saveSessions(sessions)
|
|
118
|
+
|
|
119
|
+
agent.threadSessionId = sessionId
|
|
120
|
+
agent.updatedAt = now
|
|
121
|
+
saveAgents(agents)
|
|
122
|
+
return session
|
|
123
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
|
|
9
|
+
|
|
10
|
+
function extractLastJson(stdout: string): Record<string, unknown> {
|
|
11
|
+
const lines = stdout
|
|
12
|
+
.trim()
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((line) => line.trim())
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
17
|
+
return JSON.parse(jsonLine || '{}')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('data-dir resolution', () => {
|
|
21
|
+
it('falls back to in-project workspace when the external workspace root exists but child writes fail', () => {
|
|
22
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-data-dir-'))
|
|
23
|
+
const fakeHome = path.join(tempDir, 'home')
|
|
24
|
+
const dataDir = path.join(tempDir, 'data')
|
|
25
|
+
const externalWorkspace = path.join(fakeHome, '.swarmclaw', 'workspace')
|
|
26
|
+
fs.mkdirSync(externalWorkspace, { recursive: true })
|
|
27
|
+
fs.chmodSync(externalWorkspace, 0o555)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
|
|
31
|
+
const modNs = await import('./src/lib/server/data-dir.ts')
|
|
32
|
+
const mod = modNs.default || modNs['module.exports'] || modNs
|
|
33
|
+
console.log(JSON.stringify({
|
|
34
|
+
dataDir: mod.DATA_DIR,
|
|
35
|
+
workspaceDir: mod.WORKSPACE_DIR,
|
|
36
|
+
}))
|
|
37
|
+
`], {
|
|
38
|
+
cwd: repoRoot,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
HOME: fakeHome,
|
|
42
|
+
DATA_DIR: dataDir,
|
|
43
|
+
},
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
48
|
+
const payload = extractLastJson(result.stdout || '')
|
|
49
|
+
assert.equal(payload.dataDir, dataDir)
|
|
50
|
+
assert.equal(payload.workspaceDir, path.join(dataDir, 'workspace'))
|
|
51
|
+
} finally {
|
|
52
|
+
fs.chmodSync(externalWorkspace, 0o755)
|
|
53
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -4,18 +4,26 @@ import fs from 'fs'
|
|
|
4
4
|
|
|
5
5
|
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
|
6
6
|
|
|
7
|
+
function supportsChildWrites(dir: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
10
|
+
const probeDir = fs.mkdtempSync(path.join(dir, '.swarmclaw-probe-'))
|
|
11
|
+
fs.rmSync(probeDir, { recursive: true, force: true })
|
|
12
|
+
return true
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// Workspace lives outside the project directory to avoid triggering Next.js HMR
|
|
8
19
|
// when agents create/modify files. Falls back to data/workspace for Docker/CI.
|
|
9
20
|
function resolveWorkspaceDir(): string {
|
|
10
21
|
if (process.env.WORKSPACE_DIR) return process.env.WORKSPACE_DIR
|
|
11
22
|
const external = path.join(os.homedir(), '.swarmclaw', 'workspace')
|
|
12
|
-
|
|
13
|
-
fs.mkdirSync(external, { recursive: true })
|
|
23
|
+
if (supportsChildWrites(external)) {
|
|
14
24
|
return external
|
|
15
|
-
} catch {
|
|
16
|
-
// If we can't create the external dir (permissions, etc.), fall back to in-project
|
|
17
|
-
return path.join(DATA_DIR, 'workspace')
|
|
18
25
|
}
|
|
26
|
+
return path.join(DATA_DIR, 'workspace')
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export const WORKSPACE_DIR = resolveWorkspaceDir()
|
|
@@ -23,12 +31,10 @@ export const WORKSPACE_DIR = resolveWorkspaceDir()
|
|
|
23
31
|
function resolveBrowserProfilesDir(): string {
|
|
24
32
|
if (process.env.BROWSER_PROFILES_DIR) return process.env.BROWSER_PROFILES_DIR
|
|
25
33
|
const external = path.join(os.homedir(), '.swarmclaw', 'browser-profiles')
|
|
26
|
-
|
|
27
|
-
fs.mkdirSync(external, { recursive: true })
|
|
34
|
+
if (supportsChildWrites(external)) {
|
|
28
35
|
return external
|
|
29
|
-
} catch {
|
|
30
|
-
return path.join(DATA_DIR, 'browser-profiles')
|
|
31
36
|
}
|
|
37
|
+
return path.join(DATA_DIR, 'browser-profiles')
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export const BROWSER_PROFILES_DIR = resolveBrowserProfilesDir()
|
|
@@ -12,6 +12,7 @@ import { log } from './logger'
|
|
|
12
12
|
import { WORKSPACE_DIR } from './data-dir'
|
|
13
13
|
import { drainSystemEvents } from './system-events'
|
|
14
14
|
import { buildIdentityContinuityContext } from './identity-continuity'
|
|
15
|
+
import { ensureAgentThreadSession } from './agent-thread-session'
|
|
15
16
|
|
|
16
17
|
const HEARTBEAT_TICK_MS = 5_000
|
|
17
18
|
|
|
@@ -126,9 +127,13 @@ export interface HeartbeatConfig {
|
|
|
126
127
|
target: string | null
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
interface HeartbeatFileSession {
|
|
131
|
+
cwd?: string | null
|
|
132
|
+
}
|
|
133
|
+
|
|
129
134
|
const DEFAULT_HEARTBEAT_PROMPT = 'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.'
|
|
130
135
|
|
|
131
|
-
function readHeartbeatFile(session:
|
|
136
|
+
function readHeartbeatFile(session: HeartbeatFileSession): string {
|
|
132
137
|
try {
|
|
133
138
|
const filePath = path.join(session.cwd || WORKSPACE_DIR, 'HEARTBEAT.md')
|
|
134
139
|
if (fs.existsSync(filePath)) {
|
|
@@ -358,8 +363,12 @@ async function tickHeartbeats() {
|
|
|
358
363
|
return
|
|
359
364
|
}
|
|
360
365
|
|
|
361
|
-
const sessions = loadSessions()
|
|
362
366
|
const agents = loadAgents()
|
|
367
|
+
for (const agent of Object.values(agents) as any[]) {
|
|
368
|
+
if (!agent?.id || agent.heartbeatEnabled !== true) continue
|
|
369
|
+
ensureAgentThreadSession(String(agent.id))
|
|
370
|
+
}
|
|
371
|
+
const sessions = loadSessions()
|
|
363
372
|
const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
|
|
364
373
|
|
|
365
374
|
// Prune tracked sessions that no longer exist or have heartbeat disabled
|
|
@@ -377,7 +386,6 @@ async function tickHeartbeats() {
|
|
|
377
386
|
|
|
378
387
|
for (const session of Object.values(sessions) as any[]) {
|
|
379
388
|
if (!session?.id) continue
|
|
380
|
-
if (!Array.isArray(session.plugins) || session.plugins.length === 0) continue
|
|
381
389
|
if (session.sessionType && session.sessionType !== 'human') continue
|
|
382
390
|
|
|
383
391
|
// Check if this session or its agent has explicit heartbeat opt-in
|
|
@@ -402,8 +410,13 @@ async function tickHeartbeats() {
|
|
|
402
410
|
: Math.max(cfg.intervalSec * 2, 180)
|
|
403
411
|
const userIdleThresholdSec = resolveHeartbeatUserIdleSec(settings, defaultIdleSec)
|
|
404
412
|
const lastUserAt = lastUserMessageAt(session)
|
|
405
|
-
|
|
406
|
-
|
|
413
|
+
const baselineAt = lastUserAt > 0
|
|
414
|
+
? lastUserAt
|
|
415
|
+
: explicitOptIn
|
|
416
|
+
? (typeof session.lastActiveAt === 'number' ? session.lastActiveAt : (typeof session.createdAt === 'number' ? session.createdAt : 0))
|
|
417
|
+
: 0
|
|
418
|
+
if (baselineAt <= 0) continue
|
|
419
|
+
const idleMs = now - baselineAt
|
|
407
420
|
if (idleMs < userIdleThresholdSec * 1000) continue
|
|
408
421
|
|
|
409
422
|
const last = state.lastBySession.get(session.id) || 0
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Requests are debounced with a 250ms coalesce window to batch rapid-fire events.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { ensureAgentThreadSession } from './agent-thread-session'
|
|
6
7
|
import { loadSessions, loadAgents, loadSettings } from './storage'
|
|
7
8
|
import { enqueueSessionRun } from './session-run-manager'
|
|
8
9
|
import { log } from './logger'
|
|
@@ -29,7 +30,6 @@ function flushWakes(): void {
|
|
|
29
30
|
const wakes = new Map(state.pending)
|
|
30
31
|
state.pending.clear()
|
|
31
32
|
|
|
32
|
-
const sessions = loadSessions()
|
|
33
33
|
const agents = loadAgents()
|
|
34
34
|
const settings = loadSettings()
|
|
35
35
|
|
|
@@ -39,6 +39,7 @@ function flushWakes(): void {
|
|
|
39
39
|
|
|
40
40
|
// If only agentId provided, find the agent's most recently active session
|
|
41
41
|
if (!sessionId && wake.agentId) {
|
|
42
|
+
const sessions = loadSessions()
|
|
42
43
|
let bestSession: { id: string; lastActiveAt: number } | null = null
|
|
43
44
|
for (const s of Object.values(sessions) as Array<Record<string, unknown>>) {
|
|
44
45
|
if (s.agentId !== wake.agentId) continue
|
|
@@ -48,11 +49,14 @@ function flushWakes(): void {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
sessionId = bestSession?.id
|
|
52
|
+
if (!sessionId) {
|
|
53
|
+
sessionId = ensureAgentThreadSession(wake.agentId)?.id
|
|
54
|
+
}
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
if (!sessionId) continue
|
|
54
58
|
|
|
55
|
-
const session =
|
|
59
|
+
const session = loadSessions()[sessionId] as Record<string, unknown> | undefined
|
|
56
60
|
if (!session) continue
|
|
57
61
|
|
|
58
62
|
const agentId = (session.agentId || wake.agentId) as string | undefined
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import {
|
|
4
|
+
buildOpenClawDeployBundle,
|
|
5
|
+
getOpenClawLocalDeployStatus,
|
|
6
|
+
} from './openclaw-deploy.ts'
|
|
7
|
+
|
|
8
|
+
test('docker smart deploy bundle uses official image and provider-specific metadata', () => {
|
|
9
|
+
const bundle = buildOpenClawDeployBundle({
|
|
10
|
+
template: 'docker',
|
|
11
|
+
provider: 'digitalocean',
|
|
12
|
+
target: 'gateway.example.com',
|
|
13
|
+
token: 'test-token',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
assert.equal(bundle.template, 'docker')
|
|
17
|
+
assert.equal(bundle.provider, 'digitalocean')
|
|
18
|
+
assert.equal(bundle.providerLabel, 'DigitalOcean')
|
|
19
|
+
assert.equal(bundle.endpoint, 'https://gateway.example.com/v1')
|
|
20
|
+
assert.equal(bundle.wsUrl, 'wss://gateway.example.com')
|
|
21
|
+
assert.match(bundle.summary, /official OpenClaw Docker image/i)
|
|
22
|
+
assert.deepEqual(bundle.files.map((file) => file.name), [
|
|
23
|
+
'cloud-init.yaml',
|
|
24
|
+
'.env',
|
|
25
|
+
'docker-compose.yml',
|
|
26
|
+
'bootstrap.sh',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const envFile = bundle.files.find((file) => file.name === '.env')
|
|
30
|
+
assert.ok(envFile)
|
|
31
|
+
assert.match(envFile.content, /OPENCLAW_IMAGE=openclaw:latest/)
|
|
32
|
+
assert.match(envFile.content, /OPENCLAW_GATEWAY_TOKEN=test-token/)
|
|
33
|
+
|
|
34
|
+
const cloudInit = bundle.files.find((file) => file.name === 'cloud-init.yaml')
|
|
35
|
+
assert.ok(cloudInit)
|
|
36
|
+
assert.match(cloudInit.content, /docker\.io/)
|
|
37
|
+
assert.match(cloudInit.content, /docker pull "\$\{OPENCLAW_IMAGE:-openclaw:latest\}"/)
|
|
38
|
+
assert.match(cloudInit.content, /\/opt\/openclaw\/docker-compose\.yml/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('render bundle stays aligned with the official repo flow', () => {
|
|
42
|
+
const bundle = buildOpenClawDeployBundle({
|
|
43
|
+
template: 'render',
|
|
44
|
+
target: 'https://openclaw.onrender.com',
|
|
45
|
+
token: 'render-token',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
assert.equal(bundle.template, 'render')
|
|
49
|
+
assert.equal(bundle.providerLabel, 'Render')
|
|
50
|
+
assert.equal(bundle.endpoint, 'https://openclaw.onrender.com/v1')
|
|
51
|
+
assert.equal(bundle.token, 'render-token')
|
|
52
|
+
assert.deepEqual(bundle.files.map((file) => file.name), [
|
|
53
|
+
'render.yaml',
|
|
54
|
+
'OPENCLAW_GATEWAY_TOKEN.txt',
|
|
55
|
+
])
|
|
56
|
+
assert.match(bundle.runbook[0] || '', /official OpenClaw GitHub repo/i)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('local deploy status exposes a sensible default endpoint before startup', () => {
|
|
60
|
+
const status = getOpenClawLocalDeployStatus()
|
|
61
|
+
|
|
62
|
+
assert.equal(status.running, false)
|
|
63
|
+
assert.equal(status.port, 18789)
|
|
64
|
+
assert.equal(status.endpoint, 'http://127.0.0.1:18789/v1')
|
|
65
|
+
assert.equal(status.wsUrl, 'ws://127.0.0.1:18789')
|
|
66
|
+
assert.match(status.launchCommand, /npx openclaw gateway run/)
|
|
67
|
+
})
|