Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/owner-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function makeSyncSelf(http: HttpForOrg, provider: ConfigProvider, log: Lo

let member: MemberRecord;
try {
member = (await http.getForOrg(orgId, http.apiPath(`/members/${selfMemberId}`))) as MemberRecord;
member = (await http.getForOrg(orgId, http.apiPath(`/members/${encodeURIComponent(selfMemberId)}`))) as MemberRecord;
} catch (err) {
log.warn?.(`[${orgId}] owner-sync: fetch self member failed: ${err instanceof Error ? err.message : String(err)} — keeping local owner`);
return { nameReady: false, reason: `fetch self member failed: ${err instanceof Error ? err.message : String(err)}` };
Expand All @@ -73,18 +73,35 @@ export function makeSyncSelf(http: HttpForOrg, provider: ConfigProvider, log: Lo
if (!coreOwnerId) return { nameReady: true, displayName: coreDisplayName || undefined };

const localOwnerId = orgConfig.owner?.member_id || "";
if (coreOwnerId === localOwnerId) return { nameReady: true, displayName: coreDisplayName || undefined };
const localOwnerName = orgConfig.owner?.name || "";
// Fully in sync (id matches AND we already have a name) → nothing to do. Do NOT
// early-return when the id matches but the local name is EMPTY: a prior owner-name
// fetch may have failed/timed out and bound an empty name (the catch below swallows
// it — name is cosmetic, must not block the bind); this is the path that backfills
// it on a later tick.
if (coreOwnerId === localOwnerId && localOwnerName)
return { nameReady: true, displayName: coreDisplayName || undefined };

let ownerName = "";
try {
const ownerMember = (await http.getForOrg(orgId, http.apiPath(`/members/${coreOwnerId}`))) as MemberRecord;
const ownerMember = (await http.getForOrg(orgId, http.apiPath(`/members/${encodeURIComponent(coreOwnerId)}`))) as MemberRecord;
ownerName = ownerMember?.display_name || ownerMember?.username || "";
} catch {
// owner display_name is cosmetic — a fetch failure must not block the owner bind.
}

// Only write when something ACTUALLY changed — a new owner id, or a non-empty
// fetched name that differs from the local one (the backfill case). If core still
// returns no name for an already-bound owner, skip the write so a steady state does
// not re-persist an empty name on every periodic tick.
const idChanged = coreOwnerId !== localOwnerId;
const nameChanged = !!ownerName && ownerName !== localOwnerName;
if (!idChanged && !nameChanged)
return { nameReady: true, displayName: coreDisplayName || undefined };

provider.setOwner(orgId, coreOwnerId, ownerName);
orgConfig.owner = { member_id: coreOwnerId, name: ownerName };
log.info?.(`[${orgId}] owner synced from core: ${localOwnerId || "(none)"} → ${coreOwnerId}${ownerName ? ` (${ownerName})` : ""}`);
log.info?.(`[${orgId}] owner synced from core: ${localOwnerId || "(none)"} → ${coreOwnerId}${ownerName ? ` (${ownerName})` : ""}${idChanged ? "" : " (name backfill)"}`);

return { nameReady: true, displayName: coreDisplayName || undefined };
};
Expand Down
43 changes: 43 additions & 0 deletions test/owner-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,47 @@ describe("makeSyncSelf", () => {
expect(res.nameReady).toBe(false);
expect(org.owner).toEqual({ member_id: "local_owner", name: "Local" });
});

it("当 owner 名字首拉失败先存空名后,下一次 sync 应回填名字(不因 owner 未变而永久空名)", async () => {
const { provider, org, reloadedOrg } = setup();
// owner_1 暂不在册 → 首次绑定时名字拉取 404 失败(cosmetic,不阻塞绑定)。
const members: Record<string, Record<string, unknown>> = {
m_self: { display_name: "Codex", owner_member_id: "owner_1" },
};
const sync = makeSyncSelf(fakeHttp(members), provider, quietLog);

// 第一次:owner 名字拉取失败 → 用空名绑定 owner。
await sync(org);
expect(org.owner).toEqual({ member_id: "owner_1", name: "" });

// owner 名字随后可用(超时/短暂失败恢复)。
members.owner_1 = { display_name: "Owner One" };

// 第二次:owner 未变但本地名字为空 → 应继续回填,而非在「owner 未变」处提前返回把空名留下。
await sync(org);
expect(org.owner).toEqual({ member_id: "owner_1", name: "Owner One" });
expect(reloadedOrg().owner).toEqual({ member_id: "owner_1", name: "Owner One" });
});

it("owner id 匹配且 core 仍无名字时,不做冗余写入(稳态不每 tick 重复 persist 空名)", async () => {
const { provider, org } = setup({ member_id: "owner_1", name: "" });
let setOwnerCalls = 0;
const origSetOwner = provider.setOwner.bind(provider);
provider.setOwner = ((...args: Parameters<typeof provider.setOwner>) => {
setOwnerCalls++;
return origSetOwner(...args);
}) as typeof provider.setOwner;

// owner_1 在册但无 display_name/username → 名字仍解析为空。
const http = fakeHttp({
m_self: { display_name: "Codex", owner_member_id: "owner_1" },
owner_1: {},
});
const res = await makeSyncSelf(http, provider, quietLog)(org);

// id 未变且名字仍为空 → 无变化 → 不调用 setOwner(不重复落盘空名)。
expect(res.nameReady).toBe(true);
expect(setOwnerCalls).toBe(0);
expect(org.owner).toEqual({ member_id: "owner_1", name: "" });
});
});
Loading