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
16 changes: 10 additions & 6 deletions packages/chat-ui/src/blocks/connect-github-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ export type ConnectGithubActions = {
* message's own `ConnectGithubBlockData`. */
readonly getConnectState: (messageId: string) => Promise<ConnectGithubQuery>;
/**
* Folds this room's live stream straight into the card's state —
* never a second `getConnectState` call. The host wires this to the
* workbench's existing `chat.settings` SSE event (folded through
* `./connect-github-stream.ts`'s `applyConnectGithubSettingsEvent`),
* the same event `templateReposSettingsPatch` writes onto once a
* person starts reviewing repos. Returns an unsubscribe.
* Registers for this card's state updates — the host fans an update
* out to every subscriber after its own actions (`submitAccessToken`,
* `startReviewing`, `skip`) change something, re-reading
* `getConnectState`. A credential completed elsewhere (the Plugins
* page, another tab) settles this connector's entry on
* `@corbits/workflow-catalog`'s `template/pendingConnections`
* (CL-6463's `settleConnectedService`) so the *next* fresh
* `getConnectState` — e.g. on this card's next mount — already reads
* connected, since that read resolves against the real credential,
* never this setting. Returns an unsubscribe.
*/
readonly subscribeConnectState: (
messageId: string,
Expand Down
16 changes: 8 additions & 8 deletions packages/chat-ui/src/blocks/connect-github-block-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
// framing every other block's "no port, no feature" fallback uses.
//
// CL-6463: a card's own successful PAT submit is the one change this
// container never waits on a fold for. `subscribeConnectState` folds
// whatever a host chooses to publish, and the room's `chat.settings`
// event (the only thing `connect-github-stream.ts` can fold) is written
// by the later, unrelated repo-review PATCH — never by the credential
// save itself. So `submitAccessToken` gets its own explicit
// `getConnectState` refetch here, run once as the direct consequence of
// that one submit — not a poll, and not a second source of truth
// alongside the fold; the fold keeps handling every other update.
// container never waits on the host to fan out on its own — a credential
// saved through *this* card's field gets its own explicit `getConnectState`
// refetch below, run once as the direct consequence of that one submit
// (not a poll). A credential saved anywhere else (the Plugins page,
// another tab) settles through `packages/chat/src/connect-pending.ts`'s
// `settleConnectedService`, which clears this room's own
// `template/pendingConnections` entry — so this card's next mount already
// reads connected without needing a push while it sits open.
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectGithubBlockData } from "@corbits/chat/blocks";

Expand Down
48 changes: 0 additions & 48 deletions packages/chat-ui/src/blocks/connect-github-stream.test.ts

This file was deleted.

41 changes: 0 additions & 41 deletions packages/chat-ui/src/blocks/connect-github-stream.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ export type {
ConnectServiceResult,
ConnectAffordance,
} from "./blocks/connect-service-actions";
export { applyConnectGithubSettingsEvent } from "./blocks/connect-github-stream";

export {
TextPart,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// CL-6463: a successful PAT submit must flip the connect-github card to
// connected on its own — never leaning on a host that happens to fan the
// change out through `subscribeConnectState` (real hosts vary, and
// `chat.settings` never carries the credential-save path at all; see
// `connect-github-stream.ts`'s own header). These fakes deliberately never
// change out through `subscribeConnectState` (real hosts vary: the real
// `createChatConnectGithubActions` only fans out after its own actions,
// never off a live `chat.settings` push). These fakes deliberately never
// call the subscriber from `submitAccessToken`, so a pass here proves the
// container drove its own state from the submit's own result — not from a
// side channel a differently-wired host might forget.
Expand Down
37 changes: 12 additions & 25 deletions packages/chat-ui/test/connect-github-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@
// 1. `@corbits/workflow-catalog`'s real `startReviewingRepos` mints
// one grant and one webhook trigger per selected repo, and records
// the selection.
// 2. The card settles into its connected state purely by folding a
// `chat.settings` stream event through the real
// `applyConnectGithubSettingsEvent` — never a second
// `getConnectState` fetch.
// 2. The card settles into its connected state by the host fanning an
// update out to `subscribeConnectState`'s listener — the same
// channel a real host fans out on after any of its own actions.
import { afterEach, describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";

import type { ChatSettingsEventData } from "@corbits/chat/stream-events";
import {
startReviewingRepos,
type ConnectGithubSetupPorts,
Expand All @@ -28,7 +26,6 @@ import type {
ConnectGithubQuery,
ConnectGithubRepo,
} from "../src/blocks/connect-github-actions";
import { applyConnectGithubSettingsEvent } from "../src/blocks/connect-github-stream";
import { WorkbenchTimeline } from "../src/timeline";

const REPOS: readonly ConnectGithubRepo[] = [
Expand Down Expand Up @@ -60,10 +57,8 @@ function messageWithConnectGithubBlock(): MessageItem[] {
/** The whole flow's fakes, wired the way a real host would wire them:
* `ConnectGithubActions.startReviewing` calls the real
* `startReviewingRepos` against fake grant/trigger/settings ports, then
* simulates the room's settings-PATCH route publishing `chat.settings`
* (the real plumbing every `template/*` write already rides — see
* `packages/chat/src/routes.ts`), folded through the real
* `applyConnectGithubSettingsEvent`. */
* fans the settled state out to `subscribeConnectState`'s listener —
* the same thing a real host does after any of its own actions. */
function buildHarness() {
const grantedRepos: string[] = [];
const createdTriggerRepos: string[] = [];
Expand Down Expand Up @@ -123,20 +118,12 @@ function buildHarness() {
},
async startReviewing(repoIds) {
const result = await startReviewingRepos(repoIds, REPOS, setupPorts);
const settingsEvent: ChatSettingsEventData = {
updatedBy: "prn_owner",
settings: {
"template/pendingConnections": [],
"template/selectedRepos": repoIds,
},
};
const folded = applyConnectGithubSettingsEvent(
settingsEvent,
"github",
"octocat",
REPOS,
);
if (folded !== undefined) subscriber?.(folded);
subscriber?.({
kind: "connected",
orgName: "octocat",
repos: REPOS,
selectedRepoIds: repoIds,
});
return { startedTriggerCount: result.createdTriggerIds.length };
},
async skip() {},
Expand Down Expand Up @@ -193,7 +180,7 @@ describe("connect-github round trip (CL-6345)", () => {
expect(el.querySelector(".chat-block-connect-repo-row")).toBeNull();
});

test("connect -> list repos -> pick three -> start reviewing mints a grant and a webhook trigger per repo, and settles into the connected state via the stream, never a second fetch", async () => {
test("connect -> list repos -> pick three -> start reviewing mints a grant and a webhook trigger per repo, and settles into the connected state via the host's fan-out, never a second fetch", async () => {
const harness = buildHarness();
const el = await mount(harness.actions);

Expand Down
55 changes: 47 additions & 8 deletions packages/chat/src/connect-pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
// a message under the connecting person's own address — which routes to
// the room's host agent through the ordinary message path, so the agent
// resumes the task it parked without any new trigger machinery.
//
// CL-6463: the code-review template's own GitHub connect card registers
// under a second, template-owned key (`@corbits/workflow-catalog`'s
// `template/pendingConnections`) instead of `connections/pending` — a
// credential completed anywhere other than that card's own submit (the
// Plugins page, another tab) never reached it. Rather than stand up a
// second settle path for that one key, this module settles both: a
// connector becoming connected is one event, and every room's settling
// belongs to one mechanism, not two parallel key conventions.
import { type } from "arktype";

import {
Expand All @@ -19,16 +28,32 @@ import { ConnectServiceBlockData } from "./blocks";

export const CONNECTIONS_PENDING_KEY = "connections/pending";

/** The code-review template's own pending-connections key
* (`@corbits/workflow-catalog`'s `templateSettingsPatch`/
* `templateReposSettingsPatch`) — a room minted from that template
* tracks its GitHub card's pending state here instead of under
* `CONNECTIONS_PENDING_KEY`. `settleConnectedService` knows this one
* literal key so a credential settling still reaches that card, without
* standing up a second, template-scoped settle function. */
const TEMPLATE_PENDING_CONNECTIONS_KEY = "template/pendingConnections";

const PendingConnections = type("string[]");

export function pendingConnectionsOf(
function pendingConnectionsAt(
settings: Record<string, unknown>,
key: string,
): readonly string[] {
const parsed = PendingConnections(settings[CONNECTIONS_PENDING_KEY]);
const parsed = PendingConnections(settings[key]);
if (parsed instanceof type.errors) return [];
return parsed;
}

export function pendingConnectionsOf(
settings: Record<string, unknown>,
): readonly string[] {
return pendingConnectionsAt(settings, CONNECTIONS_PENDING_KEY);
}

/** Connector ids named by `connect-service` block parts in a message —
* parsed through the block's own schema so a malformed block registers
* nothing. */
Expand Down Expand Up @@ -85,18 +110,32 @@ export async function settleConnectedService(
): Promise<void> {
const rows = await deps.store.listWorkbenchSettings(input.tenantId);
const connected = bareConnectorId(input.connectorId);
const isSettled = (entry: string) => bareConnectorId(entry) === connected;
for (const row of rows) {
const pending = pendingConnectionsOf(row.settings);
if (!pending.some((entry) => bareConnectorId(entry) === connected)) {
continue;
}
const remaining = pending.filter(
(entry) => bareConnectorId(entry) !== connected,
const templatePending = pendingConnectionsAt(
row.settings,
TEMPLATE_PENDING_CONNECTIONS_KEY,
);
const matchedPending = pending.some(isSettled);
const matchedTemplatePending = templatePending.some(isSettled);
if (!matchedPending && !matchedTemplatePending) continue;

const settingsPatch: Record<string, unknown> = { ...row.settings };
if (matchedPending) {
settingsPatch[CONNECTIONS_PENDING_KEY] = pending.filter(
(entry) => !isSettled(entry),
);
}
if (matchedTemplatePending) {
settingsPatch[TEMPLATE_PENDING_CONNECTIONS_KEY] = templatePending.filter(
(entry) => !isSettled(entry),
);
}
const updated = await deps.store.updateWorkbenchSettings({
tenantId: input.tenantId,
workbenchId: row.workbenchId,
settings: { ...row.settings, [CONNECTIONS_PENDING_KEY]: remaining },
settings: settingsPatch,
updatedBy: input.principalId,
});
deps.publish(row.workbenchId, {
Expand Down
51 changes: 51 additions & 0 deletions packages/chat/test/connect-pending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ async function seedWorkbench(
});
}

async function seedTemplateWorkbench(
store: ReturnType<typeof createInMemoryChatStore>,
workbenchId: string,
templatePending: readonly string[],
) {
await store.createWorkbenchSettings({
tenantId: TENANT.id,
workbenchId,
settings: {
"chat/kind": "workbench",
"chat/participants": [
{ address: HUMAN_ADDRESS, handle: "owner" },
{ address: AGENT_ADDRESS, handle: "myra" },
],
"template/id": "code-review",
"template/pendingConnections": templatePending,
},
updatedBy: "prn_owner",
});
}

function buildDeps() {
const store = createInMemoryChatStore();
const roomMessages = createInMemoryRoomMessageStore();
Expand Down Expand Up @@ -110,6 +131,36 @@ test("matches a pending mcp-prefixed entry when the preset connects under its ba
expect(settled?.settings["connections/pending"]).toEqual([]);
});

test("settles a room whose GitHub card is pending under the code-review template's own key — a credential created out of band (not through that card's own submit) still reaches it", async () => {
const { store, roomMessages, published, deps } = buildDeps();
await seedTemplateWorkbench(store, "chan_template", ["github"]);

await settleConnectedService(deps, {
tenantId: TENANT.id,
principalId: "prn_owner",
connectorId: "github",
displayName: "GitHub",
});

const settled = await store.getWorkbenchSettings(TENANT.id, "chan_template");
expect(settled?.settings["template/pendingConnections"]).toEqual([]);
expect(settled?.settings["template/id"]).toBe("code-review");
expect(
published.some(
(entry) =>
entry.workbenchId === "chan_template" &&
entry.event.type === "chat.settings",
),
).toBe(true);

const listed = await roomMessages.listMessages({
tenantId: TENANT.id,
workbenchId: "chan_template",
});
expect(listed.items).toHaveLength(1);
expect(JSON.stringify(listed.items[0]?.parts)).toContain("GitHub");
});

test("a connector no room is waiting on settles nothing", async () => {
const { store, roomMessages, published, deps } = buildDeps();
await seedWorkbench(store, "chan_1", ["exa"]);
Expand Down
Loading
Loading