@ai-devkit/agent-manager 0.26.0 → 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__/adapters/CodexAdapter.test.js +249 -0
- package/dist/__tests__/adapters/CodexAdapter.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/adapters/CodexAdapter.d.ts +9 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +106 -24
- package/dist/adapters/CodexAdapter.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__/adapters/CodexAdapter.test.ts +155 -0
- package/src/__tests__/utils/AgentRegistry.test.ts +48 -40
- package/src/adapters/CodexAdapter.ts +147 -27
- 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);
|
|
@@ -37,9 +37,26 @@ interface CodexEventEntry {
|
|
|
37
37
|
id?: string;
|
|
38
38
|
cwd?: string;
|
|
39
39
|
timestamp?: string;
|
|
40
|
+
role?: string;
|
|
41
|
+
content?: CodexContent[];
|
|
42
|
+
item?: CodexItem;
|
|
43
|
+
turn_id?: string;
|
|
44
|
+
internal_chat_message_metadata_passthrough?: {
|
|
45
|
+
turn_id?: string;
|
|
46
|
+
};
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
interface CodexContent {
|
|
51
|
+
type?: string;
|
|
52
|
+
text?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CodexItem {
|
|
56
|
+
type?: string;
|
|
57
|
+
content?: string | CodexContent[];
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
interface CodexSession {
|
|
44
61
|
sessionId: string;
|
|
45
62
|
projectPath: string;
|
|
@@ -502,7 +519,7 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
502
519
|
}
|
|
503
520
|
|
|
504
521
|
const lastEntry = this.findLastEventEntry(entries);
|
|
505
|
-
const lastPayloadType = lastEntry
|
|
522
|
+
const lastPayloadType = lastEntry ? this.normalizedPayloadType(lastEntry) : undefined;
|
|
506
523
|
|
|
507
524
|
const lastActive =
|
|
508
525
|
this.parseTimestamp(lastEntry?.timestamp) ||
|
|
@@ -596,15 +613,44 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
596
613
|
|
|
597
614
|
private extractSummary(entries: CodexEventEntry[]): string {
|
|
598
615
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
599
|
-
const message = entries[i]
|
|
600
|
-
if (
|
|
601
|
-
return this.truncate(message.trim(), 120);
|
|
602
|
-
}
|
|
616
|
+
const message = this.extractEntryText(entries[i]);
|
|
617
|
+
if (message) return this.truncate(message, 120);
|
|
603
618
|
}
|
|
604
619
|
|
|
605
620
|
return 'Codex session active';
|
|
606
621
|
}
|
|
607
622
|
|
|
623
|
+
private normalizedPayloadType(entry: CodexEventEntry): string | undefined {
|
|
624
|
+
const payloadType = entry.payload?.type;
|
|
625
|
+
|
|
626
|
+
if (entry.type === 'response_item' && payloadType === 'message') {
|
|
627
|
+
if (entry.payload?.role === 'assistant') return 'agent_message';
|
|
628
|
+
if (entry.payload?.role === 'user') return 'user_message';
|
|
629
|
+
return payloadType;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (entry.type === 'event_msg' && payloadType === 'item_completed') {
|
|
633
|
+
const itemType = entry.payload?.item?.type;
|
|
634
|
+
if (itemType === 'AgentMessage') return 'agent_message';
|
|
635
|
+
if (itemType === 'UserMessage') return 'user_message';
|
|
636
|
+
return itemType ?? payloadType;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return payloadType;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private extractEntryText(entry: CodexEventEntry | undefined): string {
|
|
643
|
+
if (!entry) return '';
|
|
644
|
+
|
|
645
|
+
const legacyMessage = entry.payload?.message;
|
|
646
|
+
if (typeof legacyMessage === 'string' && legacyMessage.trim().length > 0) {
|
|
647
|
+
return legacyMessage.trim();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const conversationMessage = this.toConversationMessage(entry, false);
|
|
651
|
+
return conversationMessage?.content.trim() ?? '';
|
|
652
|
+
}
|
|
653
|
+
|
|
608
654
|
private truncate(value: string, maxLength: number): string {
|
|
609
655
|
if (value.length <= maxLength) return value;
|
|
610
656
|
return `${value.slice(0, maxLength - 3)}...`;
|
|
@@ -619,7 +665,8 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
619
665
|
/**
|
|
620
666
|
* Read the full conversation from a Codex session JSONL file.
|
|
621
667
|
*
|
|
622
|
-
* Codex entries use
|
|
668
|
+
* Codex entries use either legacy payload.message fields or current
|
|
669
|
+
* response_item/event_msg content arrays.
|
|
623
670
|
*/
|
|
624
671
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
625
672
|
const verbose = options?.verbose ?? false;
|
|
@@ -628,45 +675,118 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
628
675
|
if (content === undefined) return [];
|
|
629
676
|
|
|
630
677
|
const lines = content.trim().split('\n');
|
|
678
|
+
const entries: CodexEventEntry[] = [];
|
|
631
679
|
const messages: ConversationMessage[] = [];
|
|
632
680
|
|
|
633
681
|
for (const line of lines) {
|
|
634
|
-
let entry: CodexEventEntry;
|
|
635
682
|
try {
|
|
636
|
-
|
|
683
|
+
entries.push(JSON.parse(line));
|
|
637
684
|
} catch {
|
|
638
685
|
continue;
|
|
639
686
|
}
|
|
687
|
+
}
|
|
640
688
|
|
|
641
|
-
|
|
689
|
+
const responseItemMirrorKeys = new Set<string>();
|
|
690
|
+
for (const entry of entries) {
|
|
691
|
+
if (entry.type !== 'response_item') continue;
|
|
642
692
|
|
|
643
|
-
const
|
|
644
|
-
|
|
693
|
+
const message = this.toConversationMessage(entry, verbose);
|
|
694
|
+
const mirrorKey = message ? this.mirroredMessageKey(entry, message) : null;
|
|
695
|
+
if (mirrorKey) responseItemMirrorKeys.add(mirrorKey);
|
|
696
|
+
}
|
|
645
697
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
698
|
+
for (const entry of entries) {
|
|
699
|
+
const message = this.toConversationMessage(entry, verbose);
|
|
700
|
+
if (!message) continue;
|
|
701
|
+
|
|
702
|
+
const mirrorKey = this.mirroredMessageKey(entry, message);
|
|
703
|
+
if (
|
|
704
|
+
entry.type === 'event_msg' &&
|
|
705
|
+
mirrorKey &&
|
|
706
|
+
responseItemMirrorKeys.has(mirrorKey)
|
|
707
|
+
) {
|
|
654
708
|
continue;
|
|
655
709
|
}
|
|
656
710
|
|
|
657
|
-
|
|
658
|
-
if (!text) continue;
|
|
659
|
-
|
|
660
|
-
messages.push({
|
|
661
|
-
role,
|
|
662
|
-
content: text,
|
|
663
|
-
timestamp: entry.timestamp,
|
|
664
|
-
});
|
|
711
|
+
messages.push(message);
|
|
665
712
|
}
|
|
666
713
|
|
|
667
714
|
return messages;
|
|
668
715
|
}
|
|
669
716
|
|
|
717
|
+
private toConversationMessage(entry: CodexEventEntry, verbose: boolean): ConversationMessage | null {
|
|
718
|
+
if (entry.type === 'session_meta') return null;
|
|
719
|
+
|
|
720
|
+
const payloadType = entry.payload?.type;
|
|
721
|
+
if (entry.type === 'response_item' && payloadType === 'message') {
|
|
722
|
+
const role = this.mapCodexRole(entry.payload?.role, verbose);
|
|
723
|
+
const text = this.extractContentText(entry.payload?.content);
|
|
724
|
+
if (!role || !text) return null;
|
|
725
|
+
|
|
726
|
+
return { role, content: text, timestamp: entry.timestamp };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (entry.type === 'event_msg' && payloadType === 'item_completed') {
|
|
730
|
+
const item = entry.payload?.item;
|
|
731
|
+
const role = this.mapCodexItemRole(item?.type, verbose);
|
|
732
|
+
const text = this.extractContentText(item?.content);
|
|
733
|
+
if (!role || !text) return null;
|
|
734
|
+
|
|
735
|
+
return { role, content: text, timestamp: entry.timestamp };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!payloadType) return null;
|
|
739
|
+
|
|
740
|
+
let role: ConversationMessage['role'];
|
|
741
|
+
if (payloadType === 'user_message') {
|
|
742
|
+
role = 'user';
|
|
743
|
+
} else if (payloadType === 'agent_message' || payloadType === 'task_complete') {
|
|
744
|
+
role = 'assistant';
|
|
745
|
+
} else if (verbose) {
|
|
746
|
+
role = 'system';
|
|
747
|
+
} else {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const text = entry.payload?.message?.trim();
|
|
752
|
+
if (!text) return null;
|
|
753
|
+
|
|
754
|
+
return { role, content: text, timestamp: entry.timestamp };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private mapCodexRole(role: string | undefined, verbose: boolean): ConversationMessage['role'] | null {
|
|
758
|
+
if (role === 'user') return 'user';
|
|
759
|
+
if (role === 'assistant') return 'assistant';
|
|
760
|
+
return verbose ? 'system' : null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private mapCodexItemRole(itemType: string | undefined, verbose: boolean): ConversationMessage['role'] | null {
|
|
764
|
+
if (itemType === 'AgentMessage') return 'assistant';
|
|
765
|
+
if (itemType === 'UserMessage') return 'user';
|
|
766
|
+
return verbose ? 'system' : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private mirroredMessageKey(entry: CodexEventEntry, message: ConversationMessage): string | null {
|
|
770
|
+
const turnId =
|
|
771
|
+
entry.payload?.turn_id ||
|
|
772
|
+
entry.payload?.internal_chat_message_metadata_passthrough?.turn_id;
|
|
773
|
+
|
|
774
|
+
if (!turnId) return null;
|
|
775
|
+
return `${turnId}\0${message.role}\0${message.content}`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private extractContentText(content: string | CodexContent[] | undefined): string {
|
|
779
|
+
if (typeof content === 'string') return content.trim();
|
|
780
|
+
if (!Array.isArray(content)) return '';
|
|
781
|
+
|
|
782
|
+
return content
|
|
783
|
+
.map((part) => part.text)
|
|
784
|
+
.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
|
|
785
|
+
.map((text) => text.trim())
|
|
786
|
+
.join('\n')
|
|
787
|
+
.trim();
|
|
788
|
+
}
|
|
789
|
+
|
|
670
790
|
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
671
791
|
if (!isDirectory(this.codexSessionsDir)) return [];
|
|
672
792
|
|
|
@@ -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
|
+
}
|