Skip to content

Commit ed7b433

Browse files
committed
fix(msteams): preserve unverified conversation migrations
1 parent fd9f84f commit ed7b433

3 files changed

Lines changed: 150 additions & 5 deletions

File tree

extensions/msteams/doctor-contract-api.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66
import {
77
createPluginStateKeyedStoreForTests,
88
resetPluginStateStoreForTests,
9+
setMaxPluginStateEntriesPerPluginForTests,
910
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
1011
import type {
1112
OpenKeyedStoreOptions,
@@ -73,6 +74,7 @@ describe("msteams doctor state migration", () => {
7374
});
7475

7576
afterEach(async () => {
77+
setMaxPluginStateEntriesPerPluginForTests(undefined);
7678
await fs.rm(stateDir, { recursive: true, force: true });
7779
});
7880

@@ -135,6 +137,123 @@ describe("msteams doctor state migration", () => {
135137
});
136138
});
137139

140+
it("retains legacy conversations after a production store failure and archives after retry", async () => {
141+
const filePath = path.join(stateDir, "msteams-conversations.json");
142+
const conversationId = "19:[email protected]";
143+
const ref: StoredConversationReference = {
144+
conversation: { id: conversationId, conversationType: "personal" },
145+
channelId: "msteams",
146+
serviceUrl: "https://service.example.com",
147+
user: { id: "user-recover" },
148+
};
149+
await fs.writeFile(
150+
filePath,
151+
`${JSON.stringify({
152+
version: 1,
153+
conversations: {
154+
"legacy-user-key": ref,
155+
},
156+
} satisfies MSTeamsLegacyConversationStoreData)}\n`,
157+
);
158+
159+
const migration = migrationById("msteams-conversations-json-to-plugin-state");
160+
const context = createDoctorContext(env);
161+
const siblingStore = context.openPluginStateKeyedStore<{ value: string }>({
162+
namespace: "conversation-migration-fixture",
163+
maxEntries: 10,
164+
});
165+
await siblingStore.register("occupied", { value: "already-here" });
166+
167+
setMaxPluginStateEntriesPerPluginForTests(1);
168+
await expect(
169+
migration.migrateLegacyState({
170+
config: {},
171+
env,
172+
stateDir,
173+
oauthDir: path.join(stateDir, "oauth"),
174+
context,
175+
}),
176+
).rejects.toMatchObject({ code: "PLUGIN_STATE_LIMIT_EXCEEDED" });
177+
178+
await expect(fs.access(filePath)).resolves.toBeUndefined();
179+
await expect(fs.access(`${filePath}.migrated`)).rejects.toThrow();
180+
181+
setMaxPluginStateEntriesPerPluginForTests(undefined);
182+
resetPluginStateStoreForTests({ closeDatabase: false });
183+
const result = await migration.migrateLegacyState({
184+
config: {},
185+
env,
186+
stateDir,
187+
oauthDir: path.join(stateDir, "oauth"),
188+
context,
189+
});
190+
191+
expect(result.warnings).toEqual([]);
192+
expect(result.changes).toEqual([
193+
expect.stringContaining("Migrated 1 Microsoft Teams conversation entry"),
194+
expect.stringContaining("Archived Microsoft Teams conversation legacy source"),
195+
]);
196+
await expect(fs.access(filePath)).rejects.toThrow();
197+
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
198+
199+
const store = context.openPluginStateKeyedStore<StoredConversationReference>({
200+
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
201+
maxEntries: 2000,
202+
});
203+
await expect(store.lookup(buildMSTeamsConversationStateKey(conversationId))).resolves.toEqual(
204+
expect.objectContaining({
205+
conversation: expect.objectContaining({ id: conversationId }),
206+
user: expect.objectContaining({ id: "user-recover" }),
207+
}),
208+
);
209+
await expect(store.lookup(buildMSTeamsConversationStateKey("legacy-user-key"))).resolves.toBe(
210+
undefined,
211+
);
212+
});
213+
214+
it("archives legacy conversations when zero imports are already present in plugin state", async () => {
215+
const filePath = path.join(stateDir, "msteams-conversations.json");
216+
const ref: StoredConversationReference = {
217+
conversation: { id: "19:[email protected]" },
218+
channelId: "msteams",
219+
serviceUrl: "https://service.example.com",
220+
user: { id: "user-existing" },
221+
};
222+
await fs.writeFile(
223+
filePath,
224+
`${JSON.stringify({
225+
version: 1,
226+
conversations: {
227+
"19:[email protected]": ref,
228+
},
229+
} satisfies MSTeamsLegacyConversationStoreData)}\n`,
230+
);
231+
232+
const migration = migrationById("msteams-conversations-json-to-plugin-state");
233+
const context = createDoctorContext(env);
234+
const store = context.openPluginStateKeyedStore<StoredConversationReference>({
235+
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
236+
maxEntries: 2000,
237+
});
238+
await store.register(buildMSTeamsConversationStateKey("19:[email protected]"), ref);
239+
240+
const result = await migration.migrateLegacyState({
241+
config: {},
242+
env,
243+
stateDir,
244+
oauthDir: path.join(stateDir, "oauth"),
245+
context,
246+
});
247+
248+
expect(result.warnings).toEqual([]);
249+
expect(result.changes).toEqual([
250+
expect.stringContaining("Migrated 0 Microsoft Teams conversation entries"),
251+
expect.stringContaining("Archived Microsoft Teams conversation legacy source"),
252+
]);
253+
await expect(fs.access(filePath)).rejects.toThrow();
254+
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
255+
});
256+
138257
it("imports legacy polls and vote buckets into plugin state", async () => {
139258
const filePath = path.join(stateDir, "msteams-polls.json");
140259
const poll: MSTeamsPoll = {

extensions/msteams/doctor-contract-api.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,16 @@ function parseLegacyConversationStore(value: unknown): MSTeamsLegacyConversation
165165
});
166166
}
167167

