-
Notifications
You must be signed in to change notification settings - Fork 89
feat(gateway): mirror invite createInvite from daemon → gateway DB (Track B PR-B-1) #30509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| /** | ||
| * mirrorInviteToGateway — daemon-side best-effort behavior. | ||
| * | ||
| * Track B PR-B-1: a mirror failure must NEVER throw to the createIngressInvite | ||
| * caller (the daemon-owned authoritative write is the source of truth). This | ||
| * test pins that contract by mocking the gateway IPC client to reject and | ||
| * asserting the helper resolves normally. | ||
| * | ||
| * Also asserts the wire payload contains every field daemon-side | ||
| * IngressInvite carries — so future schema additions on either side don't | ||
| * silently drift. | ||
| */ | ||
|
|
||
| import { beforeEach, describe, expect, mock, test } from "bun:test"; | ||
|
|
||
| type IpcCallArgs = { | ||
| method: string; | ||
| params?: Record<string, unknown>; | ||
| }; | ||
|
|
||
| const ipcCalls: IpcCallArgs[] = []; | ||
| let ipcCallImpl: ( | ||
| method: string, | ||
| params?: Record<string, unknown>, | ||
| ) => Promise<unknown> = async () => ({}); | ||
|
|
||
| mock.module("../../ipc/gateway-client.js", () => ({ | ||
| ipcCall: async (method: string, params?: Record<string, unknown>) => { | ||
| ipcCalls.push({ method, params }); | ||
| return ipcCallImpl(method, params); | ||
| }, | ||
| })); | ||
|
|
||
| // invite-service pulls a bunch of LLM/channel-adapter modules eagerly. Stub | ||
| // the ones that touch real I/O so the import doesn't side-effect. | ||
| mock.module("../channel-invite-transport.js", () => ({ | ||
| getInviteAdapterRegistry: () => ({}), | ||
| resolveAdapterHandle: () => undefined, | ||
| })); | ||
| mock.module("../invite-instruction-generator.js", () => ({ | ||
| generateInviteInstruction: async () => "", | ||
| })); | ||
| mock.module("../invite-redemption-service.js", () => ({ | ||
| redeemInvite: async () => ({}), | ||
| redeemVoiceInviteCode: async () => ({}), | ||
| redeemInviteByCode: async () => ({}), | ||
| })); | ||
| mock.module("../calls/call-domain.js", () => ({ | ||
| startInviteCall: async () => ({}), | ||
| })); | ||
|
|
||
| const { mirrorInviteToGateway } = await import("../invite-service.js"); | ||
|
|
||
| const baseInvite = () => ({ | ||
| id: "inv-daemon-1", | ||
| sourceChannel: "telegram", | ||
| tokenHash: "tok-h", | ||
| sourceConversationId: null, | ||
| note: null, | ||
| maxUses: 1, | ||
| useCount: 0, | ||
| expiresAt: Date.now() + 60_000, | ||
| status: "active" as const, | ||
| redeemedByExternalUserId: null, | ||
| redeemedByExternalChatId: null, | ||
| redeemedAt: null, | ||
| expectedExternalUserId: null, | ||
| voiceCodeHash: null, | ||
| voiceCodeDigits: null, | ||
| inviteCodeHash: null, | ||
| friendName: null, | ||
| guardianName: null, | ||
| contactId: "co-1", | ||
| createdAt: Date.now(), | ||
| updatedAt: Date.now(), | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| ipcCalls.length = 0; | ||
| ipcCallImpl = async () => ({}); | ||
| }); | ||
|
|
||
| describe("mirrorInviteToGateway", () => { | ||
| test("fires mirror_invite_create with the full payload", async () => { | ||
| const invite = baseInvite(); | ||
| await mirrorInviteToGateway(invite); | ||
|
|
||
| expect(ipcCalls).toHaveLength(1); | ||
| expect(ipcCalls[0]!.method).toBe("mirror_invite_create"); | ||
| const params = ipcCalls[0]!.params!; | ||
|
|
||
| // Spot-check every field that flows over the wire. | ||
| for (const key of [ | ||
| "id", | ||
| "sourceChannel", | ||
| "tokenHash", | ||
| "sourceConversationId", | ||
| "note", | ||
| "maxUses", | ||
| "useCount", | ||
| "expiresAt", | ||
| "status", | ||
| "redeemedByExternalUserId", | ||
| "redeemedByExternalChatId", | ||
| "redeemedAt", | ||
| "expectedExternalUserId", | ||
| "voiceCodeHash", | ||
| "voiceCodeDigits", | ||
| "inviteCodeHash", | ||
| "friendName", | ||
| "guardianName", | ||
| "contactId", | ||
| "createdAt", | ||
| "updatedAt", | ||
| ] as const) { | ||
| expect(params).toHaveProperty(key); | ||
| } | ||
|
|
||
| expect(params.id).toBe(invite.id); | ||
| expect(params.contactId).toBe(invite.contactId); | ||
| expect(params.tokenHash).toBe(invite.tokenHash); | ||
| }); | ||
|
|
||
| test("swallows IPC errors (best-effort dual-write)", async () => { | ||
| ipcCallImpl = async () => { | ||
| throw new Error("gateway down"); | ||
| }; | ||
|
|
||
| // The promise must resolve, not reject. | ||
| await expect(mirrorInviteToGateway(baseInvite())).resolves.toBeUndefined(); | ||
| expect(ipcCalls).toHaveLength(1); | ||
| }); | ||
|
|
||
| test("forwards voice-invite fields when present", async () => { | ||
| const invite = { | ||
| ...baseInvite(), | ||
| sourceChannel: "phone", | ||
| expectedExternalUserId: "+15551234567", | ||
| voiceCodeHash: "voice-h", | ||
| voiceCodeDigits: 6, | ||
| friendName: "Alice", | ||
| guardianName: "Bob", | ||
| }; | ||
| await mirrorInviteToGateway(invite); | ||
|
|
||
| const params = ipcCalls[0]!.params!; | ||
| expect(params.sourceChannel).toBe("phone"); | ||
| expect(params.expectedExternalUserId).toBe("+15551234567"); | ||
| expect(params.voiceCodeHash).toBe("voice-h"); | ||
| expect(params.voiceCodeDigits).toBe(6); | ||
| expect(params.friendName).toBe("Alice"); | ||
| expect(params.guardianName).toBe("Bob"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
|
|
||
| import { startInviteCall } from "../calls/call-domain.js"; | ||
| import { isChannelId } from "../channels/types.js"; | ||
| import { ipcCall } from "../ipc/gateway-client.js"; | ||
| import { | ||
| createInvite, | ||
| findById, | ||
|
|
@@ -26,6 +27,7 @@ import { | |
| DEFAULT_USER_REFERENCE, | ||
| resolveGuardianName, | ||
| } from "../prompts/user-reference.js"; | ||
| import { getLogger } from "../util/logger.js"; | ||
| import { isValidE164 } from "../util/phone.js"; | ||
| import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js"; | ||
| import { | ||
|
|
@@ -39,6 +41,51 @@ import { | |
| type VoiceRedemptionOutcome, | ||
| } from "./invite-redemption-service.js"; | ||
|
|
||
| const log = getLogger("invite-service"); | ||
|
|
||
| /** | ||
| * Mirror an authoritative daemon-side invite row to the gateway's | ||
| * `ingress_invites` table via IPC. Best-effort: logs a warn on failure | ||
| * and never throws — the daemon's own write is the source of truth during | ||
| * Track B PR-B-1. | ||
| * | ||
| * See: memory/concepts/workstreams/track-b-invite-redemption.md | ||
| */ | ||
| export async function mirrorInviteToGateway( | ||
| invite: IngressInvite, | ||
| ): Promise<void> { | ||
| try { | ||
| await ipcCall("mirror_invite_create", { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No. We do not call any gateway write actions from the assistant daemon |
||
| id: invite.id, | ||
| sourceChannel: invite.sourceChannel, | ||
| tokenHash: invite.tokenHash, | ||
| sourceConversationId: invite.sourceConversationId, | ||
| note: invite.note, | ||
| maxUses: invite.maxUses, | ||
| useCount: invite.useCount, | ||
| expiresAt: invite.expiresAt, | ||
| status: invite.status, | ||
| redeemedByExternalUserId: invite.redeemedByExternalUserId, | ||
| redeemedByExternalChatId: invite.redeemedByExternalChatId, | ||
| redeemedAt: invite.redeemedAt, | ||
| expectedExternalUserId: invite.expectedExternalUserId, | ||
| voiceCodeHash: invite.voiceCodeHash, | ||
| voiceCodeDigits: invite.voiceCodeDigits, | ||
| inviteCodeHash: invite.inviteCodeHash, | ||
| friendName: invite.friendName, | ||
| guardianName: invite.guardianName, | ||
| contactId: invite.contactId, | ||
| createdAt: invite.createdAt, | ||
| updatedAt: invite.updatedAt, | ||
| }); | ||
| } catch (err) { | ||
| log.warn( | ||
| { err, inviteId: invite.id, contactId: invite.contactId }, | ||
| "createIngressInvite: gateway mirror dual-write failed (best-effort)", | ||
| ); | ||
| } | ||
|
Comment on lines
+81
to
+86
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 mirrorInviteToGateway catch block is dead code — ipcCall never rejects The Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Response shapes — used by both HTTP routes and message handlers | ||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -232,6 +279,13 @@ export async function createIngressInvite(params: { | |
| : { inviteCodeHash }), | ||
| }); | ||
|
|
||
| // Dual-write to the gateway's mirror table. Best-effort during Track B | ||
| // PR-B-1 — gateway DB is the future source of truth, assistant DB is the | ||
| // present-day source of truth, and the daemon owns invite creation today | ||
| // (LLM-generated guardian-instruction + channel-adapter resolution stay | ||
| // daemon-side for now). PR-B-2 will flip redemption gateway-native. | ||
| await mirrorInviteToGateway(invite); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 mirrorInviteToGateway blocks invite creation on IPC round-trip At Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| // Build invite instruction for non-voice invites via LLM generation | ||
| let guardianInstruction: string | undefined; | ||
| let channelHandle: string | undefined; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This call assumes mirror failures will throw and be caught, but the IPC helper returns
undefinedfor transport and handler errors instead of rejecting. As a result, normal mirror failures bypass thecatchblock and never emit the invite-scoped warning (inviteId/contactId), making dual-write drift harder to diagnose; you need an explicitresult === undefinedcheck after the call.Useful? React with 👍 / 👎.