cli-copilot-worker 0.1.0
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/LICENSE +21 -0
- package/README.md +30 -0
- package/bin/cli-copilot-worker.mjs +3 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +682 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/copilot.d.ts +13 -0
- package/dist/src/core/copilot.js +56 -0
- package/dist/src/core/copilot.js.map +1 -0
- package/dist/src/core/failure-classifier.d.ts +8 -0
- package/dist/src/core/failure-classifier.js +148 -0
- package/dist/src/core/failure-classifier.js.map +1 -0
- package/dist/src/core/ids.d.ts +1 -0
- package/dist/src/core/ids.js +11 -0
- package/dist/src/core/ids.js.map +1 -0
- package/dist/src/core/markdown.d.ts +5 -0
- package/dist/src/core/markdown.js +14 -0
- package/dist/src/core/markdown.js.map +1 -0
- package/dist/src/core/paths.d.ts +18 -0
- package/dist/src/core/paths.js +42 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/profile-faults.d.ts +15 -0
- package/dist/src/core/profile-faults.js +110 -0
- package/dist/src/core/profile-faults.js.map +1 -0
- package/dist/src/core/profile-manager.d.ts +25 -0
- package/dist/src/core/profile-manager.js +162 -0
- package/dist/src/core/profile-manager.js.map +1 -0
- package/dist/src/core/question-registry.d.ts +25 -0
- package/dist/src/core/question-registry.js +154 -0
- package/dist/src/core/question-registry.js.map +1 -0
- package/dist/src/core/store.d.ts +39 -0
- package/dist/src/core/store.js +206 -0
- package/dist/src/core/store.js.map +1 -0
- package/dist/src/core/types.d.ts +152 -0
- package/dist/src/core/types.js +2 -0
- package/dist/src/core/types.js.map +1 -0
- package/dist/src/daemon/client.d.ts +6 -0
- package/dist/src/daemon/client.js +117 -0
- package/dist/src/daemon/client.js.map +1 -0
- package/dist/src/daemon/server.d.ts +1 -0
- package/dist/src/daemon/server.js +149 -0
- package/dist/src/daemon/server.js.map +1 -0
- package/dist/src/daemon/service.d.ts +69 -0
- package/dist/src/daemon/service.js +800 -0
- package/dist/src/daemon/service.js.map +1 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +74 -0
- package/dist/src/doctor.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/output.d.ts +28 -0
- package/dist/src/output.js +307 -0
- package/dist/src/output.js.map +1 -0
- package/package.json +59 -0
- package/src/cli.ts +881 -0
- package/src/core/copilot.ts +75 -0
- package/src/core/failure-classifier.ts +202 -0
- package/src/core/ids.ts +11 -0
- package/src/core/markdown.ts +19 -0
- package/src/core/paths.ts +56 -0
- package/src/core/profile-faults.ts +140 -0
- package/src/core/profile-manager.ts +220 -0
- package/src/core/question-registry.ts +191 -0
- package/src/core/store.ts +273 -0
- package/src/core/types.ts +211 -0
- package/src/daemon/client.ts +137 -0
- package/src/daemon/server.ts +167 -0
- package/src/daemon/service.ts +968 -0
- package/src/doctor.ts +82 -0
- package/src/index.ts +3 -0
- package/src/output.ts +391 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
CopilotFailureCategory,
|
|
6
|
+
CopilotProfile,
|
|
7
|
+
PersistedProfileState,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PROFILE_COOLDOWNS: Record<CopilotFailureCategory, number> = {
|
|
11
|
+
auth: 5 * 60_000,
|
|
12
|
+
rate_limit: 15 * 60_000,
|
|
13
|
+
connection: 60_000,
|
|
14
|
+
transient: 30_000,
|
|
15
|
+
fatal: 0,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function defaultProfileDir(): string {
|
|
19
|
+
return join(homedir(), '.copilot');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dedupeProfileDirs(profileDirs: string[]): string[] {
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
const result: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const dir of profileDirs) {
|
|
27
|
+
const normalized = dir.trim();
|
|
28
|
+
if (!normalized || seen.has(normalized)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
seen.add(normalized);
|
|
32
|
+
result.push(normalized);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result.length > 0 ? result : [defaultProfileDir()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildCooldowns(options?: {
|
|
39
|
+
cooldownMs?: number | undefined;
|
|
40
|
+
cooldowns?: Partial<Record<CopilotFailureCategory, number>> | undefined;
|
|
41
|
+
}): Record<CopilotFailureCategory, number> {
|
|
42
|
+
const cooldowns: Record<CopilotFailureCategory, number> = {
|
|
43
|
+
...DEFAULT_PROFILE_COOLDOWNS,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (options?.cooldownMs !== undefined) {
|
|
47
|
+
cooldowns.auth = options.cooldownMs;
|
|
48
|
+
cooldowns.rate_limit = options.cooldownMs;
|
|
49
|
+
cooldowns.connection = options.cooldownMs;
|
|
50
|
+
cooldowns.transient = options.cooldownMs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [category, value] of Object.entries(options?.cooldowns ?? {})) {
|
|
54
|
+
if (value !== undefined) {
|
|
55
|
+
cooldowns[category as CopilotFailureCategory] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return cooldowns;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ProfileManager {
|
|
63
|
+
private readonly cooldowns: Record<CopilotFailureCategory, number>;
|
|
64
|
+
private readonly now: () => number;
|
|
65
|
+
private readonly profiles: CopilotProfile[];
|
|
66
|
+
private currentIndex = 0;
|
|
67
|
+
|
|
68
|
+
constructor(options?: {
|
|
69
|
+
profileDirs?: string[] | undefined;
|
|
70
|
+
cooldownMs?: number | undefined;
|
|
71
|
+
cooldowns?: Partial<Record<CopilotFailureCategory, number>> | undefined;
|
|
72
|
+
now?: (() => number) | undefined;
|
|
73
|
+
persistedProfiles?: PersistedProfileState[] | undefined;
|
|
74
|
+
}) {
|
|
75
|
+
this.cooldowns = buildCooldowns({
|
|
76
|
+
cooldownMs: options?.cooldownMs,
|
|
77
|
+
cooldowns: options?.cooldowns,
|
|
78
|
+
});
|
|
79
|
+
this.now = options?.now ?? Date.now;
|
|
80
|
+
|
|
81
|
+
const configuredDirs = dedupeProfileDirs(options?.profileDirs ?? [defaultProfileDir()]);
|
|
82
|
+
const persistedByDir = new Map(
|
|
83
|
+
(options?.persistedProfiles ?? []).map((profile) => [profile.configDir, profile]),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
this.profiles = configuredDirs.map((configDir, index) => {
|
|
87
|
+
const persisted = persistedByDir.get(configDir);
|
|
88
|
+
return {
|
|
89
|
+
id: persisted?.id ?? `profile-${index + 1}`,
|
|
90
|
+
configDir,
|
|
91
|
+
cooldownUntil: persisted?.cooldownUntil,
|
|
92
|
+
failureCount: persisted?.failureCount ?? 0,
|
|
93
|
+
lastFailureReason: persisted?.lastFailureReason,
|
|
94
|
+
lastFailureCategory: persisted?.lastFailureCategory,
|
|
95
|
+
lastFailureAt: persisted?.lastFailureAt,
|
|
96
|
+
lastSuccessAt: persisted?.lastSuccessAt,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.currentIndex = this.findFirstAvailableIndex();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static fromEnvironment(persistedProfiles?: PersistedProfileState[]): ProfileManager {
|
|
104
|
+
const raw = process.env.COPILOT_CONFIG_DIRS;
|
|
105
|
+
const dirs = raw
|
|
106
|
+
? raw.split(',').map((entry) => entry.trim()).filter(Boolean)
|
|
107
|
+
: [defaultProfileDir()];
|
|
108
|
+
|
|
109
|
+
return new ProfileManager({ profileDirs: dirs, persistedProfiles });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getCandidateProfiles(): CopilotProfile[] {
|
|
113
|
+
this.resetExpiredCooldowns();
|
|
114
|
+
return this.profiles
|
|
115
|
+
.filter((profile) => profile.cooldownUntil === undefined || profile.cooldownUntil <= this.now())
|
|
116
|
+
.map((profile) => ({ ...profile }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getCurrentProfile(): CopilotProfile {
|
|
120
|
+
this.resetExpiredCooldowns();
|
|
121
|
+
const profile = this.profiles[this.currentIndex] ?? this.profiles[0];
|
|
122
|
+
if (!profile) {
|
|
123
|
+
throw new Error('No profiles configured');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ...profile };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
markFailure(reason: string): void;
|
|
130
|
+
markFailure(profileId: string, category: CopilotFailureCategory, reason: string): void;
|
|
131
|
+
markFailure(
|
|
132
|
+
profileIdOrReason: string,
|
|
133
|
+
category?: CopilotFailureCategory,
|
|
134
|
+
reason?: string,
|
|
135
|
+
): void {
|
|
136
|
+
const usingTypedSignature = reason !== undefined;
|
|
137
|
+
const profile = usingTypedSignature
|
|
138
|
+
? this.profiles.find((entry) => entry.id === profileIdOrReason)
|
|
139
|
+
: this.profiles[this.currentIndex];
|
|
140
|
+
|
|
141
|
+
if (!profile) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const failureCategory = usingTypedSignature ? category! : 'transient';
|
|
146
|
+
const failureReason = usingTypedSignature ? reason! : profileIdOrReason;
|
|
147
|
+
|
|
148
|
+
profile.failureCount += 1;
|
|
149
|
+
profile.lastFailureReason = failureReason;
|
|
150
|
+
profile.lastFailureCategory = failureCategory;
|
|
151
|
+
profile.lastFailureAt = new Date(this.now()).toISOString();
|
|
152
|
+
|
|
153
|
+
const cooldownMs = this.getCooldownMs(failureCategory);
|
|
154
|
+
profile.cooldownUntil = cooldownMs > 0 ? this.now() + cooldownMs : undefined;
|
|
155
|
+
|
|
156
|
+
if (!usingTypedSignature) {
|
|
157
|
+
const nextIndex = this.findFirstAvailableIndex();
|
|
158
|
+
if (nextIndex !== -1) {
|
|
159
|
+
this.currentIndex = nextIndex;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
markSuccess(profileId: string): void {
|
|
165
|
+
const profile = this.profiles.find((entry) => entry.id === profileId);
|
|
166
|
+
if (!profile) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
profile.cooldownUntil = undefined;
|
|
171
|
+
profile.lastSuccessAt = new Date(this.now()).toISOString();
|
|
172
|
+
|
|
173
|
+
const nextIndex = this.findFirstAvailableIndex();
|
|
174
|
+
if (nextIndex !== -1) {
|
|
175
|
+
this.currentIndex = nextIndex;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
getCooldownMs(category: CopilotFailureCategory): number {
|
|
180
|
+
return this.cooldowns[category];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
getProfiles(): CopilotProfile[] {
|
|
184
|
+
return this.profiles.map((profile) => ({ ...profile }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
toPersistedState(): PersistedProfileState[] {
|
|
188
|
+
return this.profiles.map((profile) => ({
|
|
189
|
+
id: profile.id,
|
|
190
|
+
configDir: profile.configDir,
|
|
191
|
+
cooldownUntil: profile.cooldownUntil,
|
|
192
|
+
failureCount: profile.failureCount,
|
|
193
|
+
lastFailureReason: profile.lastFailureReason,
|
|
194
|
+
lastFailureCategory: profile.lastFailureCategory,
|
|
195
|
+
lastFailureAt: profile.lastFailureAt,
|
|
196
|
+
lastSuccessAt: profile.lastSuccessAt,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private resetExpiredCooldowns(): void {
|
|
201
|
+
const now = this.now();
|
|
202
|
+
for (const profile of this.profiles) {
|
|
203
|
+
if (profile.cooldownUntil !== undefined && profile.cooldownUntil <= now) {
|
|
204
|
+
profile.cooldownUntil = undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const nextIndex = this.findFirstAvailableIndex();
|
|
209
|
+
if (nextIndex !== -1) {
|
|
210
|
+
this.currentIndex = nextIndex;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private findFirstAvailableIndex(): number {
|
|
215
|
+
const now = this.now();
|
|
216
|
+
return this.profiles.findIndex(
|
|
217
|
+
(profile) => profile.cooldownUntil === undefined || profile.cooldownUntil <= now,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { PersistentStore } from './store.js';
|
|
2
|
+
import type { PendingQuestion } from './types.js';
|
|
3
|
+
|
|
4
|
+
interface QuestionBinding {
|
|
5
|
+
conversationId: string;
|
|
6
|
+
jobId: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
question: PendingQuestion;
|
|
9
|
+
timeout: NodeJS.Timeout;
|
|
10
|
+
resolve: (value: { answer: string; wasFreeform: boolean }) => void;
|
|
11
|
+
reject: (error: Error) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseAnswer(
|
|
15
|
+
rawAnswer: string,
|
|
16
|
+
question: PendingQuestion,
|
|
17
|
+
): { success: boolean; resolvedAnswer?: string; wasFreeform?: boolean; error?: string } {
|
|
18
|
+
const answer = rawAnswer.trim();
|
|
19
|
+
if (!answer) {
|
|
20
|
+
return { success: false, error: 'Answer cannot be empty' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (question.choices && /^\d+$/.test(answer)) {
|
|
24
|
+
const index = Number.parseInt(answer, 10) - 1;
|
|
25
|
+
if (index < 0 || index >= question.choices.length) {
|
|
26
|
+
return { success: false, error: `Invalid choice. Please enter 1-${question.choices.length}.` };
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
success: true,
|
|
30
|
+
resolvedAnswer: question.choices[index]!,
|
|
31
|
+
wasFreeform: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (question.choices?.some((choice) => choice.toLowerCase() === answer.toLowerCase())) {
|
|
36
|
+
const resolved = question.choices.find((choice) => choice.toLowerCase() === answer.toLowerCase())!;
|
|
37
|
+
return {
|
|
38
|
+
success: true,
|
|
39
|
+
resolvedAnswer: resolved,
|
|
40
|
+
wasFreeform: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (answer.toUpperCase().startsWith('OTHER:') && question.allowFreeform) {
|
|
45
|
+
const custom = answer.slice('OTHER:'.length).trim();
|
|
46
|
+
if (!custom) {
|
|
47
|
+
return { success: false, error: 'OTHER: requires text after the colon.' };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
success: true,
|
|
51
|
+
resolvedAnswer: custom,
|
|
52
|
+
wasFreeform: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (question.allowFreeform) {
|
|
57
|
+
return {
|
|
58
|
+
success: true,
|
|
59
|
+
resolvedAnswer: answer,
|
|
60
|
+
wasFreeform: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { success: false, error: 'Invalid answer' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class QuestionRegistry {
|
|
68
|
+
private readonly pending = new Map<string, QuestionBinding>();
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly store: PersistentStore,
|
|
72
|
+
private readonly onEvent?: ((conversationId: string, event: string, data: Record<string, unknown>) => void) | undefined,
|
|
73
|
+
private readonly timeoutMs = 30 * 60 * 1000,
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
async register(input: {
|
|
77
|
+
conversationId: string;
|
|
78
|
+
jobId: string;
|
|
79
|
+
sessionId: string;
|
|
80
|
+
question: string;
|
|
81
|
+
choices?: string[] | undefined;
|
|
82
|
+
allowFreeform?: boolean | undefined;
|
|
83
|
+
}): Promise<{ answer: string; wasFreeform: boolean }> {
|
|
84
|
+
this.clear(input.conversationId, 'replaced by a newer question');
|
|
85
|
+
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const pendingQuestion: PendingQuestion = {
|
|
88
|
+
question: input.question,
|
|
89
|
+
choices: input.choices,
|
|
90
|
+
allowFreeform: input.allowFreeform ?? true,
|
|
91
|
+
askedAt: new Date().toISOString(),
|
|
92
|
+
sessionId: input.sessionId,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const timeout = setTimeout(() => {
|
|
96
|
+
void this.store.appendTranscript({
|
|
97
|
+
conversationId: input.conversationId,
|
|
98
|
+
jobId: input.jobId,
|
|
99
|
+
role: 'error',
|
|
100
|
+
content: 'Question timed out after 30 minutes.',
|
|
101
|
+
});
|
|
102
|
+
this.store.updatePendingQuestion(input.conversationId, undefined, 'failed');
|
|
103
|
+
this.store.finalizeJob(input.jobId, 'failed', 'Question timed out after 30 minutes');
|
|
104
|
+
reject(new Error('Question timed out'));
|
|
105
|
+
this.pending.delete(input.conversationId);
|
|
106
|
+
void this.store.persist();
|
|
107
|
+
}, this.timeoutMs);
|
|
108
|
+
timeout.unref();
|
|
109
|
+
|
|
110
|
+
this.pending.set(input.conversationId, {
|
|
111
|
+
conversationId: input.conversationId,
|
|
112
|
+
jobId: input.jobId,
|
|
113
|
+
sessionId: input.sessionId,
|
|
114
|
+
question: pendingQuestion,
|
|
115
|
+
timeout,
|
|
116
|
+
resolve,
|
|
117
|
+
reject,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.store.updatePendingQuestion(input.conversationId, pendingQuestion, 'waiting_answer');
|
|
121
|
+
this.store.updateJob(input.jobId, { status: 'waiting_answer' });
|
|
122
|
+
void this.store.appendTranscript({
|
|
123
|
+
conversationId: input.conversationId,
|
|
124
|
+
jobId: input.jobId,
|
|
125
|
+
role: 'question',
|
|
126
|
+
content: input.question,
|
|
127
|
+
data: {
|
|
128
|
+
choices: input.choices,
|
|
129
|
+
allowFreeform: input.allowFreeform ?? true,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
this.onEvent?.(input.conversationId, 'question', {
|
|
133
|
+
conversationId: input.conversationId,
|
|
134
|
+
question: input.question,
|
|
135
|
+
choices: input.choices ?? [],
|
|
136
|
+
allowFreeform: input.allowFreeform ?? true,
|
|
137
|
+
});
|
|
138
|
+
void this.store.persist();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
submitAnswer(conversationId: string, rawAnswer: string): { success: boolean; resolvedAnswer?: string; error?: string } {
|
|
143
|
+
const binding = this.pending.get(conversationId);
|
|
144
|
+
if (!binding) {
|
|
145
|
+
return { success: false, error: 'No pending question for this conversation' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const parsed = parseAnswer(rawAnswer, binding.question);
|
|
149
|
+
if (!parsed.success) {
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
clearTimeout(binding.timeout);
|
|
154
|
+
binding.resolve({
|
|
155
|
+
answer: parsed.resolvedAnswer!,
|
|
156
|
+
wasFreeform: parsed.wasFreeform!,
|
|
157
|
+
});
|
|
158
|
+
this.pending.delete(conversationId);
|
|
159
|
+
this.store.updatePendingQuestion(conversationId, undefined, 'running');
|
|
160
|
+
this.store.updateJob(binding.jobId, { status: 'running' });
|
|
161
|
+
void this.store.appendTranscript({
|
|
162
|
+
conversationId,
|
|
163
|
+
jobId: binding.jobId,
|
|
164
|
+
role: 'answer',
|
|
165
|
+
content: parsed.resolvedAnswer!,
|
|
166
|
+
});
|
|
167
|
+
this.onEvent?.(conversationId, 'answer', {
|
|
168
|
+
conversationId,
|
|
169
|
+
answer: parsed.resolvedAnswer!,
|
|
170
|
+
});
|
|
171
|
+
void this.store.persist();
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
resolvedAnswer: parsed.resolvedAnswer,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
clear(conversationId: string, reason: string): void {
|
|
180
|
+
const binding = this.pending.get(conversationId);
|
|
181
|
+
if (!binding) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
clearTimeout(binding.timeout);
|
|
186
|
+
binding.reject(new Error(reason));
|
|
187
|
+
this.pending.delete(conversationId);
|
|
188
|
+
this.store.updatePendingQuestion(conversationId, undefined, 'idle');
|
|
189
|
+
void this.store.persist();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
import { generateId } from './ids.js';
|
|
5
|
+
import { ensureStateRoot, logPath, transcriptPath, workspaceIdForCwd } from './paths.js';
|
|
6
|
+
import type {
|
|
7
|
+
ConversationRecord,
|
|
8
|
+
CopilotProfile,
|
|
9
|
+
JobKind,
|
|
10
|
+
JobAttemptRecord,
|
|
11
|
+
JobRecord,
|
|
12
|
+
PendingQuestion,
|
|
13
|
+
PersistedProfileState,
|
|
14
|
+
StateFile,
|
|
15
|
+
TranscriptEntry,
|
|
16
|
+
ConversationStatus,
|
|
17
|
+
JobStatus,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
|
|
20
|
+
const EMPTY_STATE: StateFile = {
|
|
21
|
+
version: 1,
|
|
22
|
+
conversations: [],
|
|
23
|
+
jobs: [],
|
|
24
|
+
profiles: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function nowIso(): string {
|
|
28
|
+
return new Date().toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class PersistentStore {
|
|
32
|
+
private readonly root = ensureStateRoot();
|
|
33
|
+
private state: StateFile = structuredClone(EMPTY_STATE);
|
|
34
|
+
|
|
35
|
+
async load(): Promise<void> {
|
|
36
|
+
if (!existsSync(this.root.registryPath)) {
|
|
37
|
+
this.state = structuredClone(EMPTY_STATE);
|
|
38
|
+
await this.persist();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = await readFile(this.root.registryPath, 'utf8');
|
|
43
|
+
this.state = this.normalizeState(JSON.parse(raw) as StateFile);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getProfiles(): PersistedProfileState[] {
|
|
47
|
+
return [...this.state.profiles];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setProfiles(profiles: PersistedProfileState[]): void {
|
|
51
|
+
this.state.profiles = profiles;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
listConversations(): ConversationRecord[] {
|
|
55
|
+
return [...this.state.conversations].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getConversation(conversationId: string): ConversationRecord | undefined {
|
|
59
|
+
const lowered = conversationId.toLowerCase();
|
|
60
|
+
return this.state.conversations.find((conversation) => conversation.id.toLowerCase() === lowered);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
resolveConversation(conversationIdOrPrefix: string): ConversationRecord | undefined {
|
|
64
|
+
const exact = this.getConversation(conversationIdOrPrefix);
|
|
65
|
+
if (exact) {
|
|
66
|
+
return exact;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lowered = conversationIdOrPrefix.toLowerCase();
|
|
70
|
+
const matches = this.state.conversations.filter((conversation) => conversation.id.toLowerCase().startsWith(lowered));
|
|
71
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
createConversation(input: {
|
|
75
|
+
cwd: string;
|
|
76
|
+
model: string;
|
|
77
|
+
}): ConversationRecord {
|
|
78
|
+
const timestamp = nowIso();
|
|
79
|
+
const conversation: ConversationRecord = {
|
|
80
|
+
id: generateId(),
|
|
81
|
+
sessionId: generateId(),
|
|
82
|
+
workspaceId: workspaceIdForCwd(input.cwd),
|
|
83
|
+
cwd: input.cwd,
|
|
84
|
+
model: input.model,
|
|
85
|
+
status: 'idle',
|
|
86
|
+
createdAt: timestamp,
|
|
87
|
+
updatedAt: timestamp,
|
|
88
|
+
hasStarted: false,
|
|
89
|
+
nextEntryIndex: 1,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
this.state.conversations.push(conversation);
|
|
93
|
+
return conversation;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateConversation(conversationId: string, updates: Partial<ConversationRecord>): ConversationRecord {
|
|
97
|
+
const conversation = this.getConversation(conversationId);
|
|
98
|
+
if (!conversation) {
|
|
99
|
+
throw new Error(`Conversation not found: ${conversationId}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Object.assign(conversation, updates, { updatedAt: nowIso() });
|
|
103
|
+
return conversation;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
listJobs(): JobRecord[] {
|
|
107
|
+
return [...this.state.jobs].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getJob(jobId: string): JobRecord | undefined {
|
|
111
|
+
const lowered = jobId.toLowerCase();
|
|
112
|
+
return this.state.jobs.find((job) => job.id.toLowerCase() === lowered);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
jobsForConversation(conversationId: string): JobRecord[] {
|
|
116
|
+
return this.state.jobs
|
|
117
|
+
.filter((job) => job.conversationId === conversationId)
|
|
118
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
createJob(input: {
|
|
122
|
+
conversationId: string;
|
|
123
|
+
kind: JobKind;
|
|
124
|
+
inputFilePath: string;
|
|
125
|
+
}): JobRecord {
|
|
126
|
+
const conversation = this.getConversation(input.conversationId);
|
|
127
|
+
if (!conversation) {
|
|
128
|
+
throw new Error(`Conversation not found: ${input.conversationId}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timestamp = nowIso();
|
|
132
|
+
const turnIndex = this.jobsForConversation(input.conversationId).length + 1;
|
|
133
|
+
const job: JobRecord = {
|
|
134
|
+
id: generateId(),
|
|
135
|
+
conversationId: input.conversationId,
|
|
136
|
+
kind: input.kind,
|
|
137
|
+
status: 'running',
|
|
138
|
+
inputFilePath: input.inputFilePath,
|
|
139
|
+
createdAt: timestamp,
|
|
140
|
+
startedAt: timestamp,
|
|
141
|
+
turnIndex,
|
|
142
|
+
startEntryIndex: conversation.nextEntryIndex,
|
|
143
|
+
attempts: [],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
conversation.lastJobId = job.id;
|
|
147
|
+
conversation.status = 'running';
|
|
148
|
+
conversation.updatedAt = timestamp;
|
|
149
|
+
this.state.jobs.push(job);
|
|
150
|
+
return job;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
createJobAttempt(jobId: string, profile: Pick<CopilotProfile, 'id' | 'configDir'>): JobAttemptRecord {
|
|
154
|
+
const job = this.getJob(jobId);
|
|
155
|
+
if (!job) {
|
|
156
|
+
throw new Error(`Job not found: ${jobId}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const attempt: JobAttemptRecord = {
|
|
160
|
+
profileId: profile.id,
|
|
161
|
+
profileConfigDir: profile.configDir,
|
|
162
|
+
startedAt: nowIso(),
|
|
163
|
+
outcome: 'running',
|
|
164
|
+
};
|
|
165
|
+
job.attempts.push(attempt);
|
|
166
|
+
return attempt;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
finalizeJobAttempt(
|
|
170
|
+
attempt: JobAttemptRecord,
|
|
171
|
+
updates: Partial<Omit<JobAttemptRecord, 'profileId' | 'profileConfigDir' | 'startedAt'>>,
|
|
172
|
+
): JobAttemptRecord {
|
|
173
|
+
Object.assign(attempt, updates);
|
|
174
|
+
return attempt;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
updateJob(jobId: string, updates: Partial<JobRecord>): JobRecord {
|
|
178
|
+
const job = this.getJob(jobId);
|
|
179
|
+
if (!job) {
|
|
180
|
+
throw new Error(`Job not found: ${jobId}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
Object.assign(job, updates);
|
|
184
|
+
return job;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async appendTranscript(input: {
|
|
188
|
+
conversationId: string;
|
|
189
|
+
jobId?: string | undefined;
|
|
190
|
+
role: TranscriptEntry['role'];
|
|
191
|
+
content: string;
|
|
192
|
+
data?: Record<string, unknown> | undefined;
|
|
193
|
+
}): Promise<TranscriptEntry> {
|
|
194
|
+
const conversation = this.getConversation(input.conversationId);
|
|
195
|
+
if (!conversation) {
|
|
196
|
+
throw new Error(`Conversation not found: ${input.conversationId}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const entry: TranscriptEntry = {
|
|
200
|
+
index: conversation.nextEntryIndex,
|
|
201
|
+
conversationId: input.conversationId,
|
|
202
|
+
jobId: input.jobId,
|
|
203
|
+
role: input.role,
|
|
204
|
+
content: input.content,
|
|
205
|
+
timestamp: nowIso(),
|
|
206
|
+
data: input.data,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
conversation.nextEntryIndex += 1;
|
|
210
|
+
conversation.updatedAt = entry.timestamp;
|
|
211
|
+
await mkdir(ensureStateRoot().rootDir, { recursive: true });
|
|
212
|
+
await appendFile(transcriptPath(conversation.cwd, conversation.id), `${JSON.stringify(entry)}\n`);
|
|
213
|
+
|
|
214
|
+
const logLine = `[${entry.timestamp}] [${entry.role}] ${entry.content}\n`;
|
|
215
|
+
await appendFile(logPath(conversation.cwd, conversation.id), logLine);
|
|
216
|
+
return entry;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async readTranscript(conversationId: string): Promise<TranscriptEntry[]> {
|
|
220
|
+
const conversation = this.resolveConversation(conversationId);
|
|
221
|
+
if (!conversation) {
|
|
222
|
+
throw new Error(`Conversation not found: ${conversationId}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const filePath = transcriptPath(conversation.cwd, conversation.id);
|
|
226
|
+
if (!existsSync(filePath)) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const raw = await readFile(filePath, 'utf8');
|
|
231
|
+
return raw
|
|
232
|
+
.split('\n')
|
|
233
|
+
.map((line) => line.trim())
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.map((line) => JSON.parse(line) as TranscriptEntry);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async persist(): Promise<void> {
|
|
239
|
+
await writeFile(this.root.registryPath, JSON.stringify(this.state, null, 2));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
updatePendingQuestion(conversationId: string, pendingQuestion: PendingQuestion | undefined, status: ConversationStatus): void {
|
|
243
|
+
const conversation = this.getConversation(conversationId);
|
|
244
|
+
if (!conversation) {
|
|
245
|
+
throw new Error(`Conversation not found: ${conversationId}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
conversation.pendingQuestion = pendingQuestion;
|
|
249
|
+
conversation.status = status;
|
|
250
|
+
conversation.updatedAt = nowIso();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
finalizeJob(jobId: string, status: JobStatus, error?: string): JobRecord {
|
|
254
|
+
const job = this.updateJob(jobId, {
|
|
255
|
+
status,
|
|
256
|
+
error,
|
|
257
|
+
endedAt: nowIso(),
|
|
258
|
+
});
|
|
259
|
+
return job;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private normalizeState(state: StateFile): StateFile {
|
|
263
|
+
return {
|
|
264
|
+
...state,
|
|
265
|
+
conversations: state.conversations ?? [],
|
|
266
|
+
jobs: (state.jobs ?? []).map((job) => ({
|
|
267
|
+
...job,
|
|
268
|
+
attempts: job.attempts ?? [],
|
|
269
|
+
})),
|
|
270
|
+
profiles: state.profiles ?? [],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|