168+
function resolveLegacyConversationId(
169+
rawConversationId: string,
170+
reference: StoredConversationReference,
171+
): string {
172+
const storedConversationId = reference.conversation?.id
173+
? normalizeStoredConversationId(reference.conversation.id)
174+
: "";
175+
return storedConversationId || normalizeStoredConversationId(rawConversationId);
176+
}
177+
168178
function parseLegacyPoll(value: unknown): MSTeamsPoll | null {
169179
if (!isRecord(value)) {
170180
return null;
@@ -300,25 +310,40 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
300310
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
301311
maxEntries: MSTEAMS_SQLITE_MAX_CONVERSATION_ROWS,
302312
});
313+
let attempted = 0;
303314
let imported = 0;
304-
for (const [rawConversationId, reference] of selectRetainedMSTeamsConversations(
305-
state.conversations,
306-
)) {
307-
const conversationId = normalizeStoredConversationId(rawConversationId);
315+
let present = 0;
316+
let skipped = 0;
317+
const retainedConversations = selectRetainedMSTeamsConversations(state.conversations);
318+
for (const [rawConversationId, reference] of retainedConversations) {
319+
const conversationId = resolveLegacyConversationId(rawConversationId, reference);
308320
if (!conversationId) {
321+
skipped++;
309322
continue;
310323
}
324+
attempted++;
325+
const key = buildMSTeamsConversationStateKey(conversationId);
311326
const didImport = await store.registerIfAbsent(
312-
buildMSTeamsConversationStateKey(conversationId),
327+
key,
313328
prepareMSTeamsConversationReferenceForStorage(conversationId, reference),
314329
);
315330
if (didImport) {
316331
imported++;
317332
}
333+
if (await store.lookup(key)) {
334+
present++;
335+
}
318336
}
319337
changes.push(
320338
`Migrated ${imported} ${MSTEAMS_PLUGIN_ID} conversation ${imported === 1 ? "entry" : "entries"} -> plugin state`,
321339
);
340+
if ((attempted > 0 && present < attempted) || skipped > 0) {
341+
const retained = retainedConversations.length;
342+
warnings.push(
343+
`Left ${MSTEAMS_PLUGIN_ID} conversation legacy source in place because ${present} of ${retained} retained ${retained === 1 ? "entry was" : "entries were"} visible in plugin state after migration`,
344+
);
345+
return { changes, warnings };
346+
}
322347
await archiveLegacyStateSource({
323348
filePath,
324349
label: `${MSTEAMS_PLUGIN_ID} conversation`,

src/plugin-sdk/plugin-state-test-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
createPluginStateKeyedStore as createPluginStateKeyedStoreForTests,
66
createPluginStateSyncKeyedStore as createPluginStateSyncKeyedStoreForTests,
77
resetPluginStateStoreForTests,
8+
setMaxPluginStateEntriesPerPluginForTests,
89
} from "../plugin-state/plugin-state-store.js";
910
export { createChannelIngressQueue as createChannelIngressQueueForTests } from "../channels/message/ingress-queue.js";
1011
export { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";

0 commit comments

Comments
 (0)