@ai-devkit/agent-manager 0.26.1 → 0.26.2
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/__tests__/AgentManager.test.js +37 -0
- package/dist/__tests__/AgentManager.test.js.map +1 -1
- package/dist/__tests__/utils/AgentRegistry.test.js +68 -46
- package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -1
- package/dist/database/connection.d.ts +22 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +58 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/database/index.d.ts +4 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +4 -0
- package/dist/database/index.js.map +1 -0
- package/dist/database/migrations/001_initial.sql +12 -0
- package/dist/database/schema.d.ts +4 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/database/schema.js +47 -0
- package/dist/database/schema.js.map +1 -0
- package/dist/utils/AgentRegistry.d.ts +7 -3
- package/dist/utils/AgentRegistry.d.ts.map +1 -1
- package/dist/utils/AgentRegistry.js +108 -57
- package/dist/utils/AgentRegistry.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/AgentManager.test.ts +37 -0
- package/src/__tests__/utils/AgentRegistry.test.ts +48 -40
- package/src/database/connection.ts +74 -0
- package/src/database/index.ts +7 -0
- package/src/database/migrations/001_initial.sql +12 -0
- package/src/database/schema.ts +62 -0
- package/src/utils/AgentRegistry.ts +109 -49
|
@@ -33,29 +33,28 @@ describe('AgentRegistry', () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe('register', () => {
|
|
36
|
-
it('creates the
|
|
36
|
+
it('creates the SQLite database and parent directory if missing', () => {
|
|
37
37
|
registry.register(makeEntry());
|
|
38
|
-
expect(fs.existsSync(regPath)).toBe(true);
|
|
39
|
-
|
|
40
|
-
expect(parsed.entries).toHaveLength(1);
|
|
41
|
-
expect(parsed.entries[0].name).toBe('agent1');
|
|
38
|
+
expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true);
|
|
39
|
+
expect(registry.list()[0].name).toBe('agent1');
|
|
42
40
|
});
|
|
43
41
|
|
|
44
42
|
it('appends a new entry when name is unique', () => {
|
|
45
43
|
registry.register(makeEntry({ name: 'a' }));
|
|
46
|
-
registry.register(makeEntry({ name: 'b' }));
|
|
44
|
+
registry.register(makeEntry({ name: 'b', pid: process.ppid }));
|
|
47
45
|
expect(registry.list()).toHaveLength(2);
|
|
48
46
|
});
|
|
49
47
|
|
|
50
|
-
it('upserts in place when
|
|
51
|
-
registry.register(makeEntry({ name: 'a', pid:
|
|
52
|
-
registry.register(makeEntry({ name: '
|
|
48
|
+
it('upserts in place when type and pid already exist', () => {
|
|
49
|
+
registry.register(makeEntry({ name: 'a', pid: process.pid }));
|
|
50
|
+
registry.register(makeEntry({ name: 'fallback', pid: process.pid, tmuxSession: '' }));
|
|
53
51
|
const all = registry.list();
|
|
54
52
|
expect(all).toHaveLength(1);
|
|
55
|
-
expect(all[0].pid).toBe(
|
|
53
|
+
expect(all[0].pid).toBe(process.pid);
|
|
54
|
+
expect(all[0].name).toBe('a');
|
|
56
55
|
});
|
|
57
56
|
|
|
58
|
-
it('
|
|
57
|
+
it('does not write through the legacy fixed .tmp path', () => {
|
|
59
58
|
registry.register(makeEntry());
|
|
60
59
|
expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
|
|
61
60
|
});
|
|
@@ -69,16 +68,18 @@ describe('AgentRegistry', () => {
|
|
|
69
68
|
|
|
70
69
|
it('preserves existing tmuxSession when incoming is empty string', () => {
|
|
71
70
|
registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
|
|
72
|
-
registry.register(makeEntry({ name: '
|
|
71
|
+
registry.register(makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }));
|
|
73
72
|
const saved = registry.lookup('a');
|
|
74
73
|
expect(saved?.tmuxSession).toBe('pinned');
|
|
75
|
-
expect(saved?.pid).toBe(
|
|
74
|
+
expect(saved?.pid).toBe(process.pid);
|
|
76
75
|
});
|
|
77
76
|
|
|
78
|
-
it('
|
|
79
|
-
registry.register(makeEntry({ name:
|
|
80
|
-
registry.register(makeEntry({ name: '
|
|
81
|
-
expect(registry.lookup('
|
|
77
|
+
it('lets a managed start entry replace a generated fallback for the same pid', () => {
|
|
78
|
+
registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
|
|
79
|
+
registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' }));
|
|
80
|
+
expect(registry.lookup('custom-name')?.tmuxSession).toBe('custom-name');
|
|
81
|
+
expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull();
|
|
82
|
+
expect(registry.list()).toHaveLength(1);
|
|
82
83
|
});
|
|
83
84
|
});
|
|
84
85
|
|
|
@@ -88,28 +89,35 @@ describe('AgentRegistry', () => {
|
|
|
88
89
|
expect(fs.existsSync(regPath)).toBe(false);
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
it('upserts multiple entries
|
|
92
|
-
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
|
92
|
+
it('upserts multiple entries in a single batch', () => {
|
|
93
93
|
registry.registerBatch([
|
|
94
94
|
makeEntry({ name: 'a' }),
|
|
95
|
-
makeEntry({ name: 'b' }),
|
|
96
|
-
makeEntry({ name: 'c' }),
|
|
95
|
+
makeEntry({ name: 'b', pid: process.pid + 1 }),
|
|
96
|
+
makeEntry({ name: 'c', pid: process.pid + 2 }),
|
|
97
97
|
]);
|
|
98
|
-
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
99
|
-
writeSpy.mockRestore();
|
|
100
98
|
expect(registry.list()).toHaveLength(3);
|
|
101
99
|
});
|
|
102
100
|
|
|
103
101
|
it('applies the tmuxSession merge per entry', () => {
|
|
104
102
|
registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
|
|
105
103
|
registry.registerBatch([
|
|
106
|
-
makeEntry({ name: '
|
|
107
|
-
makeEntry({ name: 'b', tmuxSession: '' }),
|
|
104
|
+
makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }),
|
|
105
|
+
makeEntry({ name: 'b', tmuxSession: '', pid: process.pid + 1 }),
|
|
108
106
|
]);
|
|
109
107
|
expect(registry.lookup('a')?.tmuxSession).toBe('pinned');
|
|
110
|
-
expect(registry.lookup('a')?.pid).toBe(
|
|
108
|
+
expect(registry.lookup('a')?.pid).toBe(process.pid);
|
|
111
109
|
expect(registry.lookup('b')?.tmuxSession).toBe('');
|
|
112
110
|
});
|
|
111
|
+
|
|
112
|
+
it('handles concurrent registry instances without duplicate pid rows', () => {
|
|
113
|
+
const other = new AgentRegistry(regPath);
|
|
114
|
+
registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
|
|
115
|
+
other.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' }));
|
|
116
|
+
registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
|
|
117
|
+
|
|
118
|
+
expect(registry.list()).toHaveLength(1);
|
|
119
|
+
expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
|
|
120
|
+
});
|
|
113
121
|
});
|
|
114
122
|
|
|
115
123
|
describe('lookup', () => {
|
|
@@ -124,20 +132,20 @@ describe('AgentRegistry', () => {
|
|
|
124
132
|
});
|
|
125
133
|
|
|
126
134
|
describe('list', () => {
|
|
127
|
-
it('returns empty array when
|
|
135
|
+
it('returns empty array when database does not contain entries', () => {
|
|
128
136
|
expect(registry.list()).toEqual([]);
|
|
129
137
|
});
|
|
130
138
|
|
|
131
|
-
it('
|
|
139
|
+
it('ignores existing legacy agents.json entries', () => {
|
|
140
|
+
const legacyEntry = makeEntry({ name: 'legacy', tmuxSession: 'legacy' });
|
|
132
141
|
fs.mkdirSync(path.dirname(regPath), { recursive: true });
|
|
133
|
-
fs.writeFileSync(regPath,
|
|
134
|
-
expect(registry.list()).toEqual([]);
|
|
135
|
-
});
|
|
142
|
+
fs.writeFileSync(regPath, JSON.stringify({ entries: [legacyEntry] }), 'utf8');
|
|
136
143
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(
|
|
144
|
+
const legacyRegistry = new AgentRegistry(regPath);
|
|
145
|
+
|
|
146
|
+
expect(legacyRegistry.lookup('legacy')).toBeNull();
|
|
147
|
+
expect(legacyRegistry.list()).toEqual([]);
|
|
148
|
+
expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true);
|
|
141
149
|
});
|
|
142
150
|
});
|
|
143
151
|
|
|
@@ -163,10 +171,10 @@ describe('AgentRegistry', () => {
|
|
|
163
171
|
|
|
164
172
|
it('is a no-op when all entries are alive', () => {
|
|
165
173
|
registry.register(makeEntry({ pid: process.pid }));
|
|
166
|
-
const before =
|
|
174
|
+
const before = registry.list();
|
|
167
175
|
registry.prune();
|
|
168
|
-
const after =
|
|
169
|
-
expect(after).
|
|
176
|
+
const after = registry.list();
|
|
177
|
+
expect(after).toEqual(before);
|
|
170
178
|
});
|
|
171
179
|
|
|
172
180
|
it('does nothing when file is missing', () => {
|
|
@@ -203,7 +211,7 @@ describe('AgentRegistry', () => {
|
|
|
203
211
|
|
|
204
212
|
it('throws RenameConflictError when new name is already in use by a live entry', () => {
|
|
205
213
|
registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
|
|
206
|
-
registry.register(makeEntry({ name: 'agent-b', pid: process.
|
|
214
|
+
registry.register(makeEntry({ name: 'agent-b', pid: process.ppid }));
|
|
207
215
|
expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
|
|
208
216
|
});
|
|
209
217
|
|
|
@@ -214,7 +222,7 @@ describe('AgentRegistry', () => {
|
|
|
214
222
|
expect(registry.lookup('agent-b')?.pid).toBe(process.pid);
|
|
215
223
|
});
|
|
216
224
|
|
|
217
|
-
it('
|
|
225
|
+
it('does not create the legacy fixed .tmp path on rename', () => {
|
|
218
226
|
registry.register(makeEntry({ name: 'old-name', pid: process.pid }));
|
|
219
227
|
registry.rename('old-name', 'new-name');
|
|
220
228
|
expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { initializeSchema } from './schema.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'agents.db');
|
|
8
|
+
|
|
9
|
+
export interface DatabaseOptions {
|
|
10
|
+
dbPath?: string;
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
readonly?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveAgentRegistryDbPath(filePath?: string): string {
|
|
16
|
+
if (!filePath) return DEFAULT_AGENT_REGISTRY_DB_PATH;
|
|
17
|
+
return filePath.endsWith('.json') ? filePath.replace(/\.json$/, '.db') : filePath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DatabaseConnection {
|
|
21
|
+
private db: Database.Database;
|
|
22
|
+
private readonly dbPath: string;
|
|
23
|
+
|
|
24
|
+
constructor(options: DatabaseOptions = {}) {
|
|
25
|
+
this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH;
|
|
26
|
+
mkdirSync(dirname(this.dbPath), { recursive: true });
|
|
27
|
+
|
|
28
|
+
this.db = new Database(this.dbPath, {
|
|
29
|
+
readonly: options.readonly ?? false,
|
|
30
|
+
verbose: options.verbose ? console.log : undefined,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.configure();
|
|
34
|
+
initializeSchema(this);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private configure(): void {
|
|
38
|
+
this.db.pragma('journal_mode = WAL');
|
|
39
|
+
this.db.pragma('foreign_keys = ON');
|
|
40
|
+
this.db.pragma('synchronous = NORMAL');
|
|
41
|
+
this.db.pragma('busy_timeout = 5000');
|
|
42
|
+
this.db.pragma('mmap_size = 268435456');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get instance(): Database.Database {
|
|
46
|
+
return this.db;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get path(): string {
|
|
50
|
+
return this.dbPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
query<T>(sql: string, params: unknown[] = []): T[] {
|
|
54
|
+
return this.db.prepare(sql).all(...params) as T[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
|
|
58
|
+
return this.db.prepare(sql).get(...params) as T | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
execute(sql: string, params: unknown[] = []): Database.RunResult {
|
|
62
|
+
return this.db.prepare(sql).run(...params);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
transaction<T>(fn: () => T): T {
|
|
66
|
+
return this.db.transaction(fn)();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
close(): void {
|
|
70
|
+
if (this.db.open) {
|
|
71
|
+
this.db.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
2
|
+
type TEXT NOT NULL,
|
|
3
|
+
pid INTEGER NOT NULL,
|
|
4
|
+
name TEXT NOT NULL UNIQUE,
|
|
5
|
+
tmux_session TEXT NOT NULL DEFAULT '',
|
|
6
|
+
cwd TEXT NOT NULL DEFAULT '',
|
|
7
|
+
started_at TEXT NOT NULL,
|
|
8
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
9
|
+
session_file_path TEXT NOT NULL DEFAULT '',
|
|
10
|
+
updated_at TEXT NOT NULL,
|
|
11
|
+
PRIMARY KEY (type, pid)
|
|
12
|
+
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import type { DatabaseConnection } from './connection.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
export function getSchemaVersion(db: DatabaseConnection): number {
|
|
9
|
+
const result = db.instance.pragma('user_version') as { user_version: number }[];
|
|
10
|
+
return result[0]?.user_version ?? 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setSchemaVersion(db: DatabaseConnection, version: number): void {
|
|
14
|
+
db.instance.pragma(`user_version = ${version}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getMigrationsDir(): string {
|
|
18
|
+
return join(__dirname, 'migrations');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Migration {
|
|
22
|
+
version: number;
|
|
23
|
+
path: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getMigrationFiles(): Migration[] {
|
|
28
|
+
const migrationsDir = getMigrationsDir();
|
|
29
|
+
|
|
30
|
+
let files: string[];
|
|
31
|
+
try {
|
|
32
|
+
files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort();
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return files.map((file) => {
|
|
38
|
+
const match = file.match(/^(\d+)_(.+)\.sql$/);
|
|
39
|
+
if (!match || !match[1] || !match[2]) {
|
|
40
|
+
throw new Error(`Invalid migration filename: ${file}. Expected format: 001_name.sql`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
version: parseInt(match[1], 10),
|
|
44
|
+
name: match[2],
|
|
45
|
+
path: join(migrationsDir, file),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function initializeSchema(db: DatabaseConnection): void {
|
|
51
|
+
const currentVersion = getSchemaVersion(db);
|
|
52
|
+
const pendingMigrations = getMigrationFiles().filter((m) => m.version > currentVersion);
|
|
53
|
+
|
|
54
|
+
for (const migration of pendingMigrations) {
|
|
55
|
+
const sql = readFileSync(migration.path, 'utf-8');
|
|
56
|
+
|
|
57
|
+
db.transaction(() => {
|
|
58
|
+
db.instance.exec(sql);
|
|
59
|
+
setSchemaVersion(db, migration.version);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
1
|
import os from 'os';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import type { AgentType } from '../adapters/AgentAdapter.js';
|
|
4
|
+
import {
|
|
5
|
+
DatabaseConnection,
|
|
6
|
+
resolveAgentRegistryDbPath,
|
|
7
|
+
} from '../database/index.js';
|
|
5
8
|
|
|
6
9
|
export class RenameNotFoundError extends Error {
|
|
7
10
|
constructor(public agentName: string) {
|
|
@@ -28,8 +31,16 @@ export interface RegistryEntry {
|
|
|
28
31
|
sessionFilePath: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
interface
|
|
32
|
-
|
|
34
|
+
interface RegistryRow {
|
|
35
|
+
name: string;
|
|
36
|
+
type: AgentType;
|
|
37
|
+
pid: number;
|
|
38
|
+
tmux_session: string;
|
|
39
|
+
cwd: string;
|
|
40
|
+
started_at: string;
|
|
41
|
+
session_id: string;
|
|
42
|
+
session_file_path: string;
|
|
43
|
+
updated_at: string;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
|
|
@@ -37,10 +48,10 @@ const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json
|
|
|
37
48
|
let defaultInstance: AgentRegistry | null = null;
|
|
38
49
|
|
|
39
50
|
export class AgentRegistry {
|
|
40
|
-
private
|
|
51
|
+
private db: DatabaseConnection;
|
|
41
52
|
|
|
42
53
|
constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
|
|
43
|
-
this.
|
|
54
|
+
this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
static default(): AgentRegistry {
|
|
@@ -50,32 +61,79 @@ export class AgentRegistry {
|
|
|
50
61
|
return defaultInstance;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const dir = path.dirname(this.filePath);
|
|
65
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
-
const tmp = `${this.filePath}.tmp`;
|
|
67
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
68
|
-
fs.renameSync(tmp, this.filePath);
|
|
64
|
+
private rowToEntry(row: RegistryRow): RegistryEntry {
|
|
65
|
+
return {
|
|
66
|
+
name: row.name,
|
|
67
|
+
type: row.type,
|
|
68
|
+
pid: row.pid,
|
|
69
|
+
tmuxSession: row.tmux_session,
|
|
70
|
+
cwd: row.cwd,
|
|
71
|
+
startedAt: row.started_at,
|
|
72
|
+
sessionId: row.session_id,
|
|
73
|
+
sessionFilePath: row.session_file_path,
|
|
74
|
+
};
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {
|
|
72
78
|
if (!existing) return incoming;
|
|
79
|
+
const incomingIsManaged = Boolean(incoming.tmuxSession);
|
|
73
80
|
return {
|
|
74
|
-
...
|
|
81
|
+
...existing,
|
|
82
|
+
name: incomingIsManaged ? incoming.name : existing.name,
|
|
75
83
|
tmuxSession: incoming.tmuxSession || existing.tmuxSession,
|
|
84
|
+
cwd: incoming.cwd || existing.cwd,
|
|
85
|
+
startedAt: existing.startedAt || incoming.startedAt,
|
|
86
|
+
sessionId: incoming.sessionId || existing.sessionId,
|
|
87
|
+
sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath,
|
|
76
88
|
};
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined {
|
|
92
|
+
const row = this.db.queryOne<RegistryRow>(
|
|
93
|
+
'SELECT * FROM agents WHERE type = ? AND pid = ?',
|
|
94
|
+
[type, pid],
|
|
95
|
+
);
|
|
96
|
+
return row ? this.rowToEntry(row) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private findByName(name: string): RegistryEntry | undefined {
|
|
100
|
+
const row = this.db.queryOne<RegistryRow>('SELECT * FROM agents WHERE name = ?', [name]);
|
|
101
|
+
return row ? this.rowToEntry(row) : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private deleteNameConflict(name: string, type: AgentType, pid: number): void {
|
|
105
|
+
const conflict = this.findByName(name);
|
|
106
|
+
if (!conflict) return;
|
|
107
|
+
if (conflict.type === type && conflict.pid === pid) return;
|
|
108
|
+
if (!this.isAlive(conflict)) {
|
|
109
|
+
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private insertOrUpdate(entry: RegistryEntry): void {
|
|
114
|
+
this.db.instance.prepare(`
|
|
115
|
+
INSERT INTO agents (
|
|
116
|
+
type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at
|
|
117
|
+
)
|
|
118
|
+
VALUES (
|
|
119
|
+
@type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt
|
|
120
|
+
)
|
|
121
|
+
ON CONFLICT(type, pid) DO UPDATE SET
|
|
122
|
+
name = excluded.name,
|
|
123
|
+
tmux_session = excluded.tmux_session,
|
|
124
|
+
cwd = excluded.cwd,
|
|
125
|
+
started_at = agents.started_at,
|
|
126
|
+
session_id = excluded.session_id,
|
|
127
|
+
session_file_path = excluded.session_file_path,
|
|
128
|
+
updated_at = excluded.updated_at
|
|
129
|
+
`).run({ ...entry, updatedAt: new Date().toISOString() });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private save(entry: RegistryEntry): void {
|
|
133
|
+
this.deleteNameConflict(entry.name, entry.type, entry.pid);
|
|
134
|
+
this.insertOrUpdate(entry);
|
|
135
|
+
}
|
|
136
|
+
|
|
79
137
|
isAlive(entry: RegistryEntry): boolean {
|
|
80
138
|
try {
|
|
81
139
|
process.kill(entry.pid, 0);
|
|
@@ -86,11 +144,13 @@ export class AgentRegistry {
|
|
|
86
144
|
}
|
|
87
145
|
|
|
88
146
|
prune(): void {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
const entries = this.list();
|
|
148
|
+
const stale = entries.filter((e) => !this.isAlive(e));
|
|
149
|
+
this.db.transaction(() => {
|
|
150
|
+
for (const entry of stale) {
|
|
151
|
+
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
94
154
|
}
|
|
95
155
|
|
|
96
156
|
register(entry: RegistryEntry): void {
|
|
@@ -99,41 +159,41 @@ export class AgentRegistry {
|
|
|
99
159
|
|
|
100
160
|
registerBatch(entries: RegistryEntry[]): void {
|
|
101
161
|
if (entries.length === 0) return;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
data.entries[idx] = this.mergeEntry(incoming, data.entries[idx]);
|
|
107
|
-
} else {
|
|
108
|
-
data.entries.push(incoming);
|
|
162
|
+
this.db.transaction(() => {
|
|
163
|
+
for (const incoming of entries) {
|
|
164
|
+
const existing = this.findByIdentity(incoming.type, incoming.pid);
|
|
165
|
+
this.save(this.mergeEntry(incoming, existing));
|
|
109
166
|
}
|
|
110
|
-
}
|
|
111
|
-
this.writeFile(data);
|
|
167
|
+
});
|
|
112
168
|
}
|
|
113
169
|
|
|
114
170
|
rename(currentName: string, newName: string): void {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
if (idx < 0) {
|
|
171
|
+
const existing = this.findByName(currentName);
|
|
172
|
+
if (!existing) {
|
|
118
173
|
throw new RenameNotFoundError(currentName);
|
|
119
174
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
if (conflict) {
|
|
175
|
+
const conflict = this.findByName(newName);
|
|
176
|
+
if (conflict && this.isAlive(conflict)) {
|
|
123
177
|
throw new RenameConflictError(newName);
|
|
124
178
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
|
|
180
|
+
this.db.transaction(() => {
|
|
181
|
+
if (conflict) {
|
|
182
|
+
this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
|
|
183
|
+
}
|
|
184
|
+
this.db.execute(
|
|
185
|
+
'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',
|
|
186
|
+
[newName, new Date().toISOString(), existing.type, existing.pid],
|
|
187
|
+
);
|
|
188
|
+
});
|
|
129
189
|
}
|
|
130
190
|
|
|
131
191
|
lookup(name: string): RegistryEntry | null {
|
|
132
|
-
|
|
133
|
-
return data.entries.find((e) => e.name === name) ?? null;
|
|
192
|
+
return this.findByName(name) ?? null;
|
|
134
193
|
}
|
|
135
194
|
|
|
136
195
|
list(): RegistryEntry[] {
|
|
137
|
-
|
|
196
|
+
const rows = this.db.query<RegistryRow>('SELECT * FROM agents ORDER BY started_at ASC, name ASC');
|
|
197
|
+
return rows.map((row) => this.rowToEntry(row));
|
|
138
198
|
}
|
|
139
199
|
}
|