@gricha/perry 0.3.6 → 0.3.8
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/dist/agent/router.js +76 -166
- package/dist/agent/web/assets/index-B-qVBi35.css +1 -0
- package/dist/agent/web/assets/{index-C-xi0Vax.js → index-hNfXv8YX.js} +5 -5
- package/dist/agent/web/index.html +2 -2
- package/dist/index.js +74 -6
- package/dist/perry-worker +0 -0
- package/dist/sessions/agents/claude.js +17 -10
- package/dist/sessions/agents/codex.js +1 -1
- package/dist/sessions/agents/index.js +31 -32
- package/dist/sessions/agents/opencode-storage.js +11 -0
- package/dist/sessions/agents/opencode.js +1 -1
- package/dist/sessions/agents/utils.js +3 -0
- package/dist/sessions/agents/worker-provider.js +66 -0
- package/dist/update-checker.js +21 -6
- package/dist/worker/client.js +105 -0
- package/dist/worker/server.js +69 -0
- package/dist/worker/session-index.js +331 -0
- package/dist/workspace/manager.js +63 -4
- package/package.json +1 -1
- package/dist/agent/web/assets/index-CYo-1I5o.css +0 -1
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Perry</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-hNfXv8YX.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B-qVBi35.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
3
4
|
import pkg from '../package.json';
|
|
4
5
|
import { startAgent } from './agent/run';
|
|
5
|
-
import { installService, uninstallService, showStatus } from './agent/systemd';
|
|
6
|
+
import { installService, uninstallService, showStatus, getServiceStatus } from './agent/systemd';
|
|
6
7
|
import { createApiClient, ApiClientError } from './client/api';
|
|
7
8
|
import { loadClientConfig, getWorker, setWorker } from './client/config';
|
|
8
9
|
import { openWSShell, openDockerExec, getTerminalWSUrl, isLocalWorker } from './client/ws-shell';
|
|
@@ -74,6 +75,38 @@ agentCmd
|
|
|
74
75
|
lines: parseInt(options.lines, 10),
|
|
75
76
|
});
|
|
76
77
|
});
|
|
78
|
+
agentCmd
|
|
79
|
+
.command('kill')
|
|
80
|
+
.description('Stop the running agent daemon')
|
|
81
|
+
.action(async () => {
|
|
82
|
+
const status = await getServiceStatus();
|
|
83
|
+
if (status.running) {
|
|
84
|
+
console.log('Stopping agent via systemd...');
|
|
85
|
+
const proc = spawn('systemctl', ['--user', 'stop', 'perry-agent'], {
|
|
86
|
+
stdio: 'inherit',
|
|
87
|
+
});
|
|
88
|
+
await new Promise((resolve) => proc.on('close', () => resolve()));
|
|
89
|
+
console.log('Agent stopped.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const agentRunning = await checkLocalAgent();
|
|
93
|
+
if (!agentRunning) {
|
|
94
|
+
console.log('No running agent found.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
console.log('Stopping agent process...');
|
|
98
|
+
const proc = spawn('pkill', ['-f', 'perry.*agent.*run'], {
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
});
|
|
101
|
+
await new Promise((resolve) => proc.on('close', () => resolve()));
|
|
102
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
103
|
+
const stillRunning = await checkLocalAgent();
|
|
104
|
+
if (stillRunning) {
|
|
105
|
+
console.log('Agent still running, sending SIGKILL...');
|
|
106
|
+
spawn('pkill', ['-9', '-f', 'perry.*agent.*run'], { stdio: 'inherit' });
|
|
107
|
+
}
|
|
108
|
+
console.log('Agent stopped.');
|
|
109
|
+
});
|
|
77
110
|
async function checkLocalAgent() {
|
|
78
111
|
try {
|
|
79
112
|
const response = await fetch(`http://localhost:${DEFAULT_AGENT_PORT}/health`, {
|
|
@@ -664,22 +697,49 @@ program
|
|
|
664
697
|
.description('Update Perry to the latest version')
|
|
665
698
|
.option('-f, --force', 'Force update even if already on latest version')
|
|
666
699
|
.action(async (options) => {
|
|
667
|
-
const {
|
|
700
|
+
const { fetchLatestVersionWithDetails, compareVersions } = await import('./update-checker.js');
|
|
668
701
|
const currentVersion = pkg.version;
|
|
669
702
|
console.log(`Current version: ${currentVersion}`);
|
|
670
703
|
console.log('Checking for updates...');
|
|
671
|
-
const
|
|
672
|
-
if (!
|
|
673
|
-
|
|
704
|
+
const result = await fetchLatestVersionWithDetails();
|
|
705
|
+
if (!result.version) {
|
|
706
|
+
// Only show detailed error messages in interactive mode (TTY)
|
|
707
|
+
if (process.stderr.isTTY) {
|
|
708
|
+
if (result.status === 504) {
|
|
709
|
+
console.error('GitHub API returned 504 Gateway Timeout. This is usually a temporary issue.');
|
|
710
|
+
console.error('Please try again in a few moments.');
|
|
711
|
+
}
|
|
712
|
+
else if (result.status === 403) {
|
|
713
|
+
console.error('GitHub API rate limit exceeded. Please try again later.');
|
|
714
|
+
}
|
|
715
|
+
else if (result.error) {
|
|
716
|
+
console.error(`Failed to fetch latest version: ${result.error}`);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
console.error('Failed to fetch latest version. Please try again later.');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
674
722
|
process.exit(1);
|
|
675
723
|
}
|
|
724
|
+
const latestVersion = result.version;
|
|
676
725
|
console.log(`Latest version: ${latestVersion}`);
|
|
677
726
|
if (compareVersions(currentVersion, latestVersion) <= 0 && !options.force) {
|
|
678
727
|
console.log('Already up to date.');
|
|
679
728
|
process.exit(0);
|
|
680
729
|
}
|
|
730
|
+
const agentRunning = await checkLocalAgent();
|
|
731
|
+
if (agentRunning) {
|
|
732
|
+
console.error('');
|
|
733
|
+
console.error('Warning: Perry agent is currently running.');
|
|
734
|
+
console.error('The update may fail with "Text file busy" error.');
|
|
735
|
+
console.error('');
|
|
736
|
+
console.error('Stop the agent first with:');
|
|
737
|
+
console.error(' perry agent kill');
|
|
738
|
+
console.error('');
|
|
739
|
+
console.error('Then run the update again.');
|
|
740
|
+
process.exit(1);
|
|
741
|
+
}
|
|
681
742
|
console.log(`Updating Perry from ${currentVersion} to ${latestVersion}...`);
|
|
682
|
-
const { spawn } = await import('child_process');
|
|
683
743
|
const child = spawn('bash', ['-c', 'curl -fsSL https://raw.githubusercontent.com/gricha/perry/main/install.sh | bash'], {
|
|
684
744
|
stdio: 'inherit',
|
|
685
745
|
});
|
|
@@ -741,6 +801,14 @@ workerCmd
|
|
|
741
801
|
process.exit(1);
|
|
742
802
|
}
|
|
743
803
|
});
|
|
804
|
+
workerCmd
|
|
805
|
+
.command('serve')
|
|
806
|
+
.option('--port <port>', 'Port to listen on', '7392')
|
|
807
|
+
.description('Start worker API server')
|
|
808
|
+
.action(async (opts) => {
|
|
809
|
+
const { startWorkerServer } = await import('./worker/server');
|
|
810
|
+
await startWorkerServer({ port: parseInt(opts.port, 10) });
|
|
811
|
+
});
|
|
744
812
|
function handleError(err) {
|
|
745
813
|
if (err instanceof ApiClientError) {
|
|
746
814
|
console.error(`Error: ${err.message}`);
|
package/dist/perry-worker
CHANGED
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseClaudeSessionContent } from '../parser';
|
|
2
|
-
import { decodeClaudeProjectPath, extractFirstUserPrompt, extractClaudeSessionName } from './utils';
|
|
2
|
+
import { decodeClaudeProjectPath, encodeClaudeProjectPath, extractFirstUserPrompt, extractClaudeSessionName, } from './utils';
|
|
3
3
|
export const claudeProvider = {
|
|
4
4
|
async discoverSessions(containerName, exec) {
|
|
5
5
|
const result = await exec(containerName, [
|
|
@@ -59,17 +59,24 @@ export const claudeProvider = {
|
|
|
59
59
|
firstPrompt,
|
|
60
60
|
};
|
|
61
61
|
},
|
|
62
|
-
async getSessionMessages(containerName, sessionId, exec) {
|
|
62
|
+
async getSessionMessages(containerName, sessionId, exec, projectPath) {
|
|
63
63
|
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
let filePath;
|
|
65
|
+
if (projectPath) {
|
|
66
|
+
const encodedPath = encodeClaudeProjectPath(projectPath);
|
|
67
|
+
filePath = `/home/workspace/.claude/projects/${encodedPath}/${safeSessionId}.jsonl`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const findResult = await exec(containerName, [
|
|
71
|
+
'bash',
|
|
72
|
+
'-c',
|
|
73
|
+
`find /home/workspace/.claude/projects -name "${safeSessionId}.jsonl" -type f 2>/dev/null | head -1`,
|
|
74
|
+
], { user: 'workspace' });
|
|
75
|
+
if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
filePath = findResult.stdout.trim();
|
|
71
79
|
}
|
|
72
|
-
const filePath = findResult.stdout.trim();
|
|
73
80
|
const catResult = await exec(containerName, ['cat', filePath], {
|
|
74
81
|
user: 'workspace',
|
|
75
82
|
});
|
|
@@ -87,7 +87,7 @@ export const codexProvider = {
|
|
|
87
87
|
firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null,
|
|
88
88
|
};
|
|
89
89
|
},
|
|
90
|
-
async getSessionMessages(containerName, sessionId, exec) {
|
|
90
|
+
async getSessionMessages(containerName, sessionId, exec, _projectPath) {
|
|
91
91
|
const findResult = await exec(containerName, ['bash', '-c', `find /home/workspace/.codex/sessions -name "*.jsonl" -type f 2>/dev/null`], { user: 'workspace' });
|
|
92
92
|
if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
|
|
93
93
|
return null;
|
|
@@ -1,53 +1,52 @@
|
|
|
1
1
|
import { claudeProvider } from './claude';
|
|
2
2
|
import { opencodeProvider } from './opencode';
|
|
3
3
|
import { codexProvider } from './codex';
|
|
4
|
+
import { discoverSessionsViaWorker, getSessionDetailsViaWorker, getSessionMessagesViaWorker, deleteSessionViaWorker, } from './worker-provider';
|
|
4
5
|
export { claudeProvider } from './claude';
|
|
5
6
|
export { opencodeProvider } from './opencode';
|
|
6
7
|
export { codexProvider } from './codex';
|
|
7
|
-
|
|
8
|
+
export { clearWorkerClientCache } from './worker-provider';
|
|
9
|
+
const _providers = {
|
|
8
10
|
'claude-code': claudeProvider,
|
|
9
11
|
opencode: opencodeProvider,
|
|
10
12
|
codex: codexProvider,
|
|
11
13
|
};
|
|
12
|
-
export async function discoverAllSessions(containerName,
|
|
13
|
-
|
|
14
|
-
claudeProvider.discoverSessions(containerName, exec),
|
|
15
|
-
opencodeProvider.discoverSessions(containerName, exec),
|
|
16
|
-
codexProvider.discoverSessions(containerName, exec),
|
|
17
|
-
]);
|
|
18
|
-
return results.flat();
|
|
14
|
+
export async function discoverAllSessions(containerName, _exec) {
|
|
15
|
+
return discoverSessionsViaWorker(containerName);
|
|
19
16
|
}
|
|
20
|
-
export async function getSessionDetails(containerName, rawSession,
|
|
21
|
-
|
|
22
|
-
if (!provider)
|
|
23
|
-
return null;
|
|
24
|
-
return provider.getSessionDetails(containerName, rawSession, exec);
|
|
17
|
+
export async function getSessionDetails(containerName, rawSession, _exec) {
|
|
18
|
+
return getSessionDetailsViaWorker(containerName, rawSession);
|
|
25
19
|
}
|
|
26
|
-
export async function getSessionMessages(containerName, sessionId, agentType,
|
|
27
|
-
const
|
|
28
|
-
if (!provider)
|
|
29
|
-
return null;
|
|
30
|
-
const result = await provider.getSessionMessages(containerName, sessionId, exec);
|
|
20
|
+
export async function getSessionMessages(containerName, sessionId, agentType, _exec, _projectPath) {
|
|
21
|
+
const result = await getSessionMessagesViaWorker(containerName, sessionId);
|
|
31
22
|
if (!result)
|
|
32
23
|
return null;
|
|
33
24
|
return { ...result, agentType };
|
|
34
25
|
}
|
|
35
|
-
export async function findSessionMessages(containerName, sessionId,
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
26
|
+
export async function findSessionMessages(containerName, sessionId, _exec) {
|
|
27
|
+
const { createWorkerClient } = await import('../../worker/client');
|
|
28
|
+
const client = await createWorkerClient(containerName);
|
|
29
|
+
const session = await client.getSession(sessionId);
|
|
30
|
+
if (!session) {
|
|
31
|
+
return null;
|
|
42
32
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const provider = providers[agentType];
|
|
47
|
-
if (!provider) {
|
|
48
|
-
return { success: false, error: 'Unknown agent type' };
|
|
33
|
+
const result = await client.getMessages(sessionId, { limit: 1000, offset: 0 });
|
|
34
|
+
if (!result || result.messages.length === 0) {
|
|
35
|
+
return null;
|
|
49
36
|
}
|
|
50
|
-
|
|
37
|
+
const agentType = session.agentType === 'claude' ? 'claude-code' : session.agentType;
|
|
38
|
+
const messages = result.messages.map((m) => ({
|
|
39
|
+
type: m.type,
|
|
40
|
+
content: m.content,
|
|
41
|
+
toolName: m.toolName,
|
|
42
|
+
toolId: m.toolId,
|
|
43
|
+
toolInput: m.toolInput,
|
|
44
|
+
timestamp: m.timestamp,
|
|
45
|
+
}));
|
|
46
|
+
return { id: sessionId, agentType, messages };
|
|
47
|
+
}
|
|
48
|
+
export async function deleteSession(containerName, sessionId, _agentType, _exec) {
|
|
49
|
+
return deleteSessionViaWorker(containerName, sessionId);
|
|
51
50
|
}
|
|
52
51
|
export async function searchSessions(containerName, query, exec) {
|
|
53
52
|
const safeQuery = query.replace(/['"\\]/g, '\\$&');
|
|
@@ -8,6 +8,7 @@ function getStorageBase(homeDir) {
|
|
|
8
8
|
export async function listOpencodeSessions(homeDir) {
|
|
9
9
|
const storageBase = getStorageBase(homeDir);
|
|
10
10
|
const sessionDir = path.join(storageBase, 'session');
|
|
11
|
+
const messageDir = path.join(storageBase, 'message');
|
|
11
12
|
const sessions = [];
|
|
12
13
|
try {
|
|
13
14
|
const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true });
|
|
@@ -26,12 +27,22 @@ export async function listOpencodeSessions(homeDir) {
|
|
|
26
27
|
const data = JSON.parse(content);
|
|
27
28
|
if (!data.id)
|
|
28
29
|
continue;
|
|
30
|
+
let messageCount = 0;
|
|
31
|
+
try {
|
|
32
|
+
const msgDir = path.join(messageDir, data.id);
|
|
33
|
+
const msgFiles = await fs.readdir(msgDir);
|
|
34
|
+
messageCount = msgFiles.filter((f) => f.startsWith('msg_') && f.endsWith('.json')).length;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// No messages directory
|
|
38
|
+
}
|
|
29
39
|
sessions.push({
|
|
30
40
|
id: data.id,
|
|
31
41
|
title: data.title || '',
|
|
32
42
|
directory: data.directory || '',
|
|
33
43
|
mtime: data.time?.updated || Math.floor(stat.mtimeMs),
|
|
34
44
|
file: filePath,
|
|
45
|
+
messageCount,
|
|
35
46
|
});
|
|
36
47
|
}
|
|
37
48
|
catch {
|
|
@@ -49,7 +49,7 @@ export const opencodeProvider = {
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
|
-
async getSessionMessages(containerName, sessionId, exec) {
|
|
52
|
+
async getSessionMessages(containerName, sessionId, exec, _projectPath) {
|
|
53
53
|
const result = await exec(containerName, ['perry', 'worker', 'sessions', 'messages', sessionId], {
|
|
54
54
|
user: 'workspace',
|
|
55
55
|
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export function decodeClaudeProjectPath(encoded) {
|
|
2
2
|
return encoded.replace(/-/g, '/');
|
|
3
3
|
}
|
|
4
|
+
export function encodeClaudeProjectPath(projectPath) {
|
|
5
|
+
return projectPath.replace(/\//g, '-');
|
|
6
|
+
}
|
|
4
7
|
export function extractFirstUserPrompt(messages) {
|
|
5
8
|
const firstPrompt = messages.find((msg) => msg.type === 'user' && msg.content && msg.content.trim().length > 0);
|
|
6
9
|
return firstPrompt?.content ? firstPrompt.content.slice(0, 200) : null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createWorkerClient } from '../../worker/client';
|
|
2
|
+
const clientCache = new Map();
|
|
3
|
+
async function getClient(containerName) {
|
|
4
|
+
let client = clientCache.get(containerName);
|
|
5
|
+
if (!client) {
|
|
6
|
+
client = await createWorkerClient(containerName);
|
|
7
|
+
clientCache.set(containerName, client);
|
|
8
|
+
}
|
|
9
|
+
return client;
|
|
10
|
+
}
|
|
11
|
+
export async function discoverSessionsViaWorker(containerName) {
|
|
12
|
+
const client = await getClient(containerName);
|
|
13
|
+
const sessions = await client.listSessions();
|
|
14
|
+
return sessions.map((s) => ({
|
|
15
|
+
id: s.id,
|
|
16
|
+
agentType: (s.agentType === 'claude' ? 'claude-code' : s.agentType),
|
|
17
|
+
projectPath: s.directory,
|
|
18
|
+
mtime: Math.floor(s.lastActivity / 1000),
|
|
19
|
+
name: s.title || undefined,
|
|
20
|
+
filePath: s.filePath,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
export async function getSessionDetailsViaWorker(containerName, rawSession) {
|
|
24
|
+
const client = await getClient(containerName);
|
|
25
|
+
const session = await client.getSession(rawSession.id);
|
|
26
|
+
if (!session) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
id: session.id,
|
|
31
|
+
name: session.title || null,
|
|
32
|
+
agentType: rawSession.agentType,
|
|
33
|
+
projectPath: session.directory,
|
|
34
|
+
messageCount: session.messageCount,
|
|
35
|
+
lastActivity: new Date(session.lastActivity).toISOString(),
|
|
36
|
+
firstPrompt: session.firstPrompt,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function getSessionMessagesViaWorker(containerName, sessionId) {
|
|
40
|
+
const client = await getClient(containerName);
|
|
41
|
+
const result = await client.getMessages(sessionId, { limit: 1000, offset: 0 });
|
|
42
|
+
if (!result || result.messages.length === 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const messages = result.messages.map((m) => ({
|
|
46
|
+
type: m.type,
|
|
47
|
+
content: m.content,
|
|
48
|
+
toolName: m.toolName,
|
|
49
|
+
toolId: m.toolId,
|
|
50
|
+
toolInput: m.toolInput,
|
|
51
|
+
timestamp: m.timestamp,
|
|
52
|
+
}));
|
|
53
|
+
return { id: sessionId, messages };
|
|
54
|
+
}
|
|
55
|
+
export async function deleteSessionViaWorker(containerName, sessionId) {
|
|
56
|
+
const client = await getClient(containerName);
|
|
57
|
+
return client.deleteSession(sessionId);
|
|
58
|
+
}
|
|
59
|
+
export function clearWorkerClientCache(containerName) {
|
|
60
|
+
if (containerName) {
|
|
61
|
+
clientCache.delete(containerName);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
clientCache.clear();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/update-checker.js
CHANGED
|
@@ -28,22 +28,37 @@ async function writeCache(cache) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
export async function fetchLatestVersion() {
|
|
31
|
+
const result = await fetchLatestVersionWithDetails();
|
|
32
|
+
return result.version;
|
|
33
|
+
}
|
|
34
|
+
export async function fetchLatestVersionWithDetails() {
|
|
31
35
|
try {
|
|
32
36
|
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
|
33
|
-
signal: AbortSignal.timeout(
|
|
37
|
+
signal: AbortSignal.timeout(5000),
|
|
34
38
|
headers: {
|
|
35
39
|
Accept: 'application/vnd.github.v3+json',
|
|
36
40
|
'User-Agent': 'perry-update-checker',
|
|
37
41
|
},
|
|
38
42
|
});
|
|
39
|
-
if (!response.ok)
|
|
40
|
-
return
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
return {
|
|
45
|
+
version: null,
|
|
46
|
+
error: `GitHub API returned ${response.status} ${response.statusText}`,
|
|
47
|
+
status: response.status,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
41
50
|
const data = (await response.json());
|
|
42
51
|
const tag = data.tag_name || null;
|
|
43
|
-
return tag ? tag.replace(/^v/, '') : null;
|
|
52
|
+
return { version: tag ? tag.replace(/^v/, '') : null };
|
|
44
53
|
}
|
|
45
|
-
catch {
|
|
46
|
-
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof Error) {
|
|
56
|
+
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
57
|
+
return { version: null, error: 'Request timed out' };
|
|
58
|
+
}
|
|
59
|
+
return { version: null, error: err.message };
|
|
60
|
+
}
|
|
61
|
+
return { version: null, error: 'Unknown error' };
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
export function compareVersions(current, latest) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getContainerIp, execInContainer } from '../docker';
|
|
2
|
+
const WORKER_PORT = 7392;
|
|
3
|
+
const HEALTH_TIMEOUT = 2000;
|
|
4
|
+
const REQUEST_TIMEOUT = 30000;
|
|
5
|
+
const STARTUP_TIMEOUT = 15000;
|
|
6
|
+
const STARTUP_POLL_INTERVAL = 200;
|
|
7
|
+
async function fetchWithTimeout(url, options = {}) {
|
|
8
|
+
const timeout = options.timeout ?? REQUEST_TIMEOUT;
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch(url, {
|
|
13
|
+
...options,
|
|
14
|
+
signal: controller.signal,
|
|
15
|
+
});
|
|
16
|
+
return response;
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timeoutId);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function isWorkerRunning(ip) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetchWithTimeout(`http://${ip}:${WORKER_PORT}/health`, {
|
|
25
|
+
timeout: HEALTH_TIMEOUT,
|
|
26
|
+
});
|
|
27
|
+
return response.ok;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function startWorkerInContainer(containerName) {
|
|
34
|
+
await execInContainer(containerName, ['sh', '-c', 'nohup perry worker serve > /tmp/perry-worker.log 2>&1 &'], { user: 'workspace' });
|
|
35
|
+
}
|
|
36
|
+
async function ensureWorkerRunning(containerName) {
|
|
37
|
+
const ip = await getContainerIp(containerName);
|
|
38
|
+
if (!ip) {
|
|
39
|
+
throw new Error(`Could not get IP for container: ${containerName}`);
|
|
40
|
+
}
|
|
41
|
+
if (await isWorkerRunning(ip)) {
|
|
42
|
+
return ip;
|
|
43
|
+
}
|
|
44
|
+
await startWorkerInContainer(containerName);
|
|
45
|
+
const deadline = Date.now() + STARTUP_TIMEOUT;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, STARTUP_POLL_INTERVAL));
|
|
48
|
+
if (await isWorkerRunning(ip)) {
|
|
49
|
+
return ip;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Worker failed to start in container: ${containerName}`);
|
|
53
|
+
}
|
|
54
|
+
export async function createWorkerClient(containerName) {
|
|
55
|
+
const ip = await ensureWorkerRunning(containerName);
|
|
56
|
+
const baseUrl = `http://${ip}:${WORKER_PORT}`;
|
|
57
|
+
return {
|
|
58
|
+
async health() {
|
|
59
|
+
const response = await fetchWithTimeout(`${baseUrl}/health`);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(`Failed to get health: ${response.statusText}`);
|
|
62
|
+
}
|
|
63
|
+
return response.json();
|
|
64
|
+
},
|
|
65
|
+
async listSessions() {
|
|
66
|
+
const response = await fetchWithTimeout(`${baseUrl}/sessions`);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`Failed to list sessions: ${response.statusText}`);
|
|
69
|
+
}
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
return data.sessions;
|
|
72
|
+
},
|
|
73
|
+
async getSession(id) {
|
|
74
|
+
const response = await fetchWithTimeout(`${baseUrl}/sessions/${encodeURIComponent(id)}`);
|
|
75
|
+
if (response.status === 404) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Failed to get session: ${response.statusText}`);
|
|
80
|
+
}
|
|
81
|
+
const data = await response.json();
|
|
82
|
+
return data.session;
|
|
83
|
+
},
|
|
84
|
+
async getMessages(id, opts = {}) {
|
|
85
|
+
const params = new URLSearchParams();
|
|
86
|
+
if (opts.limit !== undefined)
|
|
87
|
+
params.set('limit', String(opts.limit));
|
|
88
|
+
if (opts.offset !== undefined)
|
|
89
|
+
params.set('offset', String(opts.offset));
|
|
90
|
+
const url = `${baseUrl}/sessions/${encodeURIComponent(id)}/messages?${params}`;
|
|
91
|
+
const response = await fetchWithTimeout(url);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`Failed to get messages: ${response.statusText}`);
|
|
94
|
+
}
|
|
95
|
+
return response.json();
|
|
96
|
+
},
|
|
97
|
+
async deleteSession(id) {
|
|
98
|
+
const response = await fetchWithTimeout(`${baseUrl}/sessions/${encodeURIComponent(id)}`, {
|
|
99
|
+
method: 'DELETE',
|
|
100
|
+
});
|
|
101
|
+
return response.json();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export { WORKER_PORT };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { sessionIndex } from './session-index';
|
|
2
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
3
|
+
const DEFAULT_PORT = 7392;
|
|
4
|
+
export async function startWorkerServer(options = {}) {
|
|
5
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
6
|
+
await sessionIndex.initialize();
|
|
7
|
+
sessionIndex.startWatchers();
|
|
8
|
+
const server = Bun.serve({
|
|
9
|
+
port,
|
|
10
|
+
hostname: '0.0.0.0',
|
|
11
|
+
async fetch(req) {
|
|
12
|
+
const url = new URL(req.url);
|
|
13
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
14
|
+
const response = {
|
|
15
|
+
status: 'ok',
|
|
16
|
+
version: pkg.version,
|
|
17
|
+
sessionCount: sessionIndex.list().length,
|
|
18
|
+
};
|
|
19
|
+
return Response.json(response);
|
|
20
|
+
}
|
|
21
|
+
if (url.pathname === '/sessions' && req.method === 'GET') {
|
|
22
|
+
await sessionIndex.refresh();
|
|
23
|
+
const sessions = sessionIndex.list();
|
|
24
|
+
const response = { sessions };
|
|
25
|
+
return Response.json(response);
|
|
26
|
+
}
|
|
27
|
+
if (url.pathname === '/refresh' && req.method === 'POST') {
|
|
28
|
+
await sessionIndex.refresh();
|
|
29
|
+
return Response.json({ success: true });
|
|
30
|
+
}
|
|
31
|
+
const messagesMatch = url.pathname.match(/^\/sessions\/([^/]+)\/messages$/);
|
|
32
|
+
if (messagesMatch && req.method === 'GET') {
|
|
33
|
+
const id = decodeURIComponent(messagesMatch[1]);
|
|
34
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
35
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
36
|
+
const result = await sessionIndex.getMessages(id, { limit, offset });
|
|
37
|
+
const response = result;
|
|
38
|
+
return Response.json(response);
|
|
39
|
+
}
|
|
40
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
41
|
+
if (sessionMatch && req.method === 'GET') {
|
|
42
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
43
|
+
const session = sessionIndex.get(id);
|
|
44
|
+
if (!session) {
|
|
45
|
+
return Response.json({ error: 'Session not found' }, { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
return Response.json({ session });
|
|
48
|
+
}
|
|
49
|
+
if (sessionMatch && req.method === 'DELETE') {
|
|
50
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
51
|
+
const result = await sessionIndex.delete(id);
|
|
52
|
+
const response = result;
|
|
53
|
+
return Response.json(response, { status: result.success ? 200 : 404 });
|
|
54
|
+
}
|
|
55
|
+
return Response.json({ error: 'Not Found' }, { status: 404 });
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
console.error(`Worker server listening on port ${server.port}`);
|
|
59
|
+
process.on('SIGINT', () => {
|
|
60
|
+
sessionIndex.stopWatchers();
|
|
61
|
+
server.stop();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
});
|
|
64
|
+
process.on('SIGTERM', () => {
|
|
65
|
+
sessionIndex.stopWatchers();
|
|
66
|
+
server.stop();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
});
|
|
69
|
+
}
